Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

), x + 26, y + 172, { size: sz, weight: 800, ls: -2.6, color: it.win ? C.blue : C.ink, alpha: va });\n if (it.sub) text(ctx, it.sub, x + 26, y + 212, { size: 21, weight: 500, ls: -.2, color: C.ink2, alpha: Math.max(.35, va) });\n if (it.badge) { const b = it.badgeAt != null ? S(t - it.badgeAt, 13) : 1;\n if (b > .002) { ctx.save(); ctx.globalAlpha *= Math.min(1, b * 1.3); ctx.translate(0, (1 - b) * 10); pill(R, ctx, x + w - 22, it.badgeTop ? y + 52 : y + h - 46, it.badge, { align: 'right', size: 28, weight: 800, solid: it.win, blue: !it.win }); ctx.restore(); } }\n ring(ctx, x, y, w, h, r, e.ring);\n },\n\n bars(R, ctx, it, e, t, s, i0) {\n const { x, w } = it, h = it.h ?? 118, gap = it.gap ?? 16, max = it.max || Math.max(...it.rows.map(r => r.value));\n it.rows.forEach((rw, j) => {\n const at = rw.at ?? e.at + j * .12, p = S(t - at, 13); if (p \u003c= .002) return;\n const y = it.y + j * (h + gap) + (1 - p) * 20, mid = y + 50;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.3);\n glass(ctx, x, y, w, h, 28);\n if (rw.logo) logo(R, ctx, rw.logo, x + 24, y + (h - 64) / 2, 64);\n const tx = x + 108;\n text(ctx, rw.label, tx, mid, { size: 28, weight: 700, ls: -.5 });\n if (rw.sub) text(ctx, rw.sub, tx + measure(ctx, rw.label, 28, 700, -.5) + 14, mid, { size: 21, weight: 500, ls: -.2, color: C.ink2 });\n const cp = count(rw, t, at + .2), va = S(t - (rw.countAt ?? at + .2), 16);\n const tw = w - 108 - 28, ty = y + h - 38;\n ctx.beginPath(); ctx.roundRect(tx, ty, tw, 16, 8); ctx.fillStyle = '#EDF1F5'; ctx.fill();\n const fw = tw * clamp(rw.value / max) * cp;\n if (fw > 1) { ctx.beginPath(); ctx.roundRect(tx, ty, Math.max(16, fw), 16, 8); ctx.fillStyle = rw.win ? C.blue : '#A9B2BD'; ctx.fill(); }\n if (va > .002) text(ctx, (rw.prefix || '') + fmt(rw.value * cp, rw.dec) + (rw.suffix ?? '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

), x + w - 28, mid + 2, { size: 38, weight: 800, ls: -1.4, align: 'right', color: rw.win ? C.blue : C.ink, alpha: va });\n ring(ctx, x, y, w, h, 28, rw.ringAt != null ? S(t - rw.ringAt, 12) : 0);\n ctx.restore();\n });\n },\n\n tweet(R, ctx, it, e) {\n const { x, y, w } = it, h = it.h ?? 96, mid = y + h / 2;\n glass(ctx, x, y, w, h, 26);\n if (it.img) logo(R, ctx, it.img, x + 20, mid - 28, 56, { round: true });\n text(ctx, it.name || '', x + 92, mid - 4, { size: 24, weight: 700, ls: -.4 });\n if (it.handle) text(ctx, it.handle, x + 92, mid + 24, { size: 20, weight: 500, color: C.ink2 });\n if (it.text) text(ctx, '« ' + it.text + ' »', x + w - 28, midBase(mid, 28), { size: 28, weight: 600, ls: -.4, align: 'right', color: C.ink });\n },\n\n logo(R, ctx, it, e) {\n const s = it.s ?? 120, x = it.x - s / 2, y = it.y - s / 2;\n if (it.tile) {\n const T = s * 1.34, X = it.x - T / 2, Y = it.y - T / 2;\n glass(ctx, X, Y, T, T, T * .26); logo(R, ctx, it.logo, x, y, s, { gray: e.dim }); ring(ctx, X, Y, T, T, T * .26, e.ring);\n } else { ctx.save(); K.drop(ctx, .10, 24, 8); logo(R, ctx, it.logo, x, y, s, { gray: e.dim }); ctx.restore(); ring(ctx, x, y, s, s, s * .24, e.ring); }\n if (it.label) text(ctx, it.label, it.x, it.y + (it.tile ? s * .67 : s / 2) + 46, { size: it.labelSize ?? 30, weight: 700, ls: -.6, align: 'center', color: e.dim > .5 ? C.ink3 : C.ink });\n },\n\n img(R, ctx, it, e) {\n const im = R.img('/p/' + it.img); if (!im) return;\n const k = Math.min(it.w / im.width, it.h / im.height), w = im.width * k, h = im.height * k;\n ctx.save(); if (e.dim) addFilter(ctx, `grayscale(${e.dim.toFixed(3)})`); ctx.imageSmoothingQuality = 'high';\n ctx.drawImage(im, it.x - w / 2, it.y - h / 2, w, h); ctx.restore();\n },\n\n pill(R, ctx, it) {\n const sz = it.size ?? 30;\n ctx.save(); if (it.lift !== false) { const w = measure(ctx, it.text, sz, 700, -.2) + sz * 1.6 + (it.logo ? sz * 1.75 : 0), h = Math.round(sz * 2); K.shadow(ctx, it.x - w / 2, it.y - h / 2, w, h, h / 2, 22, .07, 8); }\n pill(R, ctx, it.x, it.y, it.text, { size: sz, weight: 700, solid: it.blue, logo: it.logo }); ctx.restore();\n },\n\n text(R, ctx, it) {\n const color = it.color === 'blue' ? C.blue : it.color === 'ink2' ? C.ink2 : it.color === 'ink3' ? C.ink3 : C.ink;\n text(ctx, it.text, it.x, it.y, { size: it.size ?? 34, weight: it.weight ?? 700, ls: it.ls ?? -.6, align: it.align || 'center', color });\n },\n\n arrow(R, ctx, it, e) {\n const p = e.p, x2 = it.x + it.w * p, y = it.y; if (p \u003c= .01) return;\n ctx.save(); ctx.strokeStyle = it.blue ? C.blue : C.ink3; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.beginPath(); ctx.moveTo(it.x, y); ctx.lineTo(x2, y); ctx.moveTo(x2 - 18, y - 18); ctx.lineTo(x2, y); ctx.lineTo(x2 - 18, y + 18); ctx.stroke(); ctx.restore();\n },\n};\n\n// ---------------------------------------------------------------- slide\nfunction headline(ctx, s, t, t0) {\n const lines = Array.isArray(s.title) ? s.title : s.title ? [s.title] : [], hl = new Set((s.hl || []).map(w => w.toLowerCase()));\n const base = s.size ?? 90, maxW = s.maxW ?? 960, wt = 800;\n let size = base; const widest = Math.max(0, ...lines.map(l => measure(ctx, l, base, wt, -3.5)));\n if (widest > maxW) size = base * maxW / widest;\n const ls = -3.5 * size / 90, y0 = s.y ?? (lines.length > 1 ? 350 : 395);\n lines.forEach((line, i) => {\n const p = S(t - (t0 + .06 + i * .08), 12); if (p \u003c= .002) return;\n const ws = line.split(' '), sp = measure(ctx, ' ', size, wt, ls), ww = ws.map(w => measure(ctx, w, size, wt, ls));\n let x = 540 - (ww.reduce((a, b) => a + b, 0) + sp * (ws.length - 1)) / 2;\n const y = y0 + i * size * 1.0 + (1 - p) * 22, b = (1 - p) * 8;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.3); if (b > .4) addFilter(ctx, `blur(${b.toFixed(1)}px)`);\n ws.forEach((w, j) => { text(ctx, w, x, y, { size, weight: wt, ls, color: hl.has(w.toLowerCase().replace(/[.,:;!?]+$/, '')) ? C.blue : C.ink }); x += ww[j] + sp; });\n ctx.restore();\n });\n}\n\nfunction slide(R, s, t) {\n const ctx = R.ctx, t0 = s.from - (s.pre || 0), out = s.to != null ? S(t - (s.to - .1), 22) : 0;\n if (out > .995) return;\n ctx.save(); ctx.globalAlpha *= 1 - out; if (out > .02) addFilter(ctx, `blur(${(out * 10).toFixed(1)}px)`);\n if (s.eyebrow) {\n const p = S(t - t0, 12);\n if (p > .002) text(ctx, s.eyebrow, 540, midBase(s.eyebrowY ?? 231, 30) + (1 - p) * 14, { size: 30, weight: 600, ls: -.3, align: 'center', color: C.blue, alpha: Math.min(1, p * 1.3) });\n }\n headline(ctx, s, t, t0);\n (s.items || []).forEach((it, i) => {\n const at = it.at ?? t0 + .25 + i * .12, p = S(t - at, 13); if (p \u003c= .002 && !it.ghost) return;\n const o = it.outAt != null ? S(t - it.outAt, 18) : 0; if (o > .995) return;\n const pe = it.ghost ? Math.max(p, S(t - (it.ghostAt ?? t0 + .25 + i * .12), 13)) : p;\n const e = { at, p, dim: it.dimAt != null ? S(t - it.dimAt, 10) : 0, ring: it.ringAt != null ? S(t - it.ringAt, 12) : 0 };\n const blur = (1 - pe) * 7 + o * 5;\n ctx.save(); ctx.globalAlpha *= Math.min(1, pe * 1.3) * (1 - o) * (1 - .4 * e.dim); ctx.translate(0, (1 - pe) * 24 - o * 10);\n if (blur > .4) addFilter(ctx, `blur(${blur.toFixed(1)}px)`);\n if (it.k === 'bars') ITEMS.bars(R, ctx, it, e, t, s, i); else ITEMS[it.k]?.(R, ctx, it, e, t, s);\n ctx.restore();\n });\n ctx.restore();\n}\n\nexport const CLEAN = { slide: { draw: slide } };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":14469,"content_sha256":"4fbed613cdc48f5e6fec530b15a7c9279ecd075cdcec0c34d59cac2da18a6273"},{"filename":"engine/lib/scenes-crash.js","content":"// Crash scene: a car rams a real 3D brick wall, drawn in perspective (no pose image). Same contract as scenes.js,\n// times already mapped to SHORT time.\n// crashWall a road in perspective, a brick wall that builds up course by course (every brick a shaded 3D box with\n// ink edges and a ground shadow), the car sticker that arrives, revs, rams it at hitAt and sinks into the\n// hole; the bricks around the impact burst out with real tumbling (rigid 3D boxes, gravity, bounces,\n// they settle flat on the road), the ones above the hole collapse, dust, blue burst, camera shake + punch.\n// {from, to, car (image path, front facing right), carH 470, enterAt, buildAt, ramAt, hitAt,\n// lane .8 (car lane across the wall, in bricks from its centre), rows 8, cols 6, brick ([len, h, thick] 120 56 62), color '#C8603E', yaw .62, pitch .3,\n// cx 600, cy 1215 (screen point of the impact face bottom centre), outDx}\nimport { C, clamp, lerp, E, S, leave, hash } from './kit.js';\n\nconst TAU = Math.PI * 2;\nconst hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));\nconst shade = (rgb, k) => `rgb(${rgb.map(v => Math.round(k \u003c 1 ? v * k : v + (255 - v) * (k - 1))).join(',')})`;\nconst live = (s, t) => t >= s.from - 1 && t \u003c= (s.to ?? 1e9) + 1;\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\n\n// rotation matrix from Euler angles (x then y then z), applied to a local vector\nfunction rot(v, [rx, ry, rz]) {\n let [x, y, z] = v, c = Math.cos(rx), s = Math.sin(rx);\n [y, z] = [y * c - z * s, y * s + z * c];\n c = Math.cos(ry); s = Math.sin(ry); [x, z] = [x * c + z * s, -x * s + z * c];\n c = Math.cos(rz); s = Math.sin(rz); [x, y] = [x * c - y * s, x * s + y * c];\n return [x, y, z];\n}\nconst CORNERS = [[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1], [-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]];\nconst FACES = [[0, 3, 2, 1, [0, 0, -1]], [4, 5, 6, 7, [0, 0, 1]], [0, 4, 7, 3, [-1, 0, 0]], [1, 2, 6, 5, [1, 0, 0]], [3, 7, 6, 2, [0, 1, 0]], [0, 1, 5, 4, [0, -1, 0]]];\nconst LIGHT = (() => { const l = [-.45, .8, -.42], n = Math.hypot(...l); return l.map(v => v / n); })();\n\n// the wall: courses of bricks in running bond, impact face at X = 0, the wall spans Z (across the road)\nfunction layout(s) {\n const [L, h, T] = s.brick || [120, 56, 62], rows = s.rows || 8, cols = s.cols || 6, gap = 5, W = cols * L, out = [];\n for (let r = 0; r \u003c rows; r++) {\n const off = r % 2 ? L / 2 : 0, cuts = [];\n for (let z = -W / 2 - off; z \u003c W / 2 - .1; z += L) cuts.push([Math.max(z, -W / 2), Math.min(z + L, W / 2)]);\n for (const [a, b] of cuts) {\n if (b - a \u003c 10) continue;\n out.push({ r, c: [T / 2, h * r + h / 2, (a + b) / 2], half: [T / 2 - gap / 2, h / 2 - gap / 2, (b - a) / 2 - gap / 2] });\n }\n }\n return { bricks: out, L, h, T, W, rows };\n}\n\n// deterministic rigid-body pass for one brick from its release to time t\nfunction simulate(b, i, t, hit, wall, lane) {\n const imp = [0, wall.h * 2.2, lane], R = s => hash(i * 7.31 + s);\n const d = Math.hypot(b.c[0] - imp[0], (b.c[1] - imp[1]) * 1.15, b.c[2] - imp[2]);\n const blast = d \u003c wall.h * 5.4, above = !blast && Math.abs(b.c[2] - lane) \u003c wall.L * 1.7 && b.c[1] > imp[1];\n if (!blast && !above) return { p: b.c, a: [0, 0, 0] };\n const rel = hit + (blast ? 0 : .07 + (b.c[1] - imp[1]) / 2600 + R(1) * .05);\n if (t \u003c= rel) return { p: b.c, a: [0, 0, 0] };\n let p = [...b.c], a = [0, 0, 0], v, w;\n if (blast) {\n const k = 1 - d / (wall.h * 5.4), dir = [b.c[0] - imp[0] + 60, b.c[1] - imp[1] + 70, b.c[2] - imp[2]], n = Math.hypot(...dir);\n const sp = 450 + 1050 * Math.sqrt(k) + 400 * R(2);\n v = [dir[0] / n * sp + 330 + 380 * R(3), dir[1] / n * sp * .7 + 300 + 380 * R(4), dir[2] / n * sp * 1.1 + (R(5) - .5) * 420];\n w = [(R(6) - .5) * 22, (R(7) - .5) * 9, (R(8) - .5) * 22];\n } else { v = [120 + 260 * R(3), 0, (R(5) - .5) * 160]; w = [(R(6) - .5) * 5, 0, -2 - 4 * R(8)]; }\n const dt = 1 / 120, g = 2700, steps = Math.min(600, Math.floor((t - rel) / dt));\n let rest = 0;\n for (let n = 0; n \u003c steps; n++) {\n v[1] -= g * dt;\n for (let k = 0; k \u003c 3; k++) { p[k] += v[k] * dt; a[k] += w[k] * dt; }\n const low = Math.min(...CORNERS.map(q => rot([q[0] * b.half[0], q[1] * b.half[1], q[2] * b.half[2]], a)[1]));\n if (p[1] + low \u003c 0) {\n p[1] = -low;\n if (v[1] \u003c 0) v[1] = -v[1] * .28;\n v[0] *= .72; v[2] *= .72; w = w.map(x => x * .55);\n }\n if (p[1] + low \u003c 2) { // resting on the road: tip over onto the nearest face\n rest++; v[0] *= .96; v[2] *= .96;\n if (rest > 10) { w = w.map(x => x * .8); a[0] += (Math.round(a[0] / (Math.PI / 2)) * Math.PI / 2 - a[0]) * .12; a[2] += (Math.round(a[2] / (Math.PI / 2)) * Math.PI / 2 - a[2]) * .12; }\n }\n }\n return { p, a };\n}\n\nconst crashWall = {\n needs(s, t) { return live(s, t) && s.car ? ['/p/' + s.car] : []; },\n draw(R, s, t) {\n const { ctx } = R;\n const out = s.to != null ? leave(t - s.to) : 0, pin = S(t - s.from, 12);\n if (pin \u003c= .002 || out >= 1) return;\n const wall = layout(s), rgb = hex(s.color || '#C8603E'), hit = s.hitAt ?? s.from + 2.6;\n const ya = s.yaw ?? .62, pa = s.pitch ?? .3, f = s.zoom ? 2400 * s.zoom : 2750, D = 2400, cx = s.cx ?? 560, cy = s.cy ?? 1250;\n const cyw = Math.cos(ya), syw = Math.sin(ya), cp = Math.cos(pa), sp = Math.sin(pa);\n const view = ([X, Y, Z]) => { const x1 = X * cyw - Z * syw, z1 = X * syw + Z * cyw; return [x1, Y * cp + z1 * sp, z1 * cp - Y * sp]; };\n const proj = P => { const [x, y, z] = view(P), k = f / (D + z); return [cx + x * k, cy - y * k, z, k]; };\n const poly = (pts, fill, stroke, lw) => {\n ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath();\n if (fill) { ctx.fillStyle = fill; ctx.fill(); } if (stroke) { ctx.strokeStyle = stroke; ctx.lineWidth = lw; ctx.stroke(); }\n };\n const th = t - hit, after = th > 0, lane = (s.lane ?? .8) * wall.L;\n\n alpha(ctx, (1 - out) * Math.min(1, pin * 1.4), () => {\n ctx.translate((s.outDx ?? -820) * out * out, 60 * (1 - pin));\n // camera shake + zoom punch on impact\n if (after) {\n const e = Math.exp(-th * 6.5), A = 30 * e, [ix, iy] = proj([0, wall.h * 2.2, lane]), z = 1 + .085 * Math.exp(-th * 5) * Math.min(1, th * 30);\n ctx.translate(A * Math.sin(th * 91), A * .8 * Math.cos(th * 77));\n ctx.translate(ix, iy); ctx.scale(z, z); ctx.translate(-ix, -iy);\n }\n // ---- road in perspective\n const rp = E.out(clamp((t - s.from) / .45));\n const roadW = 430, x0 = -2600, x1 = lerp(x0, 1900, rp);\n poly([proj([x0, 0, -roadW]), proj([x1, 0, -roadW]), proj([x1, 0, roadW]), proj([x0, 0, roadW])].map(q => q.slice(0, 2)), '#E9E3D6');\n ctx.lineCap = 'round';\n for (const z of [-roadW, roadW]) { const a = proj([x0, 0, z]), b = proj([x1, 0, z]); ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = C.ink; ctx.lineWidth = 7; ctx.stroke(); }\n for (let x = x0 + 80; x \u003c x1 - 160; x += 300) { const a = proj([x, 0, -roadW * .55]), b = proj([x + 150, 0, -roadW * .55]); ctx.beginPath(); ctx.moveTo(a[0], a[1]); ctx.lineTo(b[0], b[1]); ctx.strokeStyle = C.ink3; ctx.lineWidth = 6; ctx.stroke(); }\n\n // ---- car position (front bumper X), revs, rams, sinks into the hole\n const enter = s.enterAt ?? s.from, ram = s.ramAt ?? hit - .44, idleX = -640;\n let carX;\n if (t \u003c ram) carX = lerp(-2300, idleX, E.out(clamp((t - enter) / .62))) - 70 * E.io(clamp((t - enter - .9) / (ram - enter - .9)));\n else if (!after) carX = lerp(idleX - 70, 0, Math.pow(clamp((t - ram) / (hit - ram)), 2.2));\n else carX = 190 * E.out(clamp(th / .2)) - 25 * E.io(clamp((th - .2) / .5));\n const rev = t > enter + .6 && t \u003c ram ? Math.sin(t * 95) * 3.5 : 0;\n const tilt = after ? -.13 * Math.exp(-th * 5) * Math.sin(clamp(th / .06) * Math.PI / 2) : t > ram ? .03 * clamp((t - ram) / .2) : 0;\n\n // ---- bricks: build up, then the blast\n const build = s.buildAt ?? s.from + .2;\n const items = wall.bricks.map((b, i) => {\n const bp = S(t - build - b.r * .07 - hash(i * 3.3) * .05, 13); if (bp \u003c= .002) return null;\n const st = after ? simulate(b, i, t, hit, wall, lane) : { p: b.c, a: [0, 0, 0] };\n const p = [st.p[0], st.p[1] + 260 * (1 - bp), st.p[2]];\n const pts = CORNERS.map(q => { const r = rot([q[0] * b.half[0], q[1] * b.half[1], q[2] * b.half[2]], st.a); return [p[0] + r[0], p[1] + r[1], p[2] + r[2]]; });\n return { kind: 'brick', i, p, a: st.a, pts, depth: view(p)[2], fade: Math.min(1, bp * 1.6) };\n }).filter(Boolean);\n\n // ground shadows first\n for (const it of items) {\n const k = .2 * clamp(1 - it.p[1] / 700) * it.fade;\n if (k \u003c= .01) continue;\n const ys = it.pts.map(q => q[1]), lo = Math.min(...ys);\n const foot = it.pts.filter(q => q[1] \u003c lo + 30).map(q => proj([q[0] + 14, 0, q[2] - 10]));\n if (foot.length \u003c 3) continue;\n const c = foot.reduce((a, q) => [a[0] + q[0] / foot.length, a[1] + q[1] / foot.length], [0, 0]);\n foot.sort((u, w) => Math.atan2(u[1] - c[1], u[0] - c[0]) - Math.atan2(w[1] - c[1], w[0] - c[0]));\n poly(foot, `rgba(40,28,18,${k})`);\n }\n const im = s.car && R.img('/p/' + s.car), ch = s.carH || 470;\n const carP = proj([carX - 190, 0, lane]), carDepth = view(after ? [0, 120, lane - wall.L * .9] : [carX - 140, 120, lane])[2];\n if (im) { // car shadow on the road\n const a = proj([carX - 420, 0, lane - 90]), b = proj([carX - 10, 0, lane + 90]);\n ctx.save(); ctx.fillStyle = 'rgba(40,28,18,.16)'; ctx.beginPath();\n ctx.ellipse((a[0] + b[0]) / 2, (a[1] + b[1]) / 2 + 4, Math.abs(b[0] - a[0]) / 2 + 30, 26 * carP[3], 0, 0, TAU); ctx.fill(); ctx.restore();\n }\n const drawCar = () => {\n if (!im) return;\n const k = carP[3], hgt = ch * k, wid = hgt * im.width / im.height;\n // the bumper sits at (0.95, 0.9) of the sticker: anchor it on the car's front bottom\n const fx = proj([carX, 0, lane])[0], fy = carP[1] + rev * .3;\n // speed lines while it rams\n if (t > ram && t \u003c hit + .05) {\n const q = clamp((t - ram) / .15);\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 8; ctx.lineCap = 'round';\n for (let j = 0; j \u003c 3; j++) { const y = fy - hgt * (.22 + j * .14), x = fx - wid * 1.02; ctx.beginPath(); ctx.moveTo(x - 60 - 220 * q, y); ctx.lineTo(x - 60, y); ctx.stroke(); }\n ctx.restore();\n }\n ctx.save(); ctx.translate(fx, fy); ctx.rotate(tilt);\n const sq = after ? 1 - .07 * Math.exp(-th * 7) * Math.min(1, th * 25) : 1;\n ctx.scale(sq, 2 - sq); ctx.imageSmoothingQuality = 'high';\n ctx.drawImage(im, -wid * .95, -hgt * .9, wid, hgt); ctx.restore();\n };\n\n // painter: everything sorted far to near, the car in the list as one item\n const list = [...items, { kind: 'car', depth: carDepth }].sort((u, w) => w.depth - u.depth);\n for (const it of list) {\n if (it.kind === 'car') { drawCar(); continue; }\n const P = it.pts.map(proj);\n alpha(ctx, it.fade, () => {\n ctx.lineJoin = 'round';\n for (const [a, b, c, d, n] of FACES) {\n const q = [P[a], P[b], P[c], P[d]];\n const cr = (q[1][0] - q[0][0]) * (q[2][1] - q[0][1]) - (q[1][1] - q[0][1]) * (q[2][0] - q[0][0]);\n if (cr >= 0) continue;\n const wn = rot(n, it.a), l = Math.max(0, wn[0] * LIGHT[0] + wn[1] * LIGHT[1] + wn[2] * LIGHT[2]);\n poly(q, shade(rgb, .74 + .5 * l), C.ink, 2.6);\n if (l > .5 && Math.abs(wn[0]) > .6) { // a few pits on the lit big face: baked-clay texture\n ctx.fillStyle = shade(rgb, .82);\n for (let j = 0; j \u003c 3; j++) {\n const u = .2 + .6 * hash(it.i * 9 + j), v = .25 + .5 * hash(it.i * 5 + j * 2);\n const x = lerp(lerp(q[0][0], q[1][0], u), lerp(q[3][0], q[2][0], u), v), y = lerp(lerp(q[0][1], q[1][1], u), lerp(q[3][1], q[2][1], u), v);\n ctx.beginPath(); ctx.arc(x, y, 2.6, 0, TAU); ctx.fill();\n }\n }\n }\n });\n }\n\n // ---- impact: cracks a beat before, blue ring + burst lines, dust clouds\n const [ix, iy] = proj([0, wall.h * 2.2, lane]);\n if (after && th \u003c .5) {\n const q = E.out(clamp(th / .22)), fade = 1 - clamp((th - .16) / .2);\n alpha(ctx, fade, () => {\n ctx.strokeStyle = C.blue; ctx.lineCap = 'round';\n ctx.lineWidth = 9; ctx.beginPath(); ctx.arc(ix, iy, 40 + 220 * q, 0, TAU); ctx.stroke();\n for (let j = 0; j \u003c 10; j++) {\n const an = j / 10 * TAU + .3, r0 = 90 + 170 * q, r1 = r0 + 110 * q * (.7 + .3 * hash(j));\n ctx.lineWidth = 10; ctx.beginPath(); ctx.moveTo(ix + Math.cos(an) * r0, iy + Math.sin(an) * r0); ctx.lineTo(ix + Math.cos(an) * r1, iy + Math.sin(an) * r1); ctx.stroke();\n }\n });\n }\n if (after) {\n for (let j = 0; j \u003c 6; j++) {\n const st = .04 + j * .05, x = th - st; if (x \u003c= 0 || x > 1.2) continue;\n const g = E.out(clamp(x / .8)), fade = 1 - clamp((x - .55) / .65);\n const wp = proj([70 + hash(j * 2.1) * 220 * g, 20 + 120 * g * hash(j * 4.7), lane + (j % 2 ? 1 : -1) * (180 + 260 * hash(j * 3.9)) * g]);\n const r = (34 + 46 * hash(j * 1.7)) * (.4 + .6 * g) * wp[3];\n alpha(ctx, fade, () => {\n const puffs = [0, 1, 2].map(m => [wp[0] + (m - 1) * r * .7, wp[1] - (m === 1 ? r * .35 : 0), r * (m === 1 ? 1 : .75)]);\n ctx.strokeStyle = C.ink; ctx.lineWidth = 8; // one clean outline around the union: stroke wide, then fill over it\n for (const [x, y, rr] of puffs) { ctx.beginPath(); ctx.arc(x, y, rr, 0, TAU); ctx.stroke(); }\n ctx.fillStyle = '#ECE7DD';\n for (const [x, y, rr] of puffs) { ctx.beginPath(); ctx.arc(x, y, rr, 0, TAU); ctx.fill(); }\n });\n }\n }\n });\n },\n};\n\nexport const CRASH = { crashWall };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":14139,"content_sha256":"d94837cc8875f3212145c7cb01a682a96c10ec0de01a233e3cb5b4d8d6769d1f"},{"filename":"engine/lib/scenes-faceless.js","content":"// Faceless scenes: an audio-only video carried by die-cut photo stickers of the creator's head on moving paper\n// (first used by short-faceless-1). Same contract as scenes.js, times already mapped to SHORT time.\n// paperFlow full-frame textured paper that drifts all video long, with a few paper cut-outs floating over it.\n// {from: 0, to, grain 1.35, shapes: true}. Put it first in `scenes` with layout [[0, 1920]].\n// talkHead the speaker as a sticker head that moves while he talks (no lip-sync): it nods on each word, floats,\n// and gets livelier while words are being said. `face` is the default sticker, `emo` swaps it on beats.\n// {dir: 'assets/stickers/', face, emo: [{from, to, img}], posKeys [[t, [x, top, w]]],\n// body: {at, armsAt, danceAt, shirt, pants, bpm 118}, crew: [{img, at, x, top, w, shirt, pants, phase}]}\n// body grows a paper-cut torso, legs and arms under the head (die-cut white outline like the stickers);\n// danceAt starts a disco-point dance on the beat. crew = friends' sticker heads that pop in and dance too.\n// Stickers: transparent PNGs cut by faceless-stickers/files/cutout.py (same width, head top = bbox top).\n// bgPattern {from, to, keys: [[at, 'squares' | 'diamonds' | 'stripes' | 'none']], x 540, y 1500, a 1}: the paper\n// background changes pattern on the beat, each new one wipes in as a circle growing from (x, y).\n// disco {at (drop), to, x 540, y 250, r 130, spotsAt}: a mirror ball lowers on its string from the top and\n// spins; light spots sweep the paper.\n// dancefloor {at, to, y0 1360, rows 5, cols 7, bpm 118}: perspective tile floor that unfolds back to front and\n// lights tiles on the beat.\nimport { C, W, H, clamp, lerp, S, leave, hash, noise1, makePaper, relief, drop } from './kit.js';\n\nconst TAU = Math.PI * 2;\nconst SKIN = '#E2B08C';\nconst col = c => (c && C[c]) || c || C.ink;\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\nconst envA = (s, t) => (s.from \u003c= 0 ? 1 : S(t - s.from, 12)) * (1 - leave(t - (s.to ?? 1e9)));\n\n// ---------------------------------------------------------------- paperFlow\nlet PF = null;\nconst PW = 1320, PH = 2200;\nfunction paperCanvas(grain) {\n if (PF) return PF;\n PF = makePaper(PW, PH, C.paper, 41, grain);\n relief(PF, 2.5, .18, 43); relief(PF, 70, .07, 47); // tooth + soft cockle you can see moving\n return PF;\n}\nconst SHAPES = [ // [kind, x, y, size, fill] around the edges, the centre stays free for the head\n ['circle', 120, 260, 120, 'sheet'], ['rect', 960, 210, 150, 'tint'], ['blob', 70, 1180, 170, 'sheet'],\n ['rect', 1010, 1020, 110, 'sheet'], ['circle', 980, 1640, 90, 'tint'], ['dot', 170, 1760, 34, 'blue'],\n ['blob', 880, 1500, 120, 'sheet'], ['dot', 930, 640, 22, 'blue'],\n];\nfunction shapePath(ctx, kind, s, seed) {\n ctx.beginPath();\n if (kind === 'circle' || kind === 'dot') ctx.arc(0, 0, s / 2, 0, TAU);\n else if (kind === 'rect') ctx.roundRect(-s / 2, -s / 2, s, s, s * .22);\n else { const n = 9; for (let i = 0; i \u003c= n; i++) { const a = i / n * TAU, r = s / 2 * (.78 + .3 * hash(seed + i % n)); ctx[i ? 'lineTo' : 'moveTo'](Math.cos(a) * r, Math.sin(a) * r); } ctx.closePath(); }\n}\nconst paperFlow = {\n draw(R, s, t) {\n const { ctx } = R, P = paperCanvas(s.grain ?? 1.35);\n const mx = (PW - W) / 2, my = (PH - H) / 2;\n const ox = -mx + (noise1(t * .11 + 2) - .5) * 2 * mx * .9, oy = -my + (noise1(t * .09 + 7) - .5) * 2 * my * .9;\n const k = 1 + .025 * Math.sin(t * .23);\n ctx.save(); ctx.translate(W / 2, H / 2); ctx.scale(k, k); ctx.rotate(Math.sin(t * .17) * .012); ctx.translate(-W / 2, -H / 2);\n ctx.drawImage(P, ox, oy); ctx.restore();\n // a slow light falloff sweeping across, so the sheet feels lit\n const lx = W * (.3 + .4 * noise1(t * .07)), g = ctx.createRadialGradient(lx, -200, 0, lx, -200, H * 1.1);\n g.addColorStop(0, 'rgba(255,255,255,.22)'); g.addColorStop(.6, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(70,52,25,.035)');\n ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);\n if (s.shapes === false) return;\n SHAPES.forEach(([kind, x, y, sz, fill], i) => {\n const dx = (noise1(t * .16 + i * 13) - .5) * 70, dy = (noise1(t * .13 + i * 7 + 50) - .5) * 90, rot = (noise1(t * .1 + i * 3) - .5) * .9;\n const a = s.shapesA ?? 1;\n alpha(ctx, a, () => {\n ctx.translate(x + dx, y + dy); ctx.rotate(rot);\n drop(ctx, kind === 'dot' ? .16 : .2, kind === 'dot' ? 8 : 26, kind === 'dot' ? 3 : 10);\n shapePath(ctx, kind, sz, i * 11); ctx.fillStyle = fill === 'sheet' ? C.sheet : col(fill); ctx.fill();\n ctx.shadowColor = 'transparent';\n if (kind !== 'dot') { const gl = ctx.createLinearGradient(0, -sz / 2, 0, sz / 2); gl.addColorStop(0, 'rgba(255,255,255,.55)'); gl.addColorStop(.4, 'rgba(255,255,255,0)'); gl.addColorStop(1, 'rgba(60,44,20,.06)'); ctx.fillStyle = gl; ctx.fill(); }\n });\n });\n },\n};\n\n// ---------------------------------------------------------------- bgPattern\n// light paper-on-paper patterns: blue tint cells + faint ink seams, drifting slowly so the frame stays alive\nfunction patternFill(ctx, kind, t) {\n const tint = 'rgba(37,142,255,.085)', seam = 'rgba(20,22,27,.06)';\n ctx.save();\n if (kind === 'squares' || kind === 'diamonds') {\n const g = kind === 'squares' ? 135 : 150, d = (t * 16) % (2 * g);\n ctx.translate(W / 2, H / 2); if (kind === 'diamonds') ctx.rotate(Math.PI / 4); ctx.translate(d - 2 * g, d * .5 - 2 * g);\n const R0 = Math.ceil(1250 / g) + 2;\n ctx.fillStyle = tint;\n for (let i = -R0; i \u003c= R0; i++) for (let j = -R0; j \u003c= R0; j++) if ((i + j) & 1) ctx.fillRect(i * g, j * g, g, g);\n ctx.strokeStyle = seam; ctx.lineWidth = 2; ctx.beginPath();\n for (let i = -R0; i \u003c= R0 + 1; i++) { ctx.moveTo(i * g, -R0 * g); ctx.lineTo(i * g, (R0 + 1) * g); ctx.moveTo(-R0 * g, i * g); ctx.lineTo((R0 + 1) * g, i * g); }\n ctx.stroke();\n } else if (kind === 'stripes') {\n const g = 96, d = (t * 22) % (2 * g);\n ctx.fillStyle = tint;\n for (let x = d - 2 * g; x \u003c W + g; x += 2 * g) ctx.fillRect(x, 0, g, H);\n ctx.strokeStyle = seam; ctx.lineWidth = 2; ctx.beginPath();\n for (let x = d - 2 * g; x \u003c W + g; x += g) { ctx.moveTo(x, 0); ctx.lineTo(x, H); }\n ctx.stroke();\n }\n ctx.restore();\n}\nconst bgPattern = {\n draw(R, s, t) {\n const { ctx } = R, a = envA(s, t) * (s.a ?? 1); if (a \u003c= .002) return;\n const K = (s.keys || []).filter(k => k[0] \u003c= t); if (!K.length) return;\n const cur = K.at(-1), prev = K.at(-2), p = S(t - cur[0], 5), x = s.x ?? 540, y = s.y ?? 1500;\n alpha(ctx, a, () => {\n const r = 2300 * p;\n if (prev && p \u003c .999) { // the old pattern stays outside the growing circle\n ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.arc(x, y, r, 0, TAU); ctx.clip('evenodd'); patternFill(ctx, prev[1], t); ctx.restore();\n }\n ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.clip(); patternFill(ctx, cur[1], t); ctx.restore();\n if (p > .01 && p \u003c .98) { ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.strokeStyle = `rgba(37,142,255,${.5 * (1 - p)})`; ctx.lineWidth = 10; ctx.stroke(); }\n });\n },\n};\n\n// ---------------------------------------------------------------- talkHead\nconst url = (s, n) => '/p/' + (s.dir || 'assets/stickers/') + n + '.png';\n// one face at a time (no lip-sync): `face` by default, `emo` overrides it on emotional beats\nfunction faceAt(s, t) {\n for (const e of s.emo || []) if (t >= e.from && t \u003c e.to) return { img: e.img, key: 'e' + e.from };\n return { img: s.face, key: 'face' };\n}\n// speaking energy: 1 while a word is being said, eases back to 0 in the gaps\nfunction talkingAt(t, words) {\n let v = 0;\n for (const w of words) { if (w.start > t + .1) break; if (t - w.end > .5) continue; v = Math.max(v, S(t - w.start + .05, 18) * (1 - S(t - w.end - .08, 9))); }\n return v;\n}\n// smooth nod: rises in ~.09 s, decays over ~.3 s, peak 1. No overshoot.\nconst nodK = x => x \u003c= 0 ? 0 : (x / .09) * Math.exp(1 - x / .09);\nfunction nodAt(t, words) {\n let v = 0, dir = 0;\n for (let i = 0; i \u003c words.length; i++) {\n const w = words[i]; if (w.start > t) break; if (t - w.start > .8) continue;\n const k = nodK(t - w.start) * (.55 + .45 * hash(i + .5)); v += k; dir += k * (hash(i * 1.7) > .5 ? 1 : -1);\n }\n return { v: Math.min(1.3, v), dir: clamp(dir, -1, 1) };\n}\n\n// One dancer: paper-cut body under a sticker head. Local origin = neck point; u = head width.\nfunction limb(ctx, pts, lw, color, bw) {\n ctx.beginPath(); pts.forEach(([x, y], i) => ctx[i ? 'lineTo' : 'moveTo'](x, y));\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n if (bw) { ctx.strokeStyle = C.white; ctx.lineWidth = lw + 2 * bw; } else { ctx.strokeStyle = color; ctx.lineWidth = lw; }\n ctx.stroke();\n}\nconst arm = (sx, sy, a, bend, L1, L2, side) => {\n const ex = sx + Math.sin(a) * L1 * side, ey = sy + Math.cos(a) * L1, b = a + bend;\n return [[sx, sy], [ex, ey], [ex + Math.sin(b) * L2 * side, ey + Math.cos(b) * L2]];\n};\nfunction bodyParts(ctx, o, t, pass) {\n const u = o.u, bp = o.bodyP, ap = o.armsP, dp = o.danceP, b = t * (o.bpm || 118) / 60 + (o.phase || 0);\n const sway = Math.sin(Math.PI * b) * .1 * u * dp, bw = pass === 'outline' ? .045 * u : 0;\n const shirt = col(o.shirt || 'blue'), pants = col(o.pants || 'ink');\n // legs (from the hips), knees pump on the beat\n const hipY = .98 * u * bp, kb = (1 - Math.cos(TAU * b)) / 2 * dp;\n if (bp > .05) for (const sd of [-1, 1]) {\n const hx = sd * .2 * u + sway * .4, L = .46 * u * bp;\n const kx = hx + sd * .05 * u + Math.sin(Math.PI * b + (sd > 0 ? 0 : Math.PI)) * .07 * u * dp, ky = hipY + L - .08 * u * kb;\n const fx = hx + sd * .06 * u, fy = ky + L;\n limb(ctx, [[hx, hipY - .1 * u], [kx, ky], [fx, fy]], .27 * u * Math.max(.3, bp), pants, bw);\n if (pass !== 'outline') { ctx.beginPath(); ctx.ellipse(fx + sd * .07 * u, fy + .05 * u, .17 * u * bp, .08 * u * bp, 0, 0, TAU); ctx.fillStyle = C.ink; ctx.fill(); }\n else { ctx.beginPath(); ctx.ellipse(fx + sd * .07 * u, fy + .05 * u, .17 * u * bp + bw, .08 * u * bp + bw, 0, 0, TAU); ctx.fillStyle = C.white; ctx.fill(); }\n }\n // torso: shoulders wide, slightly narrower waist, rotates with the hips\n const tw = .98 * u, th = 1.02 * u * bp, wx = .74 * u;\n ctx.save(); ctx.rotate(Math.sin(Math.PI * b) * .05 * dp);\n ctx.beginPath();\n ctx.moveTo(-tw / 2 + .12 * u, -.04 * u); ctx.lineTo(tw / 2 - .12 * u, -.04 * u);\n ctx.quadraticCurveTo(tw / 2, -.02 * u, tw / 2, .16 * u);\n ctx.lineTo(wx / 2 + sway * .3, th); ctx.quadraticCurveTo(wx / 2 + sway * .3, th + .08 * u, wx / 2 - .1 * u + sway * .3, th + .08 * u);\n ctx.lineTo(-wx / 2 + .1 * u + sway * .3, th + .08 * u); ctx.quadraticCurveTo(-wx / 2 + sway * .3, th + .08 * u, -wx / 2 + sway * .3, th);\n ctx.lineTo(-tw / 2, .16 * u); ctx.quadraticCurveTo(-tw / 2, -.02 * u, -tw / 2 + .12 * u, -.04 * u); ctx.closePath();\n if (pass === 'outline') { ctx.lineJoin = 'round'; ctx.strokeStyle = C.white; ctx.lineWidth = 2 * bw; ctx.stroke(); ctx.fillStyle = C.white; ctx.fill(); }\n else {\n ctx.fillStyle = shirt; ctx.fill();\n const g = ctx.createLinearGradient(-tw / 2, 0, tw / 2, 0); g.addColorStop(0, 'rgba(255,255,255,.16)'); g.addColorStop(.5, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(0,0,0,.14)');\n ctx.fillStyle = g; ctx.fill();\n ctx.beginPath(); ctx.ellipse(0, -.03 * u, .2 * u, .09 * u, 0, 0, Math.PI); ctx.fillStyle = SKIN; ctx.fill(); // neck opening\n }\n // arms: disco point, swapping sides every 2 beats, forearms pump on the beat\n if (ap > .02) for (const sd of [-1, 1]) {\n const q = (Math.sin(Math.PI * b / 2 + (sd > 0 ? 0 : Math.PI)) + 1) / 2; // 1 = up\n const rest = .32, a = lerp(rest, lerp(.5, 2.45, q), dp), bend = lerp(.25, lerp(.9, .15, q) * (.6 + .4 * (1 - Math.cos(TAU * b)) / 2), dp);\n const pts = arm(sd * .4 * u, .1 * u, a, bend, .4 * u * ap, .38 * u * ap, sd);\n if (pass === 'outline') { limb(ctx, pts, .19 * u, null, bw); ctx.beginPath(); ctx.arc(pts[2][0], pts[2][1], .1 * u * ap + bw, 0, TAU); ctx.fillStyle = C.white; ctx.fill(); }\n else {\n limb(ctx, pts.slice(1), .16 * u, SKIN); limb(ctx, pts.slice(0, 2), .2 * u, shirt);\n ctx.beginPath(); ctx.arc(pts[2][0], pts[2][1], .1 * u * ap, 0, TAU); ctx.fillStyle = SKIN; ctx.fill();\n if (sd > 0 && q > .5 && dp > .5) { ctx.beginPath(); ctx.arc(pts[2][0], pts[2][1], .1 * u * ap, 0, TAU); ctx.strokeStyle = 'rgba(0,0,0,.08)'; ctx.lineWidth = 2; ctx.stroke(); }\n }\n }\n ctx.restore();\n}\n// draw a head (+ body) with its top-left logic: x = centre, top = top of the head, w = head width\nfunction figure(ctx, img, o, t) {\n const u = o.w, b = t * (o.bpm || 118) / 60 + (o.phase || 0), dp = o.danceP || 0;\n const bob = (1 - Math.cos(TAU * b)) / 2 * .07 * u * dp, sway = Math.sin(Math.PI * b) * .1 * u * dp;\n const REF = img ? img.height / img.width - .12 : 1.25; // neck point: just above the bottom of the cut (the chin)\n const nx = o.x + sway * .6, ny = o.top + REF * u + bob;\n if ((o.bodyP || 0) > .002) {\n ctx.save(); ctx.translate(nx, ny);\n ctx.save(); drop(ctx, .26, 34, 14); bodyParts(ctx, { ...o, u }, t, 'outline'); ctx.restore();\n bodyParts(ctx, { ...o, u }, t, 'fill'); ctx.restore();\n }\n if (!img) return;\n const h = u * img.height / img.width, rot = (o.rot || 0) + Math.sin(TAU * b) * .06 * dp, sc = o.sc || 1;\n ctx.save(); ctx.translate(nx, ny - REF * u * .5); ctx.rotate(rot); ctx.scale(sc, sc);\n drop(ctx, .3, 42, 16);\n ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, -u / 2, -REF * u * .5, u, h);\n ctx.restore();\n}\n\nconst talkHead = {\n needs(s, t) {\n if (t \u003c s.from - .5 || t > (s.to ?? 1e9) + .5) return [];\n const names = new Set([s.face, ...(s.emo || []).map(e => e.img), ...(s.crew || []).map(c => c.img)]);\n return [...names].map(n => url(s, n));\n },\n draw(R, s, t) {\n const { ctx } = R, words = R.tl.words, a = envA(s, t); if (a \u003c= .002) return;\n const [x, top, w] = s.posKeys ? track3(t, s.posKeys) : [W / 2, 560, 600];\n const f = faceAt(s, t), nod = nodAt(t, words), talk = talkingAt(t, words), B = s.body || {};\n const bodyP = B.at != null ? S(t - B.at, 7) : 0, armsP = B.armsAt != null ? S(t - B.armsAt, 8) : 0, danceP = B.danceAt != null ? S(t - B.danceAt, 5) : 0;\n const fl = 1 - danceP * .6; // the idle float calms down once he dances\n const en = fl * (.55 + .45 * talk); // more alive while he speaks\n const fx = (noise1(t * .4) - .5) * 30 * en, fy = (noise1(t * .33 + 9) - .5) * 24 * en, frot = (noise1(t * .27 + 3) - .5) * .09 * en\n + Math.sin(t * 7.3) * .02 * talk * fl; // small talking wobble\n // a face change lands with a soft settle (1.04 -> 1), never a bounce\n const ch = s._ch || (s._ch = { key: null, at: -9 });\n if (f.key !== ch.key && f.key.startsWith('e')) { const e = (s.emo || []).find(e => 'e' + e.from === f.key); ch.at = e ? e.from : t; }\n ch.key = f.key;\n const pop = f.key.startsWith('e') ? .05 * (1 - S(t - ch.at, 14)) : 0;\n const k = w / 600;\n alpha(ctx, a, () => {\n // crew first (behind him)\n for (const c of s.crew || []) {\n const p = S(t - c.at, 10); if (p \u003c= .002) continue;\n alpha(ctx, Math.min(1, p * 1.5), () => figure(ctx, R.img(url(s, c.img)), {\n x: c.x, top: c.top + (1 - p) * 260, w: c.w, bodyP: 1, armsP: 1, danceP: S(t - c.at - .2, 5), shirt: c.shirt, pants: c.pants,\n phase: c.phase || 0, bpm: B.bpm, sc: 1 + .06 * (1 - p), rot: (noise1(t * .3 + c.x) - .5) * .06,\n }, t));\n }\n figure(ctx, R.img(url(s, f.img)), {\n x: x + fx + nod.dir * nod.v * 8 * k, top: top + fy - nod.v * 24 * k, w, bodyP, armsP, danceP, shirt: B.shirt, pants: B.pants, bpm: B.bpm,\n rot: frot + nod.dir * .055 * nod.v, sc: 1 + .03 * nod.v + pop,\n }, t);\n });\n },\n};\n// snap: keys that move wider than 80 px jump there at once (teleport) instead of springing across\nfunction track3(t, keys, snap) {\n let v = keys[0][1].slice();\n for (let i = 1; i \u003c keys.length; i++) {\n const x = t - keys[i][0], far = snap && Math.abs(keys[i][1][0] - keys[i - 1][1][0]) >= 80;\n const p = far ? (x >= 0 ? 1 : 0) : S(x, keys[i][2] ?? 7); if (p \u003c= 0) break;\n for (let j = 0; j \u003c 3; j++) v[j] += (keys[i][1][j] - keys[i - 1][1][j]) * p;\n }\n return v;\n}\n\n// ---------------------------------------------------------------- disco\nconst disco = {\n draw(R, s, t) {\n const { ctx } = R, p = S(t - s.at, 6), out = 1 - S(t - (s.to ?? 1e9), 14); if (p \u003c= .002 || out \u003c= .002) return;\n const r = s.r || 130, x = s.x ?? W / 2, y = lerp(-r - 60, s.y ?? 250, p), th = t * 1.1;\n const lights = S(t - (s.spotsAt ?? s.at + .6), 6) * out;\n // light spots sweeping the paper (drawn first, under everything)\n if (lights > .01) {\n ctx.save(); ctx.globalCompositeOperation = 'multiply';\n for (let i = 0; i \u003c 26; i++) {\n const a = th * .6 + i * 2.39996, d = 220 + 900 * hash(i * 1.3), sx = x + Math.cos(a) * d, sy = y + 300 + Math.sin(a) * d * .9;\n if (sy \u003c -60 || sy > H + 60) continue;\n const rr = 30 + 34 * hash(i * 2.1), blue = i % 2 === 0;\n const g = ctx.createRadialGradient(sx, sy, 0, sx, sy, rr);\n g.addColorStop(0, blue ? `rgba(37,142,255,${.75 * lights})` : `rgba(150,140,120,${.3 * lights})`); g.addColorStop(1, 'rgba(255,255,255,0)');\n ctx.fillStyle = g; ctx.fillRect(sx - rr, sy - rr, rr * 2, rr * 2);\n }\n ctx.restore();\n ctx.save(); ctx.globalCompositeOperation = 'screen';\n for (let i = 0; i \u003c 18; i++) {\n const a = -th * .5 + i * 1.7, d = 160 + 820 * hash(i * 4.1 + 9), sx = x + Math.cos(a) * d, sy = y + 360 + Math.sin(a) * d;\n const rr = 18 + 22 * hash(i * 3.3), g = ctx.createRadialGradient(sx, sy, 0, sx, sy, rr);\n g.addColorStop(0, `rgba(255,255,255,${.85 * lights})`); g.addColorStop(1, 'rgba(255,255,255,0)');\n ctx.fillStyle = g; ctx.fillRect(sx - rr, sy - rr, rr * 2, rr * 2);\n }\n ctx.restore();\n }\n alpha(ctx, out, () => {\n // string\n ctx.strokeStyle = C.ink2; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(x, -10); ctx.lineTo(x, y - r - 14); ctx.stroke();\n ctx.fillStyle = C.ink2; ctx.beginPath(); ctx.roundRect(x - 16, y - r - 22, 32, 26, 6); ctx.fill();\n // ball: white die-cut ring + shadow, then the mirror tiles\n ctx.save(); drop(ctx, .3, 40, 18); ctx.beginPath(); ctx.arc(x, y, r + 10, 0, TAU); ctx.fillStyle = C.white; ctx.fill(); ctx.restore();\n ctx.save(); ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.clip(); ctx.fillStyle = '#5E626B'; ctx.fillRect(x - r, y - r, 2 * r, 2 * r);\n const NL = 12, NO = 24;\n for (let j = 0; j \u003c NL; j++) {\n const la0 = -Math.PI / 2 + j * Math.PI / NL, la1 = la0 + Math.PI / NL;\n for (let k = 0; k \u003c NO; k++) {\n const lo0 = k * TAU / NO + th, lo1 = lo0 + TAU / NO, lm = (lo0 + lo1) / 2;\n if (Math.cos(lm) \u003c= -.05) continue; // back side\n const P = (la, lo) => [x + r * Math.cos(la) * Math.sin(lo), y + r * Math.sin(la)];\n const q = [P(la0, lo0), P(la0, lo1), P(la1, lo1), P(la1, lo0)];\n const nz = Math.cos((la0 + la1) / 2) * Math.cos(lm), ny = Math.sin((la0 + la1) / 2), nx = Math.cos((la0 + la1) / 2) * Math.sin(lm);\n let l = clamp(.35 + .5 * (nz * .7 - ny * .5 - nx * .3));\n const tw = hash(j * 31 + k * 7 + Math.floor(t * 6) * 0.13);\n let fill = `rgb(${Math.round(lerp(120, 236, l))},${Math.round(lerp(124, 238, l))},${Math.round(lerp(132, 242, l))})`;\n if (tw > .93) fill = C.white; else if (tw \u003c .05) fill = C.blue;\n ctx.beginPath(); q.forEach((pt, i) => ctx[i ? 'lineTo' : 'moveTo'](...pt)); ctx.closePath();\n ctx.fillStyle = fill; ctx.fill(); ctx.strokeStyle = 'rgba(40,40,48,.55)'; ctx.lineWidth = 1.6; ctx.stroke();\n }\n }\n const g = ctx.createRadialGradient(x - r * .35, y - r * .4, 0, x, y, r); g.addColorStop(0, 'rgba(255,255,255,.45)'); g.addColorStop(.5, 'rgba(255,255,255,0)'); g.addColorStop(1, 'rgba(0,0,0,.28)');\n ctx.fillStyle = g; ctx.fillRect(x - r, y - r, 2 * r, 2 * r); ctx.restore();\n // glints\n for (let i = 0; i \u003c 3; i++) {\n const gp = (t * .9 + i * .37) % 1, gs = Math.sin(Math.PI * gp) * 26 * lights; if (gs \u003c 2) continue;\n const gx = x + (hash(i + Math.floor(t * .9 + i * .37)) - .5) * r * 1.2, gy = y + (hash(i * 3 + Math.floor(t * .9 + i * .37)) - .6) * r * 1.1;\n ctx.fillStyle = C.white; ctx.beginPath();\n for (let k = 0; k \u003c 8; k++) { const a = k * Math.PI / 4, rr = k % 2 ? gs * .22 : gs; ctx.lineTo(gx + Math.cos(a) * rr, gy + Math.sin(a) * rr); }\n ctx.closePath(); ctx.fill();\n }\n });\n },\n};\n\n// ---------------------------------------------------------------- dancefloor\nconst dancefloor = {\n draw(R, s, t) {\n const { ctx } = R, out = 1 - S(t - (s.to ?? 1e9), 14); if (t \u003c s.at - .05 || out \u003c= .002) return;\n const y0 = s.y0 ?? 1360, y1 = H + 40, rows = s.rows || 5, cols = s.cols || 7, b = Math.floor(t * (s.bpm || 118) / 60);\n const xAt = (c, y) => { const f = (y - y0) / (y1 - y0), half = lerp(430, 900, f); return W / 2 + (c / cols - .5) * 2 * half; };\n const yAt = r => y0 + (y1 - y0) * Math.pow(r / rows, 1.25);\n alpha(ctx, out, () => {\n // floor slab shadow on the paper\n ctx.save(); drop(ctx, .28, 40, -6);\n ctx.beginPath(); ctx.moveTo(xAt(0, y0), y0); ctx.lineTo(xAt(cols, y0), y0); ctx.lineTo(xAt(cols, y1), y1); ctx.lineTo(xAt(0, y1), y1); ctx.closePath();\n ctx.fillStyle = C.ink; ctx.globalAlpha *= S(t - s.at, 8); ctx.fill(); ctx.restore();\n for (let r = 0; r \u003c rows; r++) for (let c = 0; c \u003c cols; c++) {\n const p = S(t - s.at - (rows - 1 - r) * .06 - Math.abs(c - cols / 2) * .03, 10); if (p \u003c= .002) continue;\n const ya = yAt(r), yb = yAt(r + 1), g = 5;\n const lit = hash(r * 17 + c * 5 + b * 3.1), on = lit > .62, blue = lit > .8;\n const fill = blue ? C.blue : on ? C.sheet : r % 2 === c % 2 ? '#2A2D35' : '#1E2027';\n ctx.save(); ctx.globalAlpha *= p;\n ctx.beginPath(); ctx.moveTo(xAt(c, ya) + g, ya + g); ctx.lineTo(xAt(c + 1, ya) - g, ya + g); ctx.lineTo(xAt(c + 1, yb) - g, yb - g); ctx.lineTo(xAt(c, yb) + g, yb - g); ctx.closePath();\n ctx.fillStyle = fill; ctx.fill();\n if (on) { ctx.save(); ctx.clip(); const gl = ctx.createLinearGradient(0, ya, 0, yb); gl.addColorStop(0, 'rgba(255,255,255,.35)'); gl.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = gl; ctx.fill(); ctx.restore(); }\n ctx.restore();\n }\n });\n },\n};\n\n// ---------------------------------------------------------------- mascot\n// Full-body cartoon mascot, one drawn pose per beat (no lip-sync). Pose PNGs share one canvas (cutout.py --canvas):\n// feet at FOOT of the height, body centred. Pivot = feet, so squash/stretch and hops read like a cartoon.\nconst FOOT = .95;\nconst murl = (s, n) => '/p/' + (s.dir || 'assets/mascot/') + n + '.png';\nfunction phrasesOf(s, words) {\n if (s._ph && s._ph.n === words.length) return s._ph.list;\n const list = []; let cur = null;\n for (const w of words) { if (cur && w.start - cur.end \u003c (s.gap ?? .42)) cur.end = w.end; else list.push(cur = { start: w.start, end: w.end }); }\n s._ph = { n: words.length, list }; return list;\n}\n// which drawing is on screen at t, and since when (drives the squash on every swap)\nfunction poseAt(s, t, words) {\n const D = s.dance;\n if (D && t >= D.at && t \u003c (D.to ?? 1e9)) {\n const beat = (D.beats || 2) * 60 / (D.bpm || 104), k = Math.floor((t - D.at) / beat);\n return { img: D.imgs[k % D.imgs.length], since: D.at + k * beat };\n }\n const P = s.poses || []; let lastEnd = -1e9;\n for (let i = 0; i \u003c P.length; i++) {\n const end = P[i].to ?? Math.min(P[i].at + (s.hold ?? 1.8), P[i + 1]?.at ?? 1e9);\n if (t >= P[i].at && t \u003c end) return { img: P[i].img, since: P[i].at, flip: P[i].flip };\n if (end \u003c= t) lastEnd = Math.max(lastEnd, end);\n }\n const ph = phrasesOf(s, words); let prevEnd = -1e9;\n for (let i = 0; i \u003c ph.length; i++) {\n const a = ph[i].start - .06, b = ph[i].end + .22;\n if (t >= a && t \u003c b) { const talk = s.talk || []; return { img: talk[i % talk.length] || s.idle, since: Math.max(a, lastEnd) }; }\n if (b \u003c= t) prevEnd = b;\n }\n return { img: s.idle, since: Math.max(prevEnd, lastEnd) };\n}\n// cartoon swap: lands squashed, overshoots tall, settles (~.35 s)\nconst swapK = x => x \u003c 0 || x > .6 ? 0 : Math.exp(-x * 9) * Math.cos(x * 24);\nfunction drawMascot(ctx, img, o) {\n if (!img) return;\n const h = o.h, w = h * img.width / img.height;\n ctx.save(); ctx.translate(o.x, o.y);\n // contact shadow on the ground, shrinks while airborne\n if (o.ground !== false) {\n const air = clamp(-(o.lift || 0) / (h * .3));\n ctx.save(); ctx.scale((1 - air * .45) * (1 - (o.gone || 0)), 1);\n const g = ctx.createRadialGradient(0, 0, 0, 0, 0, h * .2); g.addColorStop(0, `rgba(30,22,10,${.22 * (1 - air * .6)})`); g.addColorStop(1, 'rgba(30,22,10,0)');\n ctx.fillStyle = g; ctx.scale(1, .16); ctx.beginPath(); ctx.arc(0, 0, h * .2, 0, TAU); ctx.fill(); ctx.restore();\n }\n ctx.translate(0, o.lift || 0); ctx.rotate(o.rot || 0); ctx.scale(o.sx || 1, o.sy || 1);\n if (o.flip) ctx.scale(-1, 1);\n ctx.save();\n if (o.clipY != null) { ctx.beginPath(); ctx.rect(-W, -h * 2, W * 2, h * 2 + o.clipY); ctx.clip(); }\n if (o.filter) ctx.filter = o.filter;\n drop(ctx, .3, 38, 16);\n ctx.imageSmoothingQuality = 'high'; ctx.drawImage(img, -w / 2, -FOOT * h, w, h);\n ctx.restore();\n // the cut gets a die-cut white edge so the floating head still reads as a sticker\n if (o.clipY != null && o.cutA > .01) alpha(ctx, o.cutA, () => {\n drop(ctx, .25, 16, 6); ctx.beginPath(); ctx.roundRect(-h * .13, o.clipY - h * .012, h * .26, h * .028, h * .014); ctx.fillStyle = C.white; ctx.fill();\n });\n ctx.restore();\n}\nfunction hopAt(t, jumps, d = .5) { // 0..1 height, plus squash before take-off and on landing\n let lift = 0, sq = 0;\n for (const j of jumps || []) {\n const x = t - (j.at ?? j);\n if (x > -.12 && x \u003c 0) sq = Math.max(sq, Math.sin(Math.PI * (x + .12) / .12) * .5);\n else if (x >= 0 && x \u003c d) lift = Math.max(lift, Math.sin(Math.PI * x / d));\n else if (x >= d && x \u003c d + .22) sq = Math.max(sq, Math.sin(Math.PI * (x - d) / .22));\n }\n return { lift, sq };\n}\n// travel between two posKeys far apart = one cartoon leap: arc in the air, lean into the move, squash on landing.\n// p = spring progress of the newest key still moving, dx = its horizontal distance.\nfunction travelAt(t, keys, sp = 7) {\n let best = null;\n for (let i = 1; i \u003c (keys || []).length; i++) {\n const x = t - keys[i][0]; if (x \u003c -.14) break;\n const dx = keys[i][1][0] - keys[i - 1][1][0]; if (Math.abs(dx) \u003c 80) continue;\n const p = S(x, keys[i][2] ?? sp); if (p \u003c .985) best = { x, p, dx, dir: Math.sign(dx), amt: Math.min(1, Math.abs(dx) / 520) };\n }\n return best;\n}\n// teleport (s.teleport): every posKeys move wider than 80 px. He zips thin and vanishes in TP_OUT s before the key,\n// then pops back at the new spot (spring, no overshoot). k = time since the key, from = where he left.\nconst TP_OUT = .1, TP_IN = .45;\nfunction teleportAt(t, keys) {\n for (let i = (keys || []).length - 1; i >= 1; i--) {\n const k = t - keys[i][0]; if (k \u003c -TP_OUT || k > TP_IN) continue;\n if (Math.abs(keys[i][1][0] - keys[i - 1][1][0]) \u003c 80) continue;\n const v = k \u003c 0 ? (u => u * u * (3 - 2 * u))(-k / TP_OUT) : S(k, 15);\n return { k, v, from: track3(keys[i][0] - .001, keys, true) };\n }\n return null;\n}\n// cartoon poof: a compact paper cloud (outline only on the outside) that swells then shrinks, plus a few marker ticks\nfunction poof(ctx, x, cy, r, k) {\n if (k \u003c= 0 || k >= 1) return;\n const e = 1 - Math.pow(1 - k, 3), a = k \u003c .7 ? 1 : 1 - (k - .7) / .3, sh = 1 - k * k, P = [];\n for (let i = 0; i \u003c 8; i++) {\n const ang = i / 8 * TAU + hash(i * 3.3) * .6, d = r * (.12 + .45 * e) * (.8 + .4 * hash(i * 1.9));\n P.push([x + Math.cos(ang) * d, cy + Math.sin(ang) * d * .75, r * (.22 + .08 * hash(i * 5.7)) * sh]);\n }\n P.push([x, cy, r * .3 * sh * (1 - e)]);\n alpha(ctx, a, () => {\n ctx.strokeStyle = 'rgba(20,22,27,.6)'; ctx.lineWidth = 7;\n for (const [px, py, pr] of P) if (pr > 1) { ctx.beginPath(); ctx.arc(px, py, pr, 0, TAU); ctx.stroke(); }\n ctx.fillStyle = C.white;\n for (const [px, py, pr] of P) if (pr > 1) { ctx.beginPath(); ctx.arc(px, py, pr, 0, TAU); ctx.fill(); }\n ctx.strokeStyle = C.ink; ctx.lineCap = 'round'; ctx.lineWidth = 6; ctx.globalAlpha *= .7 * (1 - k);\n for (let i = 0; i \u003c 6; i++) {\n const ang = i / 6 * TAU + .3, r0 = r * (.6 + .45 * e), r1 = r0 + r * .2;\n ctx.beginPath(); ctx.moveTo(x + Math.cos(ang) * r0, cy + Math.sin(ang) * r0 * .8); ctx.lineTo(x + Math.cos(ang) * r1, cy + Math.sin(ang) * r1 * .8); ctx.stroke();\n }\n });\n}\n// little paper dust puffs at the feet when he lands\nfunction dust(ctx, x, y, h, k) {\n if (k \u003c= 0 || k >= 1) return;\n const e = 1 - Math.pow(1 - k, 3), a = 1 - k;\n for (let i = 0; i \u003c 6; i++) {\n const side = i % 2 ? 1 : -1, d = (h * .1 + h * .22 * e) * (.7 + .5 * hash(i * 2.3)), r = h * (.03 + .035 * e) * (.7 + .6 * hash(i * 5.1));\n alpha(ctx, a, () => {\n ctx.beginPath(); ctx.arc(x + side * d, y - h * (.01 + .05 * e * hash(i * 1.7)), r, 0, TAU);\n ctx.fillStyle = C.white; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(20,22,27,.55)'; ctx.stroke();\n });\n }\n}\n// marker speed strokes trailing behind him while he flies across\nfunction speedLines(ctx, x, y, h, dir, a) {\n if (a \u003c= .02) return;\n ctx.save(); ctx.lineCap = 'round'; ctx.strokeStyle = C.ink;\n for (let i = 0; i \u003c 4; i++) {\n const yy = y - h * (.25 + .17 * i), len = h * (.22 + .14 * hash(i * 3.1)) * a, x0 = x - dir * h * (.3 + .06 * hash(i * 7.7));\n ctx.globalAlpha = .55 * a; ctx.lineWidth = 7 - i; ctx.beginPath(); ctx.moveTo(x0, yy); ctx.lineTo(x0 - dir * len, yy); ctx.stroke();\n }\n ctx.restore();\n}\nconst mascot = {\n needs(s, t) {\n if (t \u003c s.from - .5 || t > (s.to ?? 1e9) + .5) return [];\n const n = new Set([s.idle, s.travelPose, s.airPose, ...(s.talk || []), ...(s.poses || []).map(p => p.img), ...(s.dance?.imgs || []), ...(s.crew || []).flatMap(c => c.imgs)]);\n return [...n].filter(Boolean).map(x => murl(s, x));\n },\n draw(R, s, t) {\n const { ctx } = R, words = R.tl.words, a = envA(s, t); if (a \u003c= .002) return;\n const [x, footY, h] = s.posKeys ? track3(t, s.posKeys, s.teleport) : [W / 2, 1560, 1100];\n const D = s.dance, bpm = D?.bpm || 104, dp = D ? S(t - D.at, 6) * (1 - S(t - (D.to ?? 1e9), 8)) : 0, beat = t * bpm / 60;\n const nod = nodAt(t, words), talk = talkingAt(t, words), pose = poseAt(s, t, words);\n alpha(ctx, a, () => {\n // friends behind him, they pop up from under the floor and dance on the beat\n for (const c of s.crew || []) {\n const p = S(t - c.at, 9) * (1 - S(t - (c.to ?? 1e9), 9)); if (p \u003c= .002) continue;\n const cb = beat + (c.phase || 0), k = Math.floor((t - c.at) / (2 * 60 / bpm));\n const bounce = Math.abs(Math.sin(Math.PI * cb)), x0 = t - c.at;\n const pop = swapK(x0 - .02) * .8 + swapK(((t - c.at) % (2 * 60 / bpm))), ci = c.imgs[Math.max(0, k) % c.imgs.length];\n drawMascot(ctx, R.img(murl(s, ci)), {\n x: c.x, y: c.footY + (1 - p) * (H - c.footY + c.h * 1.1), h: c.h * (s.sizes?.[ci] ?? 1), lift: -bounce * c.h * .035, // sinks fully below the frame\n sx: 1 + .05 * pop, sy: 1 - .07 * pop + .03 * bounce, rot: Math.sin(Math.PI * cb) * .05, flip: c.flip,\n });\n }\n // squash and stretch layers\n const tp = s.teleport ? teleportAt(t, s.posKeys) : null, tr = s.teleport ? null : travelAt(t, s.posKeys), hop = hopAt(t, s.jumps);\n let img = pose.img, flip = pose.flip ?? s.flip, since = pose.since;\n // leap: anticipation crouch, arc, landing squash; the travel pose (running, facing right) flips with the direction\n let arc = 0, lean = 0, land = 0, crouch = 0;\n if (tr) {\n if (tr.x \u003c 0) crouch = Math.sin(Math.PI * (tr.x + .14) / .14) * .6 * tr.amt;\n else { arc = Math.sin(Math.PI * Math.min(1, tr.p / .92)) * tr.amt; lean = tr.dir * Math.sin(Math.PI * Math.min(1, tr.p / .8)) * .2 * tr.amt; }\n if (tr.p > .86) land = Math.sin(Math.PI * (tr.p - .86) / .125) * tr.amt;\n if (s.travelPose && tr.x >= 0 && tr.p \u003c .82) { img = s.travelPose; flip = tr.dir \u003c 0; since = t - tr.x; }\n }\n if (s.airPose && !tr && hop.lift > .25) { img = s.airPose; flip = s.flip; }\n const sw = swapK(t - since);\n const bounce = Math.abs(Math.sin(Math.PI * beat)) * dp, idle = 1 - dp;\n const sq = Math.max(hop.sq, crouch, land), up = Math.max(hop.lift, arc * .8);\n let sx = 1 + .09 * sw + .03 * nod.v * idle + .07 * sq - .04 * up, sy = 1 - .11 * sw - .045 * nod.v * idle - .11 * sq + .08 * up;\n if (tp) { sx *= tp.v; sy *= 1 + .22 * (1 - tp.v); } // zips thin and tall, then springs back\n sy += .012 * Math.sin(t * 2.6) * idle + .04 * (bounce - .5) * dp; sx -= .025 * (bounce - .5) * dp;\n const rot = lean + ((noise1(t * .45 + 3) - .5) * .07 + nod.dir * .035 * nod.v + Math.sin(t * 8.1) * .016 * talk) * idle + Math.sin(Math.PI * beat) * .06 * dp;\n const lift = -(hop.lift * h * .16 + arc * h * .38 + bounce * h * .04);\n const fx = (noise1(t * .3) - .5) * 26 * idle;\n // head-only gag: the body is cut off at the chin, then unrolls back down\n let clipY = null, cutA = 0;\n for (const [f0, f1] of s.headOnlySpans || []) {\n const cp = S(t - f0, 12) * (1 - S(t - f1, 9)); if (cp \u003c= .002) continue;\n clipY = lerp(h * .2, -FOOT * h + (s.chin ?? .6) * h, cp); cutA = clamp((cp - .7) / .3);\n }\n let filter = null;\n for (const [f0, f1] of s.hueSpans || []) if (t >= f0 && t \u003c f1 + .3) { const e = 1 - S(t - f1, 14); filter = `hue-rotate(${Math.round((t - f0) * 900 * e)}deg) saturate(${1 + .5 * e})`; }\n if (tr && tr.x > 0) speedLines(ctx, x + fx, footY + lift, h, tr.dir, Math.sin(Math.PI * Math.min(1, tr.p / .85)) * tr.amt);\n if (tp && s.dust !== false) { // puffs behind him: where he vanished, then where he lands\n const pf = tp.from; poof(ctx, pf[0] + fx, pf[1] - pf[2] * .45, pf[2] * .42, (tp.k + TP_OUT * .5) / .4);\n if (tp.k >= 0) poof(ctx, x + fx, footY - h * .45, h * .42, tp.k / .4);\n }\n if (!tp || tp.v > .01) drawMascot(ctx, R.img(murl(s, img)), { x: x + fx, y: footY, h: h * (s.sizes?.[img] ?? 1), lift, sx, sy, rot, clipY, cutA, filter, flip, gone: tp ? 1 - tp.v : 0 });\n // dust when he lands: after a leap, and after each jump of s.jumps\n if (s.dust !== false) {\n if (tr && tr.p > .8) dust(ctx, x + fx, footY, h, (tr.p - .8) / .2);\n for (const j of s.jumps || []) { const k = (t - (j.at ?? j) - .48) / .45; if (k > 0 && k \u003c 1) dust(ctx, x + fx, footY, h * .8, k); }\n }\n });\n },\n};\n// ---------------------------------------------------------------- bills\n// {from, at, to, n 22}: paper dollar bills flutter down over everything (money leaving the wallet).\nconst bills = {\n draw(R, s, t) {\n const { ctx } = R, out = 1 - S(t - (s.to ?? 1e9), 10); if (t \u003c s.at || out \u003c= .002) return;\n const n = s.n || 22, x0 = t - s.at;\n for (let i = 0; i \u003c n; i++) {\n const d = hash(i * 3.7) * (s.spread ?? 2.2), x = x0 - d; if (x \u003c= 0) continue;\n const vy = 330 + 260 * hash(i * 1.9), y = -140 + x * vy, bx = 80 + hash(i * 5.3) * (W - 160) + Math.sin(x * 2.6 + i) * 90;\n if (y > H + 140) continue;\n const rot = Math.sin(x * 2.1 + i * 2) * .7 + (hash(i) - .5), flipX = Math.cos(x * 4.2 + i * 1.3), sc = .8 + .45 * hash(i * 7.1);\n alpha(ctx, out, () => {\n ctx.translate(bx, y); ctx.rotate(rot); ctx.scale(sc * (.25 + .75 * Math.abs(flipX)), sc);\n const bw = 190, bh = 92;\n drop(ctx, .22, 18, 8); ctx.beginPath(); ctx.roundRect(-bw / 2 - 7, -bh / 2 - 7, bw + 14, bh + 14, 12); ctx.fillStyle = C.white; ctx.fill(); ctx.shadowColor = 'transparent';\n ctx.beginPath(); ctx.roundRect(-bw / 2, -bh / 2, bw, bh, 7); ctx.fillStyle = '#CFE3C4'; ctx.fill();\n ctx.lineWidth = 3; ctx.strokeStyle = '#4F7A47'; ctx.beginPath(); ctx.roundRect(-bw / 2 + 9, -bh / 2 + 9, bw - 18, bh - 18, 5); ctx.stroke();\n ctx.beginPath(); ctx.arc(0, 0, 27, 0, TAU); ctx.fillStyle = '#F4F8EF'; ctx.fill(); ctx.stroke();\n ctx.fillStyle = '#3F6A38'; ctx.font = '800 38px Inter'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText('

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, 0, 2);\n if (flipX \u003c 0) { ctx.fillStyle = 'rgba(40,60,30,.12)'; ctx.fillRect(-bw / 2, -bh / 2, bw, bh); }\n });\n }\n },\n};\n\n// ---------------------------------------------------------------- confetti\n// {at, to, x 540, y 900, n 140, power 1}: paper confetti burst (blue, ink, tint, white) with gravity and flutter.\nconst CONF = ['blue', 'ink', 'tint', 'white', 'blue', 'sheet'];\nconst confetti = {\n draw(R, s, t) {\n const { ctx } = R, x0 = t - s.at, out = 1 - S(t - (s.to ?? s.at + 3.5), 8); if (x0 \u003c= 0 || out \u003c= .002) return;\n const n = s.n || 140, pw = s.power || 1;\n for (let i = 0; i \u003c n; i++) {\n const ang = -Math.PI / 2 + (hash(i * 1.3) - .5) * (s.cone ?? 2.4), v = (900 + 1500 * hash(i * 2.7)) * pw;\n const drag = 2.2, e = (1 - Math.exp(-drag * x0)) / drag;\n const px = (s.x ?? W / 2) + Math.cos(ang) * v * e + Math.sin(x0 * 5 + i) * 26 * x0;\n const py = (s.y ?? 900) + Math.sin(ang) * v * e + 520 * x0 * x0;\n if (py > H + 40) continue;\n const fl = Math.cos(x0 * (7 + 6 * hash(i * 4.4)) + i), sz = 16 + 16 * hash(i * 6.1);\n alpha(ctx, out, () => {\n ctx.translate(px, py); ctx.rotate(x0 * (3 + 5 * hash(i)) + i); ctx.scale(1, .2 + .8 * Math.abs(fl));\n const c = CONF[i % CONF.length]; ctx.fillStyle = c === 'sheet' ? C.sheet : col(c);\n if (i % 3) ctx.fillRect(-sz / 2, -sz * .3, sz, sz * .6); else { ctx.beginPath(); ctx.arc(0, 0, sz * .38, 0, TAU); ctx.fill(); }\n });\n }\n },\n};\n\nexport const FACE = { paperFlow, bgPattern, talkHead, disco, dancefloor, mascot, bills, confetti };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":38097,"content_sha256":"5a15cc48eb3c8716e32e5efed61f86c45e849c38561e0fae034a1d933240b463"},{"filename":"engine/lib/scenes-life.js","content":"// Life scenes: a composable `board` for narrative / opinion shorts (first used by the \"live IA\" shorts).\n// One scene, many small items, each with its own spoken `at`, so a beat is authored as data instead of a new scene.\n// {type: 'board', from, to, pre, layer, items: [{k, at, to, x, y, ...}]}\n// Common item params: at (enter), to (exit), rise (px, default 40), dx (enter slide), posKeys [[t, [x, y]]],\n// scaleKeys [[t, s]], rotKeys [[t, rad]], pop (enter scale, e.g. .6, springs to 1), spin (enter rotation in rad, settles to rot),\n// dimAt (fade to 35% + desaturate to ink3), cut (in and out on the frame), still (never translates on enter/exit).\n// Board: push (slow zoom over its life, e.g. .06, around pushAt [x, y]; 0 = off),\n// outDx (exit slides sideways by outDx px instead of rising: a whip to the next board). Kinds:\n// text {text, size 64, weight 800, color, align 'center', words: true (stagger), emph, emphAt, lh, maxW}\n// chip {text, size 42, blue, dark, dot, dotColor (default the accent)}\n// nametag {text, size 28, weight 600, liveSpans [[a, b]]}: paper name tag, (x, y) = bottom-left, accent while live\n// label {text ('\\n' = new line), size 90, bg 'blue' | 'pop' | 'ink' | hex, color, rot -.035, skew .2, caps true, align, border} slanted thumbnail label box\n// stamp {text, size 70, rot -.07, solid true}\n// counter {value, fromValue 0, countFrom, countTo, size 200, prefix, suffix (blue, suffixColor 'ink' on a marker line), sep ' ', unit (small blue tail), decimals, decSep ',', clock (value in s shown m:ss, fixed digit slots)}\n// bar {w, h 96, value, fromValue 0, fillFrom, fillTo, label, valueText, blue, dark}\n// bubble {text, side 'me' | 'them', w 760, size 44, typeFrom, typeTo, name}\n// card {w, h, r 36, elev 1, fill 'ink' | 'tint' | null}\n// line {x2, y2, lw 8, color, dash, dur .45, head (arrow), bend (curve, e.g. .25)} x/y = start, drawn on over `dur`\n// circle {r, lw 6, color, dash, fill 'tint'}\n// dots {cols, rows, gap 56, r 18, color 'ink3', groups: [{i0, n, at, color}]}\n// thumbs {cols, rows, w 190, h 107, gap 18, stagger .05}\n// agents {cols 5, rows 5, w 168, h 112, gap 16, stagger .03, workFrom, workTo}\n// diverge {w 900, h 560, splitX .38, drawFrom, drawTo, upLabel, downLabel, meAt, meLabel}\n// check {s 80, color}\n// logo {logo: 'assets/logos/x.png', s 140, gray} a real app icon, centered\n// (any item) strikeAt + strikeW: a hand-drawn strike across the item, centered on (x, y), drawn left to right in .35 s; strikeColor (default 'blue',\n// 'pop' or 'ink' when the accent is a light yellow). A strike-only item (k 'text', text ' ') never rises in.\n// bar also takes logo (real icon left of the label)\n// img {img: 'assets/images/x.png', s 200 (max w), h (max h, default s), r (corner clip), frame (white print border px), shadow true} contain, centered\n// shatter {logo, s 260, breakAt, hit [x, y] (-1..1 of the icon, impact point), n 10 (rays), seed, tight} a real logo that holds, then\n// breaks into glass shards at breakAt (outward, gravity, spin) with a blue shockwave and spark lines; tight: the\n// shards are only the icon's own pieces (no white glass wedges around it, for a colored flat paper)\n// screen {img | imgKeys [[t, img]] (content swaps in place), w 900, h 620, r 30, viewKeys [[t, [cx, cy, zoom]]] (image px center, zoom 1 = fit width), viewW 7,\n// rings: [{at, to, rect: [x, y, w, h] image px, pad 10, dim true, dur .5}], arrows: [{at, to, tip: [x, y] image px, dx 160, dy -160, bend .18, label}]}\n// marks: [{at, rects|rect: [x, y, w, h] image px, dur .7, color, pad 8}] (highlighter swept over article text),\n// a real UI screenshot in a dark window; rings and arrows ride the zoom, arrows may start outside the window\n// glass {w, h, r 34, tint .62, blur 22} frosted panel over whatever is under it (camera), x/y = top-left\n// gauge {r 170, value .9, sweepFrom, sweepTo, unit} speedometer dial, needle + blue arc sweep with a motion trail\n// pixelate {img, w 300, h (from the image), r 26, sharpFrom, sharpTo, block 44} blocks resolve to sharp, then a light sweep\n// render {w 900, h 420, title, imgs: [frames], progFrom, progTo, prog 1, total (frames), elapsed (s)} a render crawling:\n// dark window, film strip developing frame by frame, striped progress bar, \"Frame n / total\", m:ss timer\n// edit {imgs: [clip frames], w 960, vh 150, markAt, cutAt, capsAt, fxAt, playFrom, playTo, steps: [{text, at}]} a timeline that\n// edits itself: the silences between real clips get boxed (markAt) and close (cutAt), \"Aa\" caption pills drop on each\n// clip (capsAt), blue animation blocks with a sparkle land (fxAt), a playhead runs; steps = blue chips checking one by one\n// term {w 960, h 640, size 52, title, logo, name 'Claude Code', sub, cmd, placeholder, focusAt, typeFrom, typeTo, suggest: [[name, desc]],\n// sendAt, out: [{text, at, dim}]} a Claude Code terminal: the slash command types in the prompt box with its\n// autocomplete list, Enter moves it to the history and the output lines land one by one\n// yt {img (thumbnail), title, channel, avatarImg, meta, dur, w 760} a YouTube video card, centered\n// toggle {label, onAt, size 40} a settings switch that flips to blue at onAt, centered\n// icon {name, s 1, color, accent, ...} names: robot, tomb, person, brain, gamepad, laptop, phone, coins, sun,\n// crown, hourglass, play, msg, trash, card, cap, clock, bulb, rocket, chart, bug\nimport { C, D, W, clamp, lerp, seg, E, S, leave, track, sheet, shadow, text, measure, midBase, wrap, chip, checkIcon, hash, noise1, lift, gloss, groove, deepness, drop, logoFinish, zoomAt, famCss } from './kit.js';\n\nconst TAU = Math.PI * 2;\nconst PIX = typeof document !== 'undefined' ? document.createElement('canvas') : null; // scratch canvas for pixelate\nconst hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));\nconst mix = (a, b, p) => { const x = hex(a), y = hex(b); return `rgb(${x.map((v, i) => Math.round(lerp(v, y[i], clamp(p)))).join(',')})`; };\nconst col = c => (c && C[c]) || c || C.ink;\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\n\n// ---------------------------------------------------------------- icons (line art, centered on 0,0, ~200 px at s = 1)\nfunction stroke(ctx, c, lw) { ctx.strokeStyle = c; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; }\nconst ICONS = {\n robot(ctx, o, t) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(0, -96); ctx.lineTo(0, -130); ctx.stroke();\n ctx.beginPath(); ctx.arc(0, -140, 12, 0, TAU); ctx.fillStyle = b; ctx.fill();\n ctx.beginPath(); ctx.roundRect(-110, -96, 220, 180, 46); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.roundRect(-128, -30, 18, 56, 8); ctx.roundRect(110, -30, 18, 56, 8); ctx.fillStyle = c; ctx.fill();\n const blink = o.blinkAt != null ? 1 - .9 * Math.sin(Math.PI * clamp((t - o.blinkAt) / .18)) : 1;\n for (const ex of [-46, 46]) { ctx.beginPath(); ctx.ellipse(ex, -18, 22, 22 * blink, 0, 0, TAU); ctx.fillStyle = b; ctx.fill(); }\n ctx.beginPath(); ctx.roundRect(-56, 32, 112, 26, 10); ctx.stroke();\n stroke(ctx, c, 5); for (const gx of [-28, 0, 28]) { ctx.beginPath(); ctx.moveTo(gx, 34); ctx.lineTo(gx, 56); ctx.stroke(); }\n if (o.reticleAt != null) { // targeting reticle closing in\n const p = S(t - o.reticleAt, 9); if (p > .002) alpha(ctx, p, () => {\n const r = lerp(260, 170, p); stroke(ctx, b, 6);\n ctx.beginPath(); ctx.arc(0, -10, r, 0, TAU); ctx.stroke();\n for (let k = 0; k \u003c 4; k++) { const a = k * TAU / 4; ctx.beginPath(); ctx.moveTo(Math.cos(a) * (r - 30), -10 + Math.sin(a) * (r - 30)); ctx.lineTo(Math.cos(a) * (r + 34), -10 + Math.sin(a) * (r + 34)); ctx.stroke(); }\n });\n }\n },\n tomb(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-90, 100); ctx.lineTo(-90, -40); ctx.arc(0, -40, 90, Math.PI, 0); ctx.lineTo(90, 100); ctx.closePath();\n ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-150, 100); ctx.lineTo(150, 100); ctx.stroke();\n text(ctx, o.label || 'FIN', 0, midBase(-6, 52), { size: 52, weight: 800, ls: 2, align: 'center', color: c });\n stroke(ctx, C.dim, 7); ctx.beginPath(); ctx.moveTo(-44, 52); ctx.lineTo(44, 52); ctx.stroke();\n },\n person(ctx, o) {\n const c = col(o.color); ctx.fillStyle = c;\n ctx.beginPath(); ctx.arc(0, -52, 46, 0, TAU); ctx.fill();\n ctx.beginPath(); ctx.moveTo(-86, 96); ctx.bezierCurveTo(-86, 12, -48, 6, 0, 6); ctx.bezierCurveTo(48, 6, 86, 12, 86, 96); ctx.closePath(); ctx.fill();\n },\n brain(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath();\n ctx.moveTo(0, -92); ctx.bezierCurveTo(-40, -118, -96, -100, -100, -56); ctx.bezierCurveTo(-140, -40, -134, 24, -100, 34);\n ctx.bezierCurveTo(-100, 80, -48, 96, -20, 76); ctx.bezierCurveTo(-10, 86, 10, 86, 20, 76); ctx.bezierCurveTo(48, 96, 100, 80, 100, 34);\n ctx.bezierCurveTo(134, 24, 140, -40, 100, -56); ctx.bezierCurveTo(96, -100, 40, -118, 0, -92); ctx.closePath();\n ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n stroke(ctx, c, 6);\n ctx.beginPath(); ctx.moveTo(0, -92); ctx.lineTo(0, 78); ctx.stroke();\n for (const sg of [-1, 1]) { ctx.beginPath(); ctx.moveTo(sg * 18, -40); ctx.bezierCurveTo(sg * 60, -50, sg * 70, -10, sg * 40, 4); ctx.stroke(); ctx.beginPath(); ctx.moveTo(sg * 24, 40); ctx.bezierCurveTo(sg * 56, 30, sg * 80, 44, sg * 84, 10); ctx.stroke(); }\n if (o.fill != null) { ctx.save(); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = 'rgba(37,142,255,.22)'; ctx.fillRect(-140, 90 - 200 * o.fill, 280, 200 * o.fill); ctx.restore(); void b; }\n },\n gamepad(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-60, -50); ctx.lineTo(60, -50); ctx.bezierCurveTo(130, -50, 150, 70, 110, 76); ctx.bezierCurveTo(80, 80, 66, 30, 40, 30);\n ctx.lineTo(-40, 30); ctx.bezierCurveTo(-66, 30, -80, 80, -110, 76); ctx.bezierCurveTo(-150, 70, -130, -50, -60, -50); ctx.closePath();\n ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n stroke(ctx, c, 8); ctx.beginPath(); ctx.moveTo(-82, -10); ctx.lineTo(-42, -10); ctx.moveTo(-62, -30); ctx.lineTo(-62, 10); ctx.stroke();\n for (const [bx, by] of [[52, -22], [80, 2]]) { ctx.beginPath(); ctx.arc(bx, by, 11, 0, TAU); ctx.fillStyle = b; ctx.fill(); }\n },\n laptop(ctx, o, t) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.roundRect(-130, -110, 260, 170, 16); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-170, 84); ctx.lineTo(170, 84); ctx.lineTo(150, 60); ctx.lineTo(-150, 60); ctx.closePath(); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n if (o.cpuFrom != null) { // CPU meter filling to red-line (ink), label\n const v = E.io(seg(t, o.cpuFrom, o.cpuTo ?? o.cpuFrom + 1.2));\n ctx.beginPath(); ctx.roundRect(-100, -20, 200, 30, 15); ctx.fillStyle = C.line; ctx.fill();\n ctx.beginPath(); ctx.roundRect(-100, -20, Math.max(30, 200 * v), 30, 15); ctx.fillStyle = v > .97 ? C.ink : b; ctx.fill();\n text(ctx, `CPU ${Math.round(v * 100)}%`, 0, -50, { size: 36, weight: 800, align: 'center', color: c });\n }\n },\n phone(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.roundRect(-64, -120, 128, 240, 26); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n stroke(ctx, c, 7); ctx.beginPath(); ctx.moveTo(-18, -96); ctx.lineTo(18, -96); ctx.stroke();\n },\n coins(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 8);\n for (let i = 0; i \u003c 4; i++) { const y = 60 - i * 34; ctx.beginPath(); ctx.ellipse(0, y, 96, 28, 0, 0, TAU); ctx.fillStyle = i === 3 ? b : C.sheet; ctx.fill(); ctx.stroke(); }\n text(ctx, o.label || '€', 0, midBase(-42, 40), { size: 40, weight: 800, align: 'center', color: C.white });\n },\n sun(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.arc(0, 0, 56, 0, TAU); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n for (let i = 0; i \u003c 8; i++) { const a = i * TAU / 8; ctx.beginPath(); ctx.moveTo(Math.cos(a) * 82, Math.sin(a) * 82); ctx.lineTo(Math.cos(a) * 110, Math.sin(a) * 110); ctx.stroke(); }\n },\n crown(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-110, 60); ctx.lineTo(-120, -50); ctx.lineTo(-56, 0); ctx.lineTo(0, -80); ctx.lineTo(56, 0); ctx.lineTo(120, -50); ctx.lineTo(110, 60); ctx.closePath();\n ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n for (const [bx, by] of [[-120, -50], [0, -80], [120, -50]]) { ctx.beginPath(); ctx.arc(bx, by - 6, 13, 0, TAU); ctx.fillStyle = b; ctx.fill(); }\n },\n hourglass(ctx, o, t) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n const v = o.flowFrom != null ? E.io(seg(t, o.flowFrom, o.flowTo ?? o.flowFrom + 1.5)) : .5;\n ctx.save(); ctx.beginPath(); ctx.moveTo(-70, -96); ctx.lineTo(70, -96); ctx.lineTo(8, 0); ctx.lineTo(70, 96); ctx.lineTo(-70, 96); ctx.lineTo(-8, 0); ctx.closePath(); ctx.clip();\n ctx.fillStyle = b; ctx.fillRect(-80, lerp(-60, 0, v), 160, 60 * (1 - v) + 2); ctx.fillRect(-80, 96 - 60 * v, 160, 60 * v); ctx.restore();\n ctx.beginPath(); ctx.moveTo(-70, -96); ctx.lineTo(70, -96); ctx.lineTo(8, 0); ctx.lineTo(70, 96); ctx.lineTo(-70, 96); ctx.lineTo(-8, 0); ctx.closePath(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-96, -96); ctx.lineTo(96, -96); ctx.moveTo(-96, 96); ctx.lineTo(96, 96); ctx.stroke();\n },\n play(ctx, o) {\n const c = col(o.accent || 'blue');\n ctx.beginPath(); ctx.roundRect(-100, -70, 200, 140, 40); ctx.fillStyle = c; ctx.fill();\n ctx.beginPath(); ctx.moveTo(-26, -36); ctx.lineTo(40, 0); ctx.lineTo(-26, 36); ctx.closePath(); ctx.fillStyle = C.white; ctx.fill();\n },\n msg(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.roundRect(-100, -76, 200, 130, 36); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-50, 54); ctx.lineTo(-64, 92); ctx.lineTo(-10, 54); ctx.stroke();\n for (const dx of [-40, 0, 40]) { ctx.beginPath(); ctx.arc(dx, -10, 10, 0, TAU); ctx.fillStyle = c; ctx.fill(); }\n },\n trash(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-76, -50); ctx.lineTo(-62, 96); ctx.lineTo(62, 96); ctx.lineTo(76, -50); ctx.closePath(); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-100, -56); ctx.lineTo(100, -56); ctx.moveTo(-30, -56); ctx.lineTo(-24, -84); ctx.lineTo(24, -84); ctx.lineTo(30, -56); ctx.stroke();\n stroke(ctx, c, 6); for (const dx of [-26, 0, 26]) { ctx.beginPath(); ctx.moveTo(dx, -20); ctx.lineTo(dx * .9, 66); ctx.stroke(); }\n },\n card(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.roundRect(-130, -80, 260, 160, 24); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.fillStyle = c; ctx.fillRect(-130, -42, 260, 30);\n ctx.beginPath(); ctx.roundRect(-96, 22, 60, 36, 8); ctx.fillStyle = b; ctx.fill();\n },\n cap(ctx, o) { // graduation cap (a school)\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-60, -10); ctx.lineTo(-60, 50); ctx.bezierCurveTo(-30, 76, 30, 76, 60, 50); ctx.lineTo(60, -10); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(0, -80); ctx.lineTo(140, -30); ctx.lineTo(0, 20); ctx.lineTo(-140, -30); ctx.closePath(); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(110, -20); ctx.lineTo(110, 50); ctx.stroke(); ctx.beginPath(); ctx.arc(110, 58, 12, 0, TAU); ctx.fillStyle = b; ctx.fill();\n },\n clock(ctx, o, t) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.arc(0, 0, 96, 0, TAU); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n const a = o.spinFrom != null ? E.io(seg(t, o.spinFrom, o.spinTo ?? o.spinFrom + 1.5)) * TAU * (o.turns || 4) : 0;\n stroke(ctx, c, 9); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(Math.sin(a / 12) * 50, -Math.cos(a / 12) * 50); ctx.stroke();\n stroke(ctx, b, 7); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(Math.sin(a) * 74, -Math.cos(a) * 74); ctx.stroke();\n },\n bulb(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-34, 50); ctx.bezierCurveTo(-34, 10, -84, -10, -84, -50); ctx.arc(0, -50, 84, Math.PI, 0); ctx.bezierCurveTo(84, -10, 34, 10, 34, 50); ctx.closePath();\n ctx.fillStyle = o.on ? C.tint : C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(-30, 76); ctx.lineTo(30, 76); ctx.moveTo(-22, 100); ctx.lineTo(22, 100); ctx.stroke();\n if (o.on) { stroke(ctx, b, 7); for (const a of [-2.4, -1.57, -.74]) { ctx.beginPath(); ctx.moveTo(Math.cos(a) * 112, -50 + Math.sin(a) * 112); ctx.lineTo(Math.cos(a) * 140, -50 + Math.sin(a) * 140); ctx.stroke(); } }\n },\n rocket(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(0, -120); ctx.bezierCurveTo(56, -80, 56, 10, 40, 60); ctx.lineTo(-40, 60); ctx.bezierCurveTo(-56, 10, -56, -80, 0, -120); ctx.closePath(); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.arc(0, -40, 18, 0, TAU); ctx.fillStyle = b; ctx.fill();\n ctx.beginPath(); ctx.moveTo(-40, 20); ctx.lineTo(-76, 70); ctx.lineTo(-36, 60); ctx.moveTo(40, 20); ctx.lineTo(76, 70); ctx.lineTo(36, 60); ctx.stroke();\n stroke(ctx, b, 8); ctx.beginPath(); ctx.moveTo(-16, 80); ctx.lineTo(-16, 112); ctx.moveTo(16, 80); ctx.lineTo(16, 112); ctx.stroke();\n },\n chart(ctx, o) {\n const c = col(o.color), b = col(o.accent || 'blue'); stroke(ctx, c, 9);\n ctx.beginPath(); ctx.moveTo(-110, -90); ctx.lineTo(-110, 90); ctx.lineTo(120, 90); ctx.stroke();\n stroke(ctx, b, 10); ctx.beginPath(); ctx.moveTo(-80, 50); ctx.lineTo(-20, 10); ctx.lineTo(20, 30); ctx.lineTo(96, -60); ctx.stroke();\n },\n bug(ctx, o) {\n const c = col(o.color); stroke(ctx, c, 8);\n ctx.beginPath(); ctx.ellipse(0, 16, 58, 76, 0, 0, TAU); ctx.fillStyle = C.sheet; ctx.fill(); ctx.stroke();\n ctx.beginPath(); ctx.arc(0, -70, 30, Math.PI, 0); ctx.stroke(); ctx.beginPath(); ctx.moveTo(0, -56); ctx.lineTo(0, 92); ctx.stroke();\n for (const sg of [-1, 1]) for (const y of [-20, 20, 60]) { ctx.beginPath(); ctx.moveTo(sg * 56, y); ctx.lineTo(sg * 96, y - 18); ctx.stroke(); }\n for (const sg of [-1, 1]) { ctx.beginPath(); ctx.moveTo(sg * 14, -96); ctx.lineTo(sg * 36, -126); ctx.stroke(); }\n },\n};\n\n// ---------------------------------------------------------------- item drawers. (0, 0) is already the item position.\nconst DRAW = {\n text(ctx, it, t) {\n // style.display (kit D): big texts (100 px and more, or depth) take the display family, the others its text family;\n // it.font (+ it.weight, default 400) overrides both, e.g. a brand serif on the end card\n const size = it.size || 64, big = !it.font && D.font && (it.depth || size >= 100), fam = it.font ? famCss(it.font) : big ? D.font : D.text || undefined;\n const wt = it.font ? it.weight || 400 : big ? D.weight : it.weight || 800, ls = it.ls ?? -size * .03, lh = it.lh || size * 1.12, depth = D.flat ? 0 : it.depth;\n const c = col(it.color || (D.title && !big && size \u003c= 64 ? D.title : null));\n const lines = it.maxW ? wrap(ctx, it.text, it.maxW, size, wt, ls, fam) : String(it.text).split('\\n');\n const emph = new Set(String(it.emph || '').split(' ').filter(Boolean));\n lines.forEach((ln, li) => {\n const base = midBase((li - (lines.length - 1) / 2) * lh, size), words = ln.split(' ');\n const ws = words.map(w => measure(ctx, w, size, wt, ls, fam)), sp = measure(ctx, ' ', size, wt, ls, fam), tw = ws.reduce((a, b) => a + b, 0) + sp * (words.length - 1);\n let x = it.align === 'left' ? 0 : it.align === 'right' ? -tw : -tw / 2;\n words.forEach((w, i) => {\n const p = it.words ? S(t - it.at - (li * words.length + i) * .055, 14) : 1, isE = emph.has(w);\n const cc = isE ? (it.emphAt != null ? mix(C.ink, C.blue, S(t - it.emphAt, 14)) : C.blue) : c, wx = x;\n if (p > .002) alpha(ctx, Math.min(1, p * 1.4), () => text(ctx, w, wx, base + (1 - p) * 40, { size, weight: wt, ls, fam, color: cc, halo: it.halo, depth }));\n x += ws[i] + sp;\n });\n });\n },\n chip(ctx, it) { chip(ctx, 0, 0, it.text, { size: it.size || 42, h: Math.round((it.size || 42) * 2), fill: it.blue ? C.blue : it.dark ? C.ink : undefined, color: it.blue ? C.onBlue : it.dark ? C.white : C.ink, border: !(it.blue || it.dark), dot: it.dot && (it.dotColor ? col(it.dotColor) : C.blue), align: it.align }); },\n // Name tag on a camera tile: a flat paper sheet, ink name, (x, y) = its BOTTOM-LEFT corner so tags line up on the\n // tile edges. liveSpans: [[a, b], ...] time ranges where it turns accent (the speaker), a 120 ms colour swap in\n // place (an edge on the tag's own at/to is instant: the camera cut already carries it).\n // imgs: [paper, live] PNGs of a name tag (CSS torn paper, 2x, 14 px margin\n // around the paper body): drawn instead of the canvas sheet.\n nametag(ctx, it, t, R) {\n const size = it.size || 28, h = Math.round(size * 1.8), padX = Math.round(size * .62), wt = it.weight || 600, r = Math.round(size * .18);\n const at = it.at ?? -1e9, to = it.to ?? 1e9;\n const on = (it.liveSpans || []).reduce((m, [a, b]) => Math.max(m, Math.min(a \u003c= at + .05 ? +(t >= a) : clamp((t - a) / .12), b >= to - .05 ? +(t \u003c b) : clamp((b - t) / .12))), 0);\n const [p0, p1] = (it.imgs || []).map(u => R?.img('/p/' + u));\n if (p0) {\n const M = 14, dw = p0.width / 2, dh = p0.height / 2, draw = (im, a) => { if (im && a > .002) { ctx.save(); ctx.globalAlpha *= a; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, -M, M - dh, dw, dh); ctx.restore(); } };\n draw(p0, p1 ? 1 - on : 1); draw(p1, on); return;\n }\n const w = Math.round(measure(ctx, it.text, size, wt, -.2) + padX * 2);\n sheet(ctx, 0, -h, w, h, r, { elev: .3 });\n if (on > .002) sheet(ctx, 0, -h, w, h, r, { fill: C.blue, alpha: on, shadow: false, border: false });\n text(ctx, it.text, padX, midBase(-h / 2, size), { size, weight: wt, ls: -.2, color: C.ink });\n },\n // Slanted label box (the podcast thumbnail look): heavy condensed italic caps on a flat color block, tilted a few\n // degrees. bg: 'blue' (accent), 'pop' (style.colors.pop), 'ink', 'sheet' or a hex; the text color follows (ink on a\n // light fill, white on a dark one) unless color is set. The block wipes in from the left over .26 s with its text.\n // size 90, rot -.035, skew .2 (italic slant), stretch 'extra-condensed', caps true, align center|left|right\n label(ctx, it, t) {\n const size = it.size || 90, bg = it.bg === 'blue' || it.bg == null ? C.blue : col(it.bg), txt = it.caps === false ? String(it.text) : String(it.text).toUpperCase();\n const light = (h => { const m = String(h).match(/^#?([0-9a-f]{6})$/i); if (!m) return true; const v = parseInt(m[1], 16); return (.2126 * (v >> 16) + .7152 * (v >> 8 & 255) + .0722 * (v & 255)) / 255 > .55; })(bg);\n const fg = it.color ? col(it.color) : bg === C.blue ? C.onBlue : light ? C.ink : C.white;\n ctx.save(); ctx.font = `900 ${size}px Archivo`; ctx.fontStretch = it.stretch || 'extra-condensed'; ctx.letterSpacing = (it.ls ?? size * .005) + 'px';\n const lines = txt.split('\\n'), lh = size * .98, tw = Math.max(...lines.map(l => ctx.measureText(l).width));\n const px = size * .3, py = size * .16, w = tw + px * 2, h = lines.length * lh + py * 2 - (lh - size * .9), x0 = it.align === 'left' ? 0 : it.align === 'right' ? -w : -w / 2;\n const q = E.out(seg(t, it.at, it.at + .26));\n ctx.rotate(it.rot ?? -.035);\n if (q \u003c .999) { ctx.beginPath(); ctx.rect(x0 - 40, -h / 2 - 40, (w + 80) * q, h + 80); ctx.clip(); }\n ctx.save(); drop(ctx, .26, 18, 8); ctx.fillStyle = bg; ctx.fillRect(x0, -h / 2, w, h); ctx.restore();\n if (it.border) { ctx.lineWidth = it.border; ctx.strokeStyle = C.ink; ctx.strokeRect(x0, -h / 2, w, h); }\n ctx.fillStyle = fg; ctx.textAlign = 'center'; ctx.textBaseline = 'alphabetic';\n const sk = it.skew ?? .2;\n lines.forEach((l, i) => {\n const by = -h / 2 + py + size * .82 + i * lh; // caps height ~.72 em: optically centered in the block\n ctx.save(); ctx.translate(x0 + w / 2, by); ctx.transform(1, 0, -sk, 1, 0, 0); ctx.fillText(l, 0, 0); ctx.restore();\n });\n ctx.restore();\n },\n stamp(ctx, it, t) {\n const p = S(t - it.at, 11), size = it.size || 70, w = measure(ctx, it.text, size, 800, -1) + 76, h = size + 54;\n ctx.rotate(it.rot ?? -.07); const k = 1 + .3 * (1 - p); ctx.scale(k, k);\n if (it.solid !== false) shadow(ctx, -w / 2, -h / 2, w, h, 22, 22, .2, 8);\n ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, 22); ctx.fillStyle = it.solid !== false ? C.sheet : 'rgba(37,142,255,.08)'; ctx.fill();\n ctx.lineWidth = 8; ctx.strokeStyle = C.blue; ctx.stroke();\n ctx.beginPath(); ctx.roundRect(-w / 2 + 12, -h / 2 + 12, w - 24, h - 24, 14); ctx.lineWidth = 2.5; ctx.stroke();\n text(ctx, it.text, 0, midBase(0, size), { size, weight: 800, ls: -1, align: 'center', color: C.blue });\n },\n counter(ctx, it, t) {\n const size = it.size || 200, k = E.out(seg(t, it.countFrom ?? it.at, it.countTo ?? (it.countFrom ?? it.at) + .9));\n const fam = D.font || undefined, wt = D.font ? D.weight : 800, ls = -size * (D.font ? .03 : .035), depth = D.flat ? 0 : it.depth;\n const v = lerp(it.fromValue || 0, it.value, k), dec = it.decimals || 0;\n if (it.clock) { // stopwatch m:ss (value in seconds), every digit in the same slot so the timer never jitters while it runs\n const sec = Math.round(v), str = `${String(Math.floor(sec / 60)).padStart(2, '0')}:${String(sec % 60).padStart(2, '0')}`;\n const dw = Math.max(...'0123456789'.split('').map(d => measure(ctx, d, size, wt, ls, fam))), cw = measure(ctx, ':', size, wt, ls, fam);\n const adv = c => c === ':' ? cw : dw, tw = [...str].reduce((a, c) => a + adv(c), 0);\n let x = it.align === 'left' ? 0 : -tw / 2;\n for (const c of str) { text(ctx, c, x + adv(c) / 2, midBase(0, size), { size, weight: wt, ls, fam, color: col(it.color), depth, align: 'center' }); x += adv(c); }\n return;\n }\n let s = v.toFixed(dec); if (dec && it.decSep) s = s.replace('.', it.decSep); if (it.sep !== false && !dec) s = s.replace(/\\B(?=(\\d{3})+(?!\\d))/g, it.sep ?? ' ');\n const pre = it.prefix || '', suf = it.suffix || '', unit = it.unit || '', ufam = D.text || undefined;\n const sw = measure(ctx, pre + s + suf, size, wt, ls, fam), uw = unit ? measure(ctx, unit, size * .42, 800, -1, ufam) + size * .08 : 0;\n const x0 = it.align === 'left' ? 0 : -(sw + uw) / 2;\n text(ctx, pre + s, x0, midBase(0, size), { size, weight: wt, ls, fam, color: col(it.color), depth });\n if (suf) text(ctx, suf, x0 + measure(ctx, pre + s, size, wt, ls, fam), midBase(0, size), { size, weight: wt, ls, fam, color: it.suffixColor ? col(it.suffixColor) : C.blue, depth });\n if (unit) text(ctx, unit, x0 + sw + size * .08, midBase(size * .18, size * .42), { size: size * .42, weight: 800, ls: -1, fam: ufam, color: C.blue });\n },\n bar(ctx, it, t, R) {\n const w = it.w || 900, h = it.h || 96, g = E.out(seg(t, it.fillFrom ?? it.at + .05, it.fillTo ?? (it.fillFrom ?? it.at + .05) + .8));\n const v = lerp(it.fromValue || 0, it.value, g), ls = (it.labelSize || 46);\n if (it.logo) logoAt(R, ctx, it.logo, ls * .65, -24 - ls * .36, ls * 1.3);\n if (it.label) text(ctx, it.label, it.logo ? ls * 1.55 : 0, -24, { size: ls, weight: 800, ls: -.8, color: it.blue ? C.ink : C.ink2 });\n ctx.beginPath(); ctx.roundRect(0, 0, w, h, h / 2); ctx.fillStyle = 'rgba(40,30,10,.06)'; ctx.fill(); groove(ctx, 0, 0, w, h, h / 2);\n if (v > .001) { const fw = Math.max(h, w * v); lift(ctx, 0, 0, fw, h, h / 2, .5); ctx.beginPath(); ctx.roundRect(0, 0, fw, h, h / 2); ctx.fillStyle = it.blue ? C.blue : it.dark ? C.ink : C.ink3; ctx.fill(); gloss(ctx, 0, 0, fw, h, h / 2); }\n if (it.valueText) text(ctx, it.valueText, w, -24, { size: it.labelSize || 46, weight: 800, align: 'right', color: it.blue ? C.blue : C.ink2 });\n },\n bubble(ctx, it, t) {\n const me = it.side === 'me', size = it.size || 44, w = it.w || 760, padX = 40, padY = 30;\n const lines = wrap(ctx, it.text, w - padX * 2, size, 600, -.3), lh = size * 1.28, h = lines.length * lh + padY * 2 - (lh - size) + 6;\n const bw = Math.min(w, Math.max(...lines.map(l => measure(ctx, l, size, 600, -.3))) + padX * 2), x0 = me ? -bw : 0;\n sheet(ctx, x0, 0, bw, h, 36, { fill: me ? C.blue : undefined, elev: .8, border: !me });\n let n = Infinity;\n if (it.typeFrom != null) { const tot = it.text.split(' ').length; n = Math.round(tot * seg(t, it.typeFrom, it.typeTo ?? it.typeFrom + 1)); }\n let k = 0;\n lines.forEach((ln, i) => {\n const ws = ln.split(' '), vis = ws.slice(0, Math.max(0, n - k)).join(' '); k += ws.length;\n if (vis) text(ctx, vis, x0 + padX, padY + size * .8 + i * lh, { size, weight: 600, ls: -.3, color: me ? C.onBlue : C.ink });\n });\n if (it.name) text(ctx, it.name, me ? 0 : 6, -20, { size: 34, weight: 700, align: me ? 'right' : 'left', color: C.ink3 });\n },\n card(ctx, it, t) {\n const r = it.r ?? 36;\n sheet(ctx, 0, 0, it.w, it.h, r, { elev: it.elev ?? 1, fill: it.fill === 'ink' ? C.ink : it.fill === 'tint' ? C.tint : undefined, border: !it.fill });\n if (it.ringAt != null) { const q = S(t - it.ringAt, 12); if (q > .002) alpha(ctx, q, () => { ctx.beginPath(); ctx.roundRect(-10, -10, it.w + 20, it.h + 20, r + 10); stroke(ctx, C.blue, 7); ctx.stroke(); }); }\n },\n reticle(ctx, it, t) { // a sight closing on (x, y): ring shrinks from 1.7 r to r, four ticks, a blue dot when locked\n const p = E.io(seg(t, it.closeFrom ?? it.at, it.closeTo ?? (it.closeFrom ?? it.at) + .8)), r = lerp(it.r * 1.7, it.r, p);\n stroke(ctx, C.blue, it.lw || 7); ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.stroke();\n for (let k = 0; k \u003c 4; k++) { const a = k * TAU / 4; ctx.beginPath(); ctx.moveTo(Math.cos(a) * (r - 34), Math.sin(a) * (r - 34)); ctx.lineTo(Math.cos(a) * (r + 40), Math.sin(a) * (r + 40)); ctx.stroke(); }\n if (p > .98) { ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); }\n },\n line(ctx, it, t) {\n const d = E.out(seg(t, it.at, it.at + (it.dur ?? .45))), X = it.x2 - it.x, Y = it.y2 - it.y;\n stroke(ctx, col(it.color), it.lw || 8); if (it.dash) ctx.setLineDash(it.dash);\n if (it.bend) { // hand-drawn curve: quadratic bezier bowed sideways by bend (fraction of the length), drawn on along the curve\n const mx = X / 2 - Y * it.bend, my = Y / 2 + X * it.bend, bz = u => [2 * (1 - u) * u * mx + u * u * X, 2 * (1 - u) * u * my + u * u * Y];\n ctx.beginPath(); ctx.moveTo(0, 0); for (let u = .04; u \u003c d; u += .04) ctx.lineTo(...bz(u)); ctx.lineTo(...bz(d)); ctx.stroke(); ctx.setLineDash([]);\n if (it.head && d > .6) { const [x2, y2] = bz(d), [xa, ya] = bz(Math.max(0, d - .05)), a = Math.atan2(y2 - ya, x2 - xa), L = 30; ctx.beginPath(); ctx.moveTo(x2 - Math.cos(a - .5) * L, y2 - Math.sin(a - .5) * L); ctx.lineTo(x2, y2); ctx.lineTo(x2 - Math.cos(a + .5) * L, y2 - Math.sin(a + .5) * L); ctx.stroke(); }\n return;\n }\n const x2 = lerp(0, X, d), y2 = lerp(0, Y, d);\n ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(x2, y2); ctx.stroke(); ctx.setLineDash([]);\n if (it.head && d > .6) { const a = Math.atan2(Y, X), L = 30; ctx.beginPath(); ctx.moveTo(x2 - Math.cos(a - .5) * L, y2 - Math.sin(a - .5) * L); ctx.lineTo(x2, y2); ctx.lineTo(x2 - Math.cos(a + .5) * L, y2 - Math.sin(a + .5) * L); ctx.stroke(); }\n },\n circle(ctx, it) {\n ctx.beginPath(); ctx.arc(0, 0, it.r, 0, TAU);\n if (it.fill) { ctx.fillStyle = it.fill === 'tint' ? 'rgba(37,142,255,.08)' : col(it.fill); ctx.fill(); }\n stroke(ctx, col(it.color || 'blue'), it.lw || 6); if (it.dash) ctx.setLineDash(it.dash); ctx.stroke(); ctx.setLineDash([]);\n },\n rect(ctx, it) { // outline zone (a \"bubble\" around a group); x, y = top-left\n ctx.beginPath(); ctx.roundRect(0, 0, it.w, it.h, it.r ?? it.h / 2);\n if (it.fill) { ctx.fillStyle = it.fill === 'tint' ? 'rgba(37,142,255,.08)' : col(it.fill); ctx.fill(); }\n stroke(ctx, col(it.color || 'blue'), it.lw || 6); if (it.dash) ctx.setLineDash(it.dash); ctx.stroke(); ctx.setLineDash([]);\n },\n dots(ctx, it, t) {\n const cols = it.cols, rows = it.rows, g = it.gap || 56, r = it.r || 18, n = cols * rows, x0 = -(cols - 1) * g / 2, y0 = -(rows - 1) * g / 2;\n for (let i = 0; i \u003c n; i++) {\n const a = S(t - it.at - i * (it.stagger ?? .008), 14); if (a \u003c= .002) continue;\n let c = col(it.color || 'ink3');\n for (const gr of it.groups || []) if (i >= (gr.i0 || 0) && i \u003c (gr.i0 || 0) + gr.n) { const q = S(t - gr.at - (i - (gr.i0 || 0)) * .006, 13); if (q > .002) c = mix(typeof c === 'string' && c[0] === '#' ? c : C.ink3, col(gr.color), q); }\n alpha(ctx, Math.min(1, a * 1.4), () => { ctx.beginPath(); ctx.arc(x0 + (i % cols) * g, y0 + Math.floor(i / cols) * g + (1 - a) * 20, r, 0, TAU); ctx.fillStyle = c; ctx.fill(); });\n }\n },\n thumbs(ctx, it, t) {\n const cols = it.cols, rows = it.rows, w = it.w || 190, h = it.h || 107, g = it.gap || 18, n = cols * rows;\n const X0 = -(cols * w + (cols - 1) * g) / 2, Y0 = -(rows * h + (rows - 1) * g) / 2;\n for (let i = 0; i \u003c n; i++) {\n const a = S(t - it.at - i * (it.stagger ?? .05), 14); if (a \u003c= .002) continue;\n const x = X0 + (i % cols) * (w + g), y = Y0 + Math.floor(i / cols) * (h + g), hh = hash(i * 7.3 + 2);\n alpha(ctx, Math.min(1, a * 1.4), () => {\n ctx.save(); ctx.translate(0, (1 - a) * 24);\n sheet(ctx, x, y, w, h, 14, { elev: .5 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 14); ctx.clip();\n ctx.fillStyle = hh > .7 ? C.tint : hh > .35 ? C.line : '#EFEBE2'; ctx.fillRect(x, y, w, h);\n ctx.fillStyle = hh > .5 ? C.ink : C.blue; ctx.fillRect(x + 14, y + 16, w * (.35 + .3 * hash(i + 9)), 14); ctx.fillRect(x + 14, y + 38, w * (.2 + .2 * hash(i + 3)), 14);\n ctx.beginPath(); ctx.arc(x + w * .74, y + h * .62, h * .28, 0, TAU); ctx.fillStyle = C.ink3; ctx.fill();\n ctx.restore(); ctx.restore();\n });\n }\n },\n agents(ctx, it, t) {\n const cols = it.cols || 5, rows = it.rows || 5, w = it.w || 168, h = it.h || 112, g = it.gap || 16, n = cols * rows;\n const X0 = -(cols * w + (cols - 1) * g) / 2, Y0 = -(rows * h + (rows - 1) * g) / 2;\n for (let i = 0; i \u003c n; i++) {\n const a = S(t - it.at - i * (it.stagger ?? .03), 14); if (a \u003c= .002) continue;\n const x = X0 + (i % cols) * (w + g), y = Y0 + Math.floor(i / cols) * (h + g);\n const sp = .25 + .75 * hash(i * 3.1 + 1), wf = it.workFrom ?? it.at, wt = it.workTo ?? wf + 4;\n const v = clamp(((t - wf) / (wt - wf)) * sp * 1.4 + hash(i) * .15), done = v >= 1;\n alpha(ctx, Math.min(1, a * 1.4), () => {\n ctx.save(); ctx.translate(0, (1 - a) * 24);\n sheet(ctx, x, y, w, h, 18, { elev: .5 });\n ctx.beginPath(); ctx.arc(x + 30, y + 32, 11, 0, TAU); ctx.fillStyle = done ? C.blue : C.ink; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + 52, y + 24, w - 76, 16, 8); ctx.fillStyle = C.line; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + 20, y + h - 40, w - 40, 16, 8); ctx.fillStyle = C.line; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + 20, y + h - 40, Math.max(16, (w - 40) * v), 16, 8); ctx.fillStyle = done ? C.blue : C.ink3; ctx.fill();\n if (done) checkIcon(ctx, x + w - 34, y + 32, 30, C.blue, 1, 6);\n ctx.restore();\n });\n }\n },\n diverge(ctx, it, t) {\n const w = it.w || 900, h = it.h || 560, sx = w * (it.splitX ?? .38), my = h / 2, d = E.io(seg(t, it.drawFrom ?? it.at, it.drawTo ?? (it.drawFrom ?? it.at) + 1.6));\n stroke(ctx, C.ink3, 4); ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, h); ctx.lineTo(w, h); ctx.stroke();\n text(ctx, it.xLabel || 'Avec l\\'IA', w, h + 56, { size: 36, weight: 700, align: 'right', color: C.ink3 });\n const curve = (sg, u) => { const x = u * w; if (x \u003c= sx) return [x, my]; const k = (x - sx) / (w - sx); return [x, my - sg * (h * .44) * Math.pow(k, 1.6)]; };\n for (const [sg, c, lw] of [[-1, C.ink3, 12], [1, C.blue, 14]]) {\n stroke(ctx, c, lw); ctx.beginPath();\n for (let i = 0; i \u003c= 60 * d; i++) { const [x, y] = curve(sg, i / 60); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }\n ctx.stroke();\n }\n const lab = (txt, sg, c, at) => { const q = S(t - at, 13); if (q > .002 && txt) alpha(ctx, q, () => { const [x, y] = curve(sg, 1); text(ctx, txt, x - 10, y + (sg > 0 ? -40 : -34), { size: 44, weight: 800, align: 'right', color: c }); }); };\n lab(it.upLabel, 1, C.blue, it.upAt ?? (it.drawTo ?? it.at + 1.6));\n lab(it.downLabel, -1, C.ink2, it.downAt ?? (it.drawTo ?? it.at + 1.6));\n if (it.meAt != null) { const q = S(t - it.meAt, 12); if (q > .002) { const [x, y] = curve(1, .92); alpha(ctx, q, () => { ctx.beginPath(); ctx.arc(x, y, 22, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); ctx.lineWidth = 6; ctx.strokeStyle = C.sheet; ctx.stroke(); chip(ctx, x - 40, y + 70, it.meLabel || 'Moi', { size: 40, h: 80, align: 'right', fill: C.ink, color: C.white, border: false }); }); } }\n },\n check(ctx, it, t) { checkIcon(ctx, 0, 0, it.s || 80, col(it.color || 'blue'), E.out(seg(t, it.at, it.at + .35)), (it.s || 80) * .16); },\n logo(ctx, it, t, R) { logoAt(R, ctx, it.logo, 0, 0, it.s || 140, it.gray); },\n img(ctx, it, t, R) {\n const im = R?.img('/p/' + it.img); if (!im) return;\n const mw = it.s || 200, mh = it.h || mw, k = Math.min(mw / im.width, mh / im.height), w = im.width * k, h = im.height * k;\n ctx.save(); ctx.imageSmoothingQuality = 'high';\n if (it.frame) { // white print border around the photo\n const f = it.frame, R0 = (it.r || 0) + f * .6;\n if (it.shadow !== false) lift(ctx, -w / 2 - f, -h / 2 - f, w + 2 * f, h + 2 * f, R0, w > 300 ? 1 : .5);\n ctx.beginPath(); ctx.roundRect(-w / 2 - f, -h / 2 - f, w + 2 * f, h + 2 * f, R0); ctx.fillStyle = C.white; ctx.fill();\n ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, it.r || 0); ctx.clip();\n } else if (it.r) { if (it.shadow !== false) lift(ctx, -w / 2, -h / 2, w, h, it.r, w > 300 ? 1 : .5); ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, it.r); ctx.clip(); }\n else if (it.shadow !== false) drop(ctx, .2, 26, 12);\n ctx.drawImage(im, -w / 2, -h / 2, w, h); ctx.restore();\n },\n screen(ctx, it, t, R) { // a real UI screenshot in a dark window, with spring zooms, blue rings and arrows that follow the zoom\n let ki = 0; if (it.imgKeys) while (ki + 1 \u003c it.imgKeys.length && it.imgKeys[ki + 1][0] \u003c= t) ki++; // imgKeys: the content swaps in place\n const im = R?.img('/p/' + (it.imgKeys ? it.imgKeys[ki][1] : it.img)); if (!im) return;\n const prev = ki > 0 && R?.img('/p/' + it.imgKeys[ki - 1][1]), swap = prev ? S(t - it.imgKeys[ki][0], 18) : 1;\n const w = it.w || 900, h = it.h || 620, r = it.r ?? 30, x0 = -w / 2, y0 = -h / 2;\n const [vx, vy, z] = it.viewKeys ? track(t, it.viewKeys, it.viewW || 7) : [im.width / 2, im.height / 2, 1];\n const k = (w / im.width) * z, hw = w / 2 / k, hh = h / 2 / k;\n const cx = im.width > hw * 2 ? clamp(vx, hw, im.width - hw) : im.width / 2;\n const cy = im.height > hh * 2 ? clamp(vy, hh, im.height - hh) : im.height / 2;\n const P = (ix, iy) => [(ix - cx) * k, (iy - cy) * k];\n const lit = it.lit ?? 1; // morph: the window lifts off the page as it grows\n if (it.shadow !== false && lit > .002) { ctx.save(); ctx.globalAlpha *= lit; lift(ctx, x0, y0, w, h, r, 1.2); ctx.restore(); }\n ctx.save(); if (it.shadow !== false) drop(ctx, .28 * lit, 40, 18);\n ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.fillStyle = it.bg || '#1B1B1D'; ctx.fill(); ctx.restore();\n ctx.save(); ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.clip(); ctx.imageSmoothingQuality = 'high';\n if (swap \u003c 1) ctx.drawImage(prev, -cx * k, -cy * k, im.width * k, im.height * k);\n alpha(ctx, swap, () => ctx.drawImage(im, -cx * k, -cy * k, im.width * k, im.height * k));\n for (const g of it.rings || []) { // focus ring: dims the rest of the window, then draws a blue outline on\n const q = S(t - g.at, 12) * (g.to != null ? 1 - leave(t - g.to) : 1); if (q \u003c= .002) continue;\n const pad = g.pad ?? 10, [ax, ay] = P(g.rect[0], g.rect[1]), rw = g.rect[2] * k + pad * 2, rh = g.rect[3] * k + pad * 2, rr = Math.min(g.r ?? 18, rh / 2);\n if (g.dim !== false) { const m = new Path2D(); m.rect(x0, y0, w, h); m.roundRect(ax - pad, ay - pad, rw, rh, rr); ctx.fillStyle = `rgba(8,8,10,${.5 * q})`; ctx.fill(m, 'evenodd'); }\n const d = E.out(seg(t, g.at, g.at + (g.dur ?? .5))), L = 2 * (rw + rh);\n alpha(ctx, q, () => { ctx.beginPath(); ctx.roundRect(ax - pad, ay - pad, rw, rh, rr); stroke(ctx, C.blue, g.lw || 7); ctx.setLineDash([L * d, L]); ctx.stroke(); ctx.setLineDash([]); });\n }\n for (const m of it.marks || []) { // highlighter over the text (multiply keeps the words ink), swept rect by rect, rides the zoom\n const rs = m.rects || [m.rect], tot = rs.reduce((a, q) => a + q[2], 0); let left = tot * E.cubic(seg(t, m.at, m.at + (m.dur ?? .7)));\n if (left \u003c= .5) continue;\n ctx.save(); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = m.color || 'rgba(37,142,255,.3)'; const px = m.pad ?? 8;\n for (const q of rs) { if (left \u003c= 0) break; const gw = Math.min(left, q[2]); left -= q[2]; const [ax, ay] = P(q[0] - px, q[1] - px * .6);\n ctx.beginPath(); ctx.roundRect(ax, ay, (gw + 2 * px) * k, (q[3] + 1.2 * px) * k, 8); ctx.fill(); }\n ctx.restore();\n }\n ctx.restore();\n ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(255,255,255,.14)'; ctx.stroke();\n for (const a of it.arrows || []) { // arrow drawn on from (tip + dx, dy) to the tip, tip in image px; may start outside the window\n const q = a.to != null ? 1 - leave(t - a.to) : 1, d = E.out(seg(t, a.at, a.at + (a.dur ?? .5))); if (d \u003c= .002 || q \u003c= .002) continue;\n const [tx, ty] = P(a.tip[0], a.tip[1]), sx = tx + (a.dx ?? 160), sy = ty + (a.dy ?? -160), bend = a.bend ?? .18;\n const mx = (sx + tx) / 2 - (ty - sy) * bend, my = (sy + ty) / 2 + (tx - sx) * bend;\n const bz = u => [(1 - u) ** 2 * sx + 2 * (1 - u) * u * mx + u * u * tx, (1 - u) ** 2 * sy + 2 * (1 - u) * u * my + u * u * ty];\n alpha(ctx, q, () => {\n ctx.save(); drop(ctx, .22, 10, 4); stroke(ctx, a.color ? col(a.color) : C.blue, a.lw || 10);\n ctx.beginPath(); ctx.moveTo(sx, sy); for (let u = .04; u \u003c= d + 1e-6; u += .04) { const [px, py] = bz(Math.min(u, d)); ctx.lineTo(px, py); } ctx.stroke();\n if (d > .7) { const [ex, ey] = bz(d), [bx, by] = bz(d - .06), an = Math.atan2(ey - by, ex - bx), H = 34 * S(d - .7, 18) / S(.3, 18);\n ctx.beginPath(); ctx.moveTo(ex - Math.cos(an - .55) * H, ey - Math.sin(an - .55) * H); ctx.lineTo(ex, ey); ctx.lineTo(ex - Math.cos(an + .55) * H, ey - Math.sin(an + .55) * H); ctx.stroke(); }\n ctx.restore();\n if (a.label) { const qq = S(t - a.at - .25, 13); if (qq > .002) alpha(ctx, qq, () => chip(ctx, sx, sy + (a.dy > 0 ? 50 : -50), a.label, { size: a.labelSize || 40, h: Math.round((a.labelSize || 40) * 2), fill: C.blue, color: C.white, border: false })); }\n });\n }\n },\n glass(ctx, it) { // frosted glass: the frame under it, blurred, then a white tint and a bright hairline\n const w = it.w, h = it.h, r = it.r ?? 34, m = ctx.getTransform(), cv = ctx.canvas;\n const q0 = m.transformPoint(new DOMPoint(0, 0)), q1 = m.transformPoint(new DOMPoint(w, h)), pad = 60 * m.a;\n const sx = Math.max(0, Math.floor(Math.min(q0.x, q1.x) - pad)), sy = Math.max(0, Math.floor(Math.min(q0.y, q1.y) - pad));\n const sw = Math.min(cv.width, Math.ceil(Math.max(q0.x, q1.x) + pad)) - sx, sh = Math.min(cv.height, Math.ceil(Math.max(q0.y, q1.y) + pad)) - sy;\n ctx.save(); const out = new Path2D(); out.rect(-300, -300, w + 600, h + 600); out.roundRect(0, 0, w, h, r); ctx.clip(out, 'evenodd');\n shadow(ctx, 0, 0, w, h, r, 44, .2, 16); ctx.restore();\n if (sw > 0 && sh > 0) { ctx.save(); ctx.beginPath(); ctx.roundRect(0, 0, w, h, r); ctx.clip(); ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.filter = `blur(${Math.round((it.blur ?? 22) * m.a)}px)`; ctx.drawImage(cv, sx, sy, sw, sh, sx, sy, sw, sh); ctx.restore(); }\n ctx.beginPath(); ctx.roundRect(0, 0, w, h, r); ctx.fillStyle = `rgba(250,249,246,${it.tint ?? .62})`; ctx.fill();\n ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(255,255,255,.9)'; ctx.stroke();\n ctx.beginPath(); ctx.roundRect(-1, -1, w + 2, h + 2, r + 1); ctx.lineWidth = 1; ctx.strokeStyle = 'rgba(20,22,27,.08)'; ctx.stroke();\n },\n gauge(ctx, it, t) { // speedometer: a 270° dial, the blue arc and the needle sweep to value (0..1) with a motion trail, centered\n const r = it.r || 170, a0 = Math.PI * .75, span = Math.PI * 1.5, f0 = it.sweepFrom ?? it.at + .1, f1 = it.sweepTo ?? f0 + .8;\n const vAt = tt => (it.value ?? .9) * E.out(seg(tt, f0, f1)), v = vAt(t), R0 = r - 22;\n sheet(ctx, -r - 26, -r - 26, 2 * r + 52, 2 * r + 52, r + 26, { elev: .9 });\n ctx.save(); ctx.lineCap = 'round';\n ctx.beginPath(); ctx.arc(0, 0, R0, a0, a0 + span); ctx.lineWidth = 24; ctx.strokeStyle = 'rgba(40,30,10,.07)'; ctx.stroke();\n if (v > .002) { ctx.beginPath(); ctx.arc(0, 0, R0, a0, a0 + span * v); ctx.strokeStyle = C.blue; ctx.stroke(); }\n for (let i = 0; i \u003c= 20; i++) {\n const a = a0 + span * i / 20, big = i % 5 === 0, r1 = R0 - 30, r2 = R0 - (big ? 62 : 46);\n ctx.beginPath(); ctx.moveTo(Math.cos(a) * r1, Math.sin(a) * r1); ctx.lineTo(Math.cos(a) * r2, Math.sin(a) * r2);\n ctx.lineWidth = big ? 6 : 3; ctx.strokeStyle = i / 20 \u003c= v + .001 ? C.ink : C.ink3; ctx.stroke();\n }\n const needle = (an, op) => alpha(ctx, op, () => { ctx.save(); ctx.rotate(an); ctx.beginPath(); ctx.moveTo(-24, -9); ctx.lineTo(R0 - 58, -2.5); ctx.lineTo(R0 - 58, 2.5); ctx.lineTo(-24, 9); ctx.closePath(); ctx.fillStyle = C.ink; ctx.fill(); ctx.restore(); });\n for (const [lag, op] of [[.12, .1], [.08, .16], [.04, .26]]) { const lv = vAt(t - lag); if (v - lv > .015) needle(a0 + span * lv, op); } // trail while it sweeps fast\n ctx.save(); drop(ctx, .22, 10, 4); needle(a0 + span * v, 1); ctx.restore();\n ctx.beginPath(); ctx.arc(0, 0, 20, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); ctx.beginPath(); ctx.arc(0, 0, 7, 0, TAU); ctx.fillStyle = C.white; ctx.fill();\n ctx.restore();\n if (it.unit) text(ctx, it.unit, 0, r * .62, { size: 32, weight: 800, align: 'center', color: C.ink3 });\n },\n pixelate(ctx, it, t, R) { // a real image that resolves from big pixel blocks to sharp (sharpFrom..sharpTo), then a light sweep, centered\n const im = R?.img('/p/' + it.img); if (!im) return;\n const w = it.w || 300, h = it.h || Math.round(w * im.height / im.width), r = it.r ?? 26, x0 = -w / 2, y0 = -h / 2;\n const f0 = it.sharpFrom ?? it.at + .3, f1 = it.sharpTo ?? f0 + .9, p = seg(t, f0, f1);\n const steps = [it.block || 44, 30, 20, 12, 7, 3], blk = p >= 1 ? 1 : steps[Math.min(steps.length - 1, Math.floor(p * steps.length))];\n lift(ctx, x0, y0, w, h, r, 1);\n ctx.save(); ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.clip();\n if (blk \u003c= 1) { ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, x0, y0, w, h); }\n else {\n const m = Math.hypot(ctx.getTransform().a, ctx.getTransform().b), cw = Math.max(2, Math.round(w * m / blk)), ch = Math.max(2, Math.round(h * m / blk));\n PIX.width = cw; PIX.height = ch; const pc = PIX.getContext('2d'); pc.imageSmoothingQuality = 'high'; pc.drawImage(im, 0, 0, cw, ch);\n ctx.imageSmoothingEnabled = false; ctx.drawImage(PIX, 0, 0, cw, ch, x0, y0, w, h); ctx.imageSmoothingEnabled = true;\n }\n const g = seg(t, f1, f1 + .55); if (g > 0 && g \u003c 1) { // sheen: a soft white band crossing once when it lands\n const bx = lerp(x0 - w * .6, x0 + w * 1.2, E.io(g)), gr = ctx.createLinearGradient(bx, y0, bx + w * .45, y0 + h * .25);\n gr.addColorStop(0, 'rgba(255,255,255,0)'); gr.addColorStop(.5, 'rgba(255,255,255,.42)'); gr.addColorStop(1, 'rgba(255,255,255,0)');\n ctx.fillStyle = gr; ctx.fillRect(x0, y0, w, h);\n }\n ctx.restore();\n ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(20,22,27,.1)'; ctx.stroke();\n },\n render(ctx, it, t, R) { // a render crawling: dark window, film strip of real frames developing one by one, striped bar, frame counter, timer; centered\n const w = it.w || 900, h = it.h || 420, r = 30, x0 = -w / 2, y0 = -h / 2, f0 = it.progFrom ?? it.at, f1 = it.progTo ?? f0 + 4;\n const p = (it.prog ?? 1) * clamp((t - f0) / (f1 - f0)), imgs = it.imgs || [], n = imgs.length, pad = 30;\n lift(ctx, x0, y0, w, h, r, 1.2);\n ctx.save(); drop(ctx, .28, 40, 18); ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.fillStyle = '#1B1B1D'; ctx.fill(); ctx.restore();\n ['#FF5F57', '#FEBC2E', '#28C840'].forEach((c, i) => { ctx.beginPath(); ctx.arc(x0 + 36 + i * 28, y0 + 36, 8, 0, TAU); ctx.fillStyle = c; ctx.fill(); });\n if (it.title) text(ctx, it.title, x0 + 130, y0 + 46, { size: 28, weight: 600, fam: 'JetBrains Mono', color: 'rgba(255,255,255,.55)' });\n ctx.save(); ctx.translate(x0 + w - 44, y0 + 36); ctx.rotate(t * 7); ctx.beginPath(); ctx.arc(0, 0, 12, 0, TAU * .72); stroke(ctx, C.blue, 4); ctx.stroke(); ctx.restore();\n const sy = y0 + 72, sh = h - 72 - 126, gap = 12, fw = n ? (w - pad * 2 - gap * (n - 1)) / n : 0, done = p * n;\n imgs.forEach((src, i) => {\n const im = R?.img('/p/' + src), fx = x0 + pad + i * (fw + gap), q = clamp(done - i);\n ctx.save(); ctx.beginPath(); ctx.roundRect(fx, sy, fw, sh, 12); ctx.fillStyle = '#2A2A2E'; ctx.fill(); ctx.clip();\n if (q \u003c 1) { const sx = fx + ((t * 260 + i * 70) % (fw * 2.2)) - fw * .6, gr = ctx.createLinearGradient(sx, 0, sx + fw * .6, 0); // waiting: a dim shimmer\n gr.addColorStop(0, 'rgba(255,255,255,0)'); gr.addColorStop(.5, 'rgba(255,255,255,.06)'); gr.addColorStop(1, 'rgba(255,255,255,0)'); ctx.fillStyle = gr; ctx.fillRect(fx, sy, fw, sh); }\n if (im && q > 0) { // developing: revealed top to bottom behind a blue scan line\n ctx.save(); ctx.beginPath(); ctx.rect(fx, sy, fw, sh * q); ctx.clip(); ctx.imageSmoothingQuality = 'high';\n const k = Math.max(fw / im.width, sh / im.height), iw = im.width * k, ih = im.height * k; ctx.drawImage(im, fx + (fw - iw) / 2, sy + (sh - ih) / 2, iw, ih); ctx.restore();\n if (q \u003c 1) { ctx.fillStyle = C.blue; ctx.fillRect(fx, sy + sh * q - 2, fw, 4); }\n }\n ctx.restore();\n });\n const by = y0 + h - 100, bw = w - pad * 2, bh = 22;\n ctx.beginPath(); ctx.roundRect(x0 + pad, by, bw, bh, bh / 2); ctx.fillStyle = 'rgba(255,255,255,.08)'; ctx.fill();\n if (p > .002) {\n const fwd = Math.max(bh, bw * p); ctx.save(); ctx.beginPath(); ctx.roundRect(x0 + pad, by, fwd, bh, bh / 2); ctx.fillStyle = C.blue; ctx.fill(); ctx.clip();\n ctx.fillStyle = 'rgba(255,255,255,.18)'; const o = (t * 60) % 36; for (let sx = x0 + pad - 40 + o; sx \u003c x0 + pad + fwd; sx += 36) { ctx.beginPath(); ctx.moveTo(sx, by + bh); ctx.lineTo(sx + 14, by + bh); ctx.lineTo(sx + 28, by); ctx.lineTo(sx + 14, by); ctx.closePath(); ctx.fill(); }\n ctx.restore();\n }\n const tot = it.total || 0, el = (it.elapsed || 0) * p / (it.prog ?? 1), mm = Math.floor(el / 60), ss = String(Math.floor(el % 60)).padStart(2, '0');\n if (tot) text(ctx, `Frame ${Math.round(p * tot)} / ${tot}`, x0 + pad, by + 66, { size: 30, weight: 600, fam: 'JetBrains Mono', color: 'rgba(255,255,255,.7)' });\n if (it.elapsed) text(ctx, `${mm}:${ss}`, x0 + w - pad, by + 68, { size: 40, weight: 800, fam: 'JetBrains Mono', align: 'right', color: C.white });\n },\n term(ctx, it, t, R) { // a Claude Code terminal, centered: header (logo, name, sub), a prompt box where `cmd` types (typeFrom..typeTo)\n // with the matching slash commands listed under it (suggest: [[name, desc]], first one highlighted), Enter at sendAt\n // moves the command into the history, then out: [{text, at}] lines land one by one with a dot\n const w = it.w || 960, h = it.h || 640, r = 30, x0 = -w / 2, y0 = -h / 2, pad = 44, M = 'JetBrains Mono', fs = it.size || 52;\n lift(ctx, x0, y0, w, h, r, 1.2);\n ctx.save(); drop(ctx, .28, 40, 18); ctx.beginPath(); ctx.roundRect(x0, y0, w, h, r); ctx.fillStyle = '#1B1B1D'; ctx.fill(); ctx.restore();\n ['#FF5F57', '#FEBC2E', '#28C840'].forEach((c, i) => { ctx.beginPath(); ctx.arc(x0 + 36 + i * 28, y0 + 36, 8, 0, TAU); ctx.fillStyle = c; ctx.fill(); });\n if (it.title) text(ctx, it.title, x0 + w / 2, y0 + 48, { size: 34, weight: 600, fam: M, align: 'center', color: 'rgba(255,255,255,.5)' });\n const hy = y0 + 160;\n if (it.logo) logoAt(R, ctx, it.logo, x0 + pad + 50, hy, 100);\n text(ctx, it.name || 'Claude Code', x0 + pad + 130, hy - 4, { size: 60, weight: 800, color: C.white });\n if (it.sub) text(ctx, it.sub, x0 + pad + 130, hy + 48, { size: 36, weight: 500, fam: M, color: 'rgba(255,255,255,.5)' });\n const cmd = it.cmd || '', tf = it.typeFrom ?? it.at, tt = it.typeTo ?? tf + cmd.length * .05, sent = it.sendAt != null && t >= it.sendAt;\n const n = Math.round(cmd.length * clamp((t - tf) / Math.max(.05, tt - tf))), typed = cmd.slice(0, n);\n const line = (s, y, c, cur) => { // \"> \" + mono text, blue block cursor while it types\n text(ctx, '>', x0 + pad + 26, midBase(y, fs), { size: fs, weight: 700, fam: M, color: 'rgba(255,255,255,.45)' });\n const cx = x0 + pad + 80; text(ctx, s, cx, midBase(y, fs), { size: fs, weight: 600, fam: M, color: c });\n if (cur && Math.sin(t * 11) > -.3) { ctx.fillStyle = C.blue; ctx.fillRect(cx + measure(ctx, s, fs, 600, 0, M) + 4, y - fs * .55, fs * .55, fs * 1.1); }\n };\n let by = hy + 150;\n if (sent) { // history: the sent command, then the output lines\n const q = E.out(seg(t, it.sendAt, it.sendAt + .3));\n line(cmd, by + (1 - q) * 60, C.white, false);\n (it.out || []).forEach((o, i) => {\n const p = S(t - o.at, 13); if (p \u003c= .002) return; const oy = by + 100 + i * 84;\n alpha(ctx, Math.min(1, p * 1.4), () => {\n ctx.beginPath(); ctx.arc(x0 + pad + 36, oy + (1 - p) * 20, 11, 0, TAU); ctx.fillStyle = o.dim ? 'rgba(255,255,255,.4)' : C.blue; ctx.fill();\n text(ctx, o.text, x0 + pad + 80, midBase(oy + (1 - p) * 20, 42), { size: 42, weight: 500, fam: M, color: o.dim ? 'rgba(255,255,255,.55)' : C.white });\n });\n });\n return;\n }\n const bh = fs * 2.1;\n ctx.beginPath(); ctx.roundRect(x0 + pad, by - bh / 2, w - pad * 2, bh, 18); ctx.lineWidth = 3; ctx.strokeStyle = 'rgba(255,255,255,.22)'; ctx.stroke();\n if (!typed && it.placeholder) text(ctx, it.placeholder, x0 + pad + 80, midBase(by, fs), { size: fs * .8, weight: 500, fam: M, color: 'rgba(255,255,255,.3)' });\n line(typed, by, C.white, t >= (it.focusAt ?? tf - .3));\n const sg = it.suggest || []; if (!sg.length || n \u003c 1) return;\n const shown = sg.filter(([nm]) => nm.startsWith(typed) || typed.startsWith(nm.slice(0, typed.length)));\n shown.slice(0, 4).forEach(([nm, desc], i) => {\n const sy = by + bh / 2 + 60 + i * 74, hi = i === 0;\n if (hi) { ctx.beginPath(); ctx.roundRect(x0 + pad, sy - 34, w - pad * 2, 68, 14); ctx.fillStyle = 'rgba(37,142,255,.16)'; ctx.fill(); }\n text(ctx, nm, x0 + pad + 26, midBase(sy, 40), { size: 40, weight: 700, fam: M, color: hi ? C.blue : 'rgba(255,255,255,.6)' });\n if (desc) text(ctx, desc, x0 + w - pad - 22, midBase(sy, 36), { size: 36, weight: 500, align: 'right', color: hi ? 'rgba(255,255,255,.8)' : 'rgba(255,255,255,.4)' });\n });\n },\n edit(ctx, it, t, R) { // a timeline that edits itself, centered: real clips with their silences (boxed at markAt, closed at cutAt),\n // \"Aa\" caption pills dropping on each clip (capsAt), blue animation blocks with a sparkle (fxAt), a playhead (playFrom..playTo),\n // then steps [{text, at}] as blue chips that check one by one under the sheet\n const imgs = it.imgs || [], n = Math.max(1, imgs.length), w = it.w || 960, hw = 92, pad = 26, vh = it.vh || 150, th = 62, gap = 14;\n const h = pad * 2 + vh + (th + gap) * 2, x0 = -w / 2, y0 = -h / 2, lx = x0 + hw, lw = w - hw - pad;\n const cu = i => [1.25, .95, 1.15, .9, 1.05][i % 5], gu = i => i \u003c n - 1 ? [.42, .3, .5, .36][i % 4] : 0;\n const mk = it.markAt != null ? S(t - it.markAt, 12) : 0, cl = it.cutAt != null ? E.io(seg(t, it.cutAt, it.cutAt + .45)) : 0;\n let U = 0; for (let i = 0; i \u003c n; i++) U += cu(i) + gu(i);\n const unit = lw / U, xs = []; let acc = lx;\n for (let i = 0; i \u003c n; i++) { xs.push(acc); acc += (cu(i) + gu(i) * (1 - cl)) * unit; }\n const span = acc - lx, ph = it.playFrom != null ? E.io(seg(t, it.playFrom, it.playTo ?? it.playFrom + 1.2)) : -1, phx = lx + span * ph;\n const vy = y0 + pad, ty = vy + vh + gap, fy = ty + th + gap;\n const spark = (x, y, r, c) => { ctx.beginPath(); for (let k = 0; k \u003c 8; k++) { const a = k * TAU / 8 - TAU / 4, rr = k % 2 ? r * .32 : r; ctx[k ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rr, y + Math.sin(a) * rr); } ctx.closePath(); ctx.fillStyle = c; ctx.fill(); };\n sheet(ctx, x0, y0, w, h, 34, { elev: 1.2 });\n [ty - gap / 2, fy - gap / 2].forEach(yy => { ctx.fillStyle = C.line; ctx.fillRect(x0 + 24, yy, w - 48, 1.5); });\n const hx = x0 + hw / 2; // track heads: play, Aa, sparkle\n ctx.beginPath(); ctx.moveTo(hx - 13, vy + vh / 2 - 17); ctx.lineTo(hx + 17, vy + vh / 2); ctx.lineTo(hx - 13, vy + vh / 2 + 17); ctx.closePath(); ctx.fillStyle = C.ink; ctx.fill();\n text(ctx, 'Aa', hx, midBase(ty + th / 2, 32), { size: 32, weight: 800, align: 'center', color: C.ink });\n spark(hx, fy + th / 2, 19, C.ink);\n for (let i = 0; i \u003c n; i++) {\n const cx = xs[i], cw = cu(i) * unit - 6, im = R?.img('/p/' + imgs[i]), on = ph > 0 && ph \u003c 1 && phx >= cx && phx \u003c= cx + cw;\n ctx.save(); ctx.beginPath(); ctx.roundRect(cx, vy, cw, vh, 12); ctx.fillStyle = C.dim; ctx.fill(); ctx.clip();\n if (im) { const k = Math.max(cw / im.width, vh / im.height); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, cx + (cw - im.width * k) / 2, vy + (vh - im.height * k) / 2, im.width * k, im.height * k); }\n ctx.restore();\n ctx.beginPath(); ctx.roundRect(cx, vy, cw, vh, 12); ctx.strokeStyle = on ? C.blue : 'rgba(20,22,27,.12)'; ctx.lineWidth = on ? 5 : 2; ctx.stroke();\n const gw = gu(i) * (1 - cl) * unit, gx = cx + cu(i) * unit - 6; // the silence after this clip: flat dashes, boxed, then closed\n if (gw > 1) {\n alpha(ctx, 1 - cl, () => { for (let q = gx + 10; q \u003c gx + gw - 8; q += 16) { ctx.beginPath(); ctx.roundRect(q, vy + vh / 2 - 4, 9, 8, 4); ctx.fillStyle = C.ink3; ctx.fill(); } });\n alpha(ctx, mk * (1 - cl), () => {\n ctx.beginPath(); ctx.roundRect(gx + 4, vy + 10, Math.max(2, gw - 2), vh - 20, 12); ctx.fillStyle = 'rgba(37,142,255,.12)'; ctx.fill();\n ctx.setLineDash([10, 8]); ctx.strokeStyle = C.blue; ctx.lineWidth = 3; ctx.stroke(); ctx.setLineDash([]);\n });\n }\n if (it.capsAt != null) { const p = S(t - it.capsAt - i * .07, 13); if (p > .002) alpha(ctx, Math.min(1, p * 1.4), () => {\n const yy = ty + 6 - 26 * (1 - p), pw = Math.max(40, cw - 8);\n ctx.beginPath(); ctx.roundRect(cx + 4, yy, pw, th - 12, 12); ctx.fillStyle = on ? C.blue : C.tint; ctx.fill(); ctx.strokeStyle = C.blue; ctx.lineWidth = 2; ctx.stroke();\n text(ctx, 'Aa', cx + 18, midBase(yy + (th - 12) / 2, 26), { size: 26, weight: 800, color: on ? C.white : C.blue });\n if (pw > 110) { ctx.beginPath(); ctx.roundRect(cx + 66, yy + (th - 12) / 2 - 5, Math.min(pw - 84, 90), 10, 5); ctx.fillStyle = on ? 'rgba(255,255,255,.8)' : 'rgba(37,142,255,.45)'; ctx.fill(); }\n }); }\n if (it.fxAt != null) { const p = S(t - it.fxAt - i * .07, 13); if (p > .002) alpha(ctx, Math.min(1, p * 1.4), () => {\n const bw = cw * (.45 + .3 * hash(i * 5.1 + 2)), bx = cx + 4 + (cw - 8 - bw) * hash(i * 2.7 + 9), yy = fy + 6, bh = th - 12, k = .6 + .4 * p;\n ctx.save(); ctx.translate(bx + bw / 2, yy + bh / 2); ctx.scale(k, k);\n ctx.beginPath(); ctx.roundRect(-bw / 2, -bh / 2, bw, bh, 12); ctx.fillStyle = C.blue; ctx.fill();\n spark(-bw / 2 + 24, 0, 14, C.white); ctx.restore();\n }); }\n }\n if (ph >= 0 && t > it.playFrom - .2) alpha(ctx, clamp((t - it.playFrom + .2) / .2), () => {\n ctx.fillStyle = C.ink; ctx.fillRect(phx - 2.5, y0 + 8, 5, h - 16);\n ctx.beginPath(); ctx.moveTo(phx - 15, y0 - 6); ctx.lineTo(phx + 15, y0 - 6); ctx.lineTo(phx, y0 + 14); ctx.closePath(); ctx.fill();\n });\n const st = it.steps || []; if (st.length) {\n const sz = 34, ws = st.map(s => measure(ctx, s.text, sz, 700, -.3) + Math.round(sz * .72) * 2 + sz * .95), sg = 16;\n let sx = -(ws.reduce((a, b) => a + b, 0) + sg * (st.length - 1)) / 2;\n st.forEach((s, i) => { const p = S(t - s.at, 12);\n if (p > .002) { ctx.save(); ctx.translate(0, 22 * (1 - p)); chip(ctx, sx, y0 + h + 58, s.text, { size: sz, align: 'left', fill: C.blue, color: C.onBlue, check: true, checkP: E.out(seg(t, s.at + .08, s.at + .4)), alpha: Math.min(1, p * 1.4) }); ctx.restore(); }\n sx += ws[i] + sg; });\n }\n },\n yt(ctx, it, t, R) { // YouTube video card: thumbnail (duration chip, red play), avatar, two-line title, channel + meta\n const w = it.w || 760, pad = 24, tw = w - pad * 2, th = Math.round(tw * 9 / 16), ts = it.titleSize || 36;\n const lines = wrap(ctx, it.title || '', tw - 96, ts, 700, -.4).slice(0, 2), h = pad + th + 26 + lines.length * ts * 1.25 + 48 + pad;\n const x0 = -w / 2, y0 = -h / 2, im = it.img ? R?.img('/p/' + it.img) : null, av = it.avatarImg ? R?.img('/p/' + it.avatarImg) : null;\n sheet(ctx, x0, y0, w, h, 30, { elev: 1.1 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x0 + pad, y0 + pad, tw, th, 18); ctx.clip();\n if (im) { const k = Math.max(tw / im.width, th / im.height); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, x0 + pad + (tw - im.width * k) / 2, y0 + pad + (th - im.height * k) / 2, im.width * k, im.height * k); }\n else { ctx.fillStyle = C.ink; ctx.fillRect(x0 + pad, y0 + pad, tw, th); }\n ctx.restore();\n const px = 0, py = y0 + pad + th / 2;\n ctx.save(); drop(ctx, .3, 18, 6); ctx.beginPath(); ctx.roundRect(px - 52, py - 36, 104, 72, 20); ctx.fillStyle = '#FF0033'; ctx.fill(); ctx.restore();\n ctx.beginPath(); ctx.moveTo(px - 13, py - 18); ctx.lineTo(px + 20, py); ctx.lineTo(px - 13, py + 18); ctx.closePath(); ctx.fillStyle = '#FFFFFF'; ctx.fill();\n if (it.dur) { const dw = measure(ctx, it.dur, 26, 700) + 22; ctx.beginPath(); ctx.roundRect(x0 + pad + tw - dw - 12, y0 + pad + th - 50, dw, 38, 8); ctx.fillStyle = 'rgba(0,0,0,.8)'; ctx.fill();\n text(ctx, it.dur, x0 + pad + tw - dw / 2 - 12, y0 + pad + th - 22, { size: 26, weight: 700, align: 'center', color: '#FFFFFF' }); }\n const ay = y0 + pad + th + 26, ax = x0 + pad + 34;\n ctx.save(); ctx.beginPath(); ctx.arc(ax, ay + 34, 34, 0, TAU); ctx.clip(); if (av) ctx.drawImage(av, ax - 34, ay, 68, 68); else { ctx.fillStyle = C.ink2; ctx.fill(); } ctx.restore();\n lines.forEach((ln, i) => text(ctx, ln, x0 + pad + 96, ay + ts * .9 + i * ts * 1.25, { size: ts, weight: 700, ls: -.4 }));\n text(ctx, [it.channel, it.meta].filter(Boolean).join(' · '), x0 + pad + 96, ay + lines.length * ts * 1.25 + 34, { size: 28, weight: 500, color: C.ink3 });\n },\n toggle(ctx, it, t) { // settings switch: label, then a pill whose knob slides right and turns blue at onAt\n const size = it.size || 40, lw = measure(ctx, it.label || '', size, 700, -.4), sw = size * 2.6, sh = size * 1.5, gap = size * .8, x0 = -(lw + gap + sw) / 2;\n const on = it.onAt != null ? S(t - it.onAt, 12) : 0;\n text(ctx, it.label || '', x0, midBase(0, size), { size, weight: 700, ls: -.4 });\n const bx = x0 + lw + gap; ctx.beginPath(); ctx.roundRect(bx, -sh / 2, sw, sh, sh / 2); ctx.fillStyle = mix('#D9D5CC', C.blue, on); ctx.fill();\n ctx.save(); drop(ctx, .25, 8, 3); ctx.beginPath(); ctx.arc(bx + sh / 2 + (sw - sh) * on, 0, sh / 2 - 6, 0, TAU); ctx.fillStyle = '#FFFFFF'; ctx.fill(); ctx.restore();\n },\n shatter(ctx, it, t, R) { // a real logo that breaks into glass shards at breakAt (hit [x, y] = impact point, -1..1 of the icon), with a blue shockwave\n const sz = it.s || 260, im = R?.img('/p/' + it.logo), tau = t - (it.breakAt ?? 1e9);\n if (tau \u003c 0) return logoAt(R, ctx, it.logo, 0, 0, sz, it.gray);\n if (!im) return;\n const sh = it._shards || (it._shards = shards(sz, it.hit || [-.35, -.1], it.n || 10, it.seed || 7)), h = sz / 2, rr = sz * .22;\n const icon = new Path2D(); icon.roundRect(-h, -h, sz, sz, rr);\n for (const q of sh) {\n const a = 1 - seg(tau, .42, .95); if (a \u003c= .002) break;\n const ox = q.vx * tau, oy = q.vy * tau + 1300 * tau * tau, k = 1 - .25 * seg(tau, .2, .95);\n ctx.save(); ctx.globalAlpha *= a; ctx.translate(q.cx + ox, q.cy + oy); ctx.rotate(q.w * tau); ctx.scale(k, k); ctx.translate(-q.cx, -q.cy);\n ctx.save(); if (it.tight) ctx.clip(icon); drop(ctx, .22, 16, 8); ctx.fillStyle = C.white; ctx.fill(q.path); ctx.restore();\n ctx.clip(q.path); ctx.clip(icon); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, -h, -h, sz, sz);\n ctx.lineWidth = 2.5; ctx.strokeStyle = 'rgba(255,255,255,.85)'; ctx.stroke(q.path); ctx.restore();\n }\n const [hx, hy] = (it.hit || [-.35, -.1]).map(v => v * h), w = E.out(seg(tau, 0, .5)); // shockwave + spark lines from the impact\n if (w \u003c 1) alpha(ctx, 1 - w, () => {\n ctx.beginPath(); ctx.arc(hx, hy, lerp(sz * .2, sz * 1.25, w), 0, TAU); stroke(ctx, C.blue, lerp(18, 3, w)); ctx.stroke();\n for (let i = 0; i \u003c 12; i++) { const an = i / 12 * TAU + hash(i + 3) * .4, r0 = lerp(sz * .3, sz * 1.1, w), r1 = r0 + lerp(70, 10, w);\n ctx.beginPath(); ctx.moveTo(hx + Math.cos(an) * r0, hy + Math.sin(an) * r0); ctx.lineTo(hx + Math.cos(an) * r1, hy + Math.sin(an) * r1); stroke(ctx, i % 3 ? C.blue : C.ink, 7); ctx.stroke(); }\n });\n },\n icon(ctx, it, t) { const f = ICONS[it.name]; if (!f) return; const s = it.s || 1; ctx.scale(s, s); if (deepness() > 0) drop(ctx, .16, 18, 8); f(ctx, it, t); },\n starship(ctx, it, t) { drawStarship(ctx, it, t); },\n ferrari(ctx, it, t) { drawFerrari(ctx, it, t); },\n ostrich(ctx, it, t) { drawOstrich(ctx, it, t); },\n};\n\n// ---------------------------------------------------------------- illustrated actors (drawn, no image assets)\n// starship {s 1, igniteAt (default at + .3), liftFrom, liftTo, liftY 160}: a stainless Starship (heat tiles, flaps, three\n// bells) on a paper launch pad; the blue-white flame flickers from igniteAt, smoke puffs spread on the pad, and between\n// liftFrom and liftTo the ship climbs liftY px while the smoke stays on the ground. (0, 0) = ship centre at rest.\nfunction drawStarship(ctx, it, t) {\n const s = it.s || 1, ign = it.igniteAt ?? it.at + .3, r = 48, top = -170, bot = 236, tip = -306;\n ctx.scale(s, s);\n const lift = it.liftFrom != null ? (u => u * u * u)(seg(t, it.liftFrom, it.liftTo ?? it.liftFrom + 1.4)) * (it.liftY ?? 160) / s : 0;\n const fire = S(t - ign, 9), pad = bot + 54;\n // smoke on the pad (stays put while the ship climbs)\n if (fire > .002) for (let i = 0; i \u003c 18; i++) { // a billowing cloud that rolls out both ways along the pad\n const side = i % 2 ? 1 : -1, u = Math.floor(i / 2) / 8, age = Math.max(0, t - ign - u * .35);\n const grow = 1 - Math.exp(-age * 2.2), reach = (1 - Math.exp(-age * 1.1)) * (60 + 230 * u) * (1 + .25 * hash(i + 3));\n const rad = (30 + 46 * (1 - u * .55) * (.7 + .6 * hash(i + 11))) * (.25 + .75 * grow) * (1 + .5 * seg(lift, 0, 120));\n const x = side * (22 + reach) + 6 * noise1(t * .9 + i), y = pad - rad * .5 - 30 * u * grow - 8 * hash(i + 5);\n ctx.save(); drop(ctx, .1, 22, 8); ctx.beginPath(); ctx.arc(x, y, rad, 0, TAU);\n const g = ctx.createRadialGradient(x - rad * .35, y - rad * .45, rad * .1, x, y, rad);\n g.addColorStop(0, '#FFFFFF'); g.addColorStop(.7, '#F1EEE8'); g.addColorStop(1, '#DDD8CD'); ctx.fillStyle = g; ctx.globalAlpha *= Math.min(1, fire * 1.2) * (1 - .35 * u); ctx.fill(); ctx.restore();\n }\n ctx.save(); ctx.translate(0, -lift);\n // flame\n if (fire > .002) {\n const fl = (130 + 46 * noise1(t * 13) + lift * .35) * fire, w = 34 + 5 * noise1(t * 17 + 4);\n const g = ctx.createLinearGradient(0, bot, 0, bot + fl);\n g.addColorStop(0, '#FFFFFF'); g.addColorStop(.22, '#CFE4FF'); g.addColorStop(.55, 'rgba(37,142,255,.85)'); g.addColorStop(1, 'rgba(37,142,255,0)');\n ctx.beginPath(); ctx.moveTo(-w, bot + 8); ctx.bezierCurveTo(-w * 1.15, bot + fl * .35, -w * .35, bot + fl * .8, 0, bot + fl);\n ctx.bezierCurveTo(w * .35, bot + fl * .8, w * 1.15, bot + fl * .35, w, bot + 8); ctx.closePath(); ctx.fillStyle = g; ctx.fill();\n ctx.beginPath(); ctx.ellipse(0, bot + 18 + fl * .16, w * .42, fl * .2, 0, 0, TAU); ctx.fillStyle = 'rgba(255,255,255,.9)'; ctx.fill();\n }\n // bells\n for (const bx of [-24, 0, 24]) { ctx.beginPath(); ctx.moveTo(bx - 8, bot - 2); ctx.lineTo(bx + 8, bot - 2); ctx.lineTo(bx + 12, bot + 16); ctx.lineTo(bx - 12, bot + 16); ctx.closePath(); ctx.fillStyle = '#3A3E46'; ctx.fill(); }\n // flaps (behind the hull edge)\n const flap = (pts) => { ctx.beginPath(); pts.forEach(([x, y], i) => i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)); ctx.closePath(); ctx.fillStyle = '#2C2F36'; ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,.18)'; ctx.lineWidth = 2; ctx.stroke(); };\n for (const sg of [-1, 1]) {\n flap([[sg * (r - 4), -226], [sg * (r + 26), -204], [sg * (r + 28), -164], [sg * (r - 4), -150]]);\n flap([[sg * (r - 4), 104], [sg * (r + 50), 160], [sg * (r + 52), bot - 4], [sg * (r - 4), bot - 4]]);\n }\n // hull\n const hull = new Path2D(); hull.moveTo(-r, bot); hull.lineTo(-r, top); hull.bezierCurveTo(-r, top - 78, -16, tip + 22, 0, tip);\n hull.bezierCurveTo(16, tip + 22, r, top - 78, r, top); hull.lineTo(r, bot); hull.closePath();\n ctx.save(); drop(ctx, .22, 34, 16); ctx.fillStyle = '#C9CED6'; ctx.fill(hull); ctx.restore();\n ctx.save(); ctx.clip(hull);\n const steel = ctx.createLinearGradient(-r, 0, r, 0);\n steel.addColorStop(0, '#8E949E'); steel.addColorStop(.2, '#DDE1E7'); steel.addColorStop(.42, '#FFFFFF'); steel.addColorStop(.68, '#CBD0D8'); steel.addColorStop(1, '#858B95');\n ctx.fillStyle = steel; ctx.fillRect(-r, tip, 2 * r, bot - tip);\n const tiles = ctx.createLinearGradient(-r, 0, -r * .1, 0); tiles.addColorStop(0, '#1E2026'); tiles.addColorStop(1, '#3B3F48'); // heat shield\n ctx.fillStyle = tiles; ctx.fillRect(-r, tip, r * .9, bot - tip);\n ctx.strokeStyle = 'rgba(255,255,255,.07)'; ctx.lineWidth = 1;\n for (let y = tip + 30; y \u003c bot; y += 14) { ctx.beginPath(); ctx.moveTo(-r, y); ctx.lineTo(-r * .1, y); ctx.stroke(); }\n ctx.strokeStyle = 'rgba(30,34,42,.12)'; ctx.lineWidth = 1.5; // weld rings\n for (let y = top + 20; y \u003c bot; y += 42) { ctx.beginPath(); ctx.moveTo(-r, y); ctx.lineTo(r, y); ctx.stroke(); }\n ctx.fillStyle = 'rgba(255,255,255,.75)'; ctx.fillRect(r * .12, tip + 40, 3, bot - tip - 50); // specular line\n ctx.restore();\n ctx.strokeStyle = 'rgba(20,22,27,.28)'; ctx.lineWidth = 2; ctx.stroke(hull);\n ctx.restore();\n}\n\n// ostrich {s 1, downFrom, downTo, upAt, mound 300, dir 1}: an ostrich on a sand strip; between downFrom and downTo it\n// bends its neck and buries its head in the mound (sand burst), at upAt it pulls out with a burst and three blue\n// \"wake up\" ticks. (0, 0) = the ground under its body; it faces +x (dir -1 mirrors).\n// A red Ferrari in side view, facing right, wheels on y = 0. It rolls in from the left on `at` (spinning wheels and speed\n// lines while it brakes to a stop). {s, roll: px travelled on entry (default 260), rollDur (default .9)}.\nfunction drawFerrari(ctx, it, t) {\n const s = it.s || 1; ctx.scale(s, s);\n const k = E.out(seg(t, it.at, it.at + (it.rollDur ?? .9))), roll = it.roll ?? 260, x0 = -roll * (1 - k), v = 1 - k;\n ctx.translate(x0, 0);\n // ground shadow\n ctx.save(); ctx.beginPath(); ctx.ellipse(0, 4, 300, 16, 0, 0, TAU); ctx.fillStyle = 'rgba(20,16,8,.16)'; ctx.filter = 'blur(6px)'; ctx.fill(); ctx.restore();\n // speed lines while it is still moving\n if (v > .02) { ctx.save(); ctx.lineCap = 'round'; ctx.strokeStyle = `rgba(23,25,30,${(.35 * v).toFixed(3)})`; ctx.lineWidth = 6;\n for (const [y, l] of [[-120, 120], [-80, 170], [-40, 110]]) { ctx.beginPath(); ctx.moveTo(-300 - 30, y); ctx.lineTo(-300 - 30 - l * v, y); ctx.stroke(); }\n ctx.restore(); }\n const body = new Path2D();\n body.moveTo(-268, -34); body.lineTo(-272, -78); body.quadraticCurveTo(-268, -100, -238, -104);\n body.lineTo(-150, -108); body.quadraticCurveTo(-110, -112, -78, -140); body.quadraticCurveTo(-48, -164, 10, -164);\n body.quadraticCurveTo(60, -164, 96, -128); body.quadraticCurveTo(116, -110, 160, -102);\n body.quadraticCurveTo(250, -90, 278, -66); body.quadraticCurveTo(290, -54, 284, -32); body.lineTo(226, -30);\n body.arc(166, -32, 58, 0, Math.PI, true); body.lineTo(-108, -30); body.arc(-168, -32, 58, 0, Math.PI, true); body.closePath();\n ctx.save(); drop(ctx, .22, 24, 10);\n const g = ctx.createLinearGradient(0, -164, 0, -30); g.addColorStop(0, '#F0383E'); g.addColorStop(.55, '#D41820'); g.addColorStop(1, '#8E0C12');\n ctx.fillStyle = g; ctx.fill(body); ctx.restore();\n ctx.save(); ctx.clip(body);\n // shoulder highlight and lower sill shade\n ctx.beginPath(); ctx.moveTo(-250, -96); ctx.quadraticCurveTo(0, -112, 270, -70); ctx.lineWidth = 7; ctx.strokeStyle = 'rgba(255,255,255,.35)'; ctx.stroke();\n ctx.fillStyle = 'rgba(40,0,4,.28)'; ctx.fillRect(-300, -46, 600, 20);\n // side intake\n ctx.beginPath(); ctx.moveTo(-60, -66); ctx.quadraticCurveTo(-20, -88, 30, -84); ctx.quadraticCurveTo(0, -64, -60, -66); ctx.fillStyle = '#1A1215'; ctx.fill();\n ctx.restore();\n // glasshouse\n ctx.beginPath(); ctx.moveTo(-96, -112); ctx.quadraticCurveTo(-60, -152, 8, -154); ctx.quadraticCurveTo(54, -154, 84, -122); ctx.quadraticCurveTo(60, -116, -96, -112); ctx.closePath();\n const gl = ctx.createLinearGradient(-90, -154, 80, -112); gl.addColorStop(0, '#2B3038'); gl.addColorStop(1, '#0F1115'); ctx.fillStyle = gl; ctx.fill();\n ctx.beginPath(); ctx.moveTo(-40, -146); ctx.lineTo(-8, -150); ctx.lineTo(-44, -116); ctx.lineTo(-72, -116); ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,.16)'; ctx.fill();\n ctx.fillStyle = '#C8121A'; ctx.fillRect(-14, -152, 9, 40); // B pillar\n // mirror, lights\n ctx.beginPath(); ctx.ellipse(96, -126, 16, 8, -.3, 0, TAU); ctx.fillStyle = '#B5121A'; ctx.fill();\n ctx.beginPath(); ctx.moveTo(240, -84); ctx.quadraticCurveTo(268, -76, 276, -64); ctx.lineTo(250, -70); ctx.closePath(); ctx.fillStyle = '#F4F1EA'; ctx.fill();\n ctx.beginPath(); ctx.roundRect(-272, -92, 18, 12, 4); ctx.fillStyle = '#5A0408'; ctx.fill();\n // wheels: tyre, five-spoke rim, yellow caliper\n const spin = t * 2 + (it.roll ?? 260) * (1 - k) / 40;\n for (const wx of [-168, 166]) {\n ctx.save(); ctx.translate(wx, -32);\n ctx.beginPath(); ctx.arc(0, 0, 46, 0, TAU); ctx.fillStyle = '#16181C'; ctx.fill();\n ctx.beginPath(); ctx.arc(0, 0, 31, 0, TAU); const rg = ctx.createRadialGradient(-8, -10, 4, 0, 0, 31); rg.addColorStop(0, '#E9ECEF'); rg.addColorStop(1, '#8A9098'); ctx.fillStyle = rg; ctx.fill();\n ctx.beginPath(); ctx.roundRect(8, -22, 12, 26, 4); ctx.fillStyle = '#F2C200'; ctx.fill();\n ctx.rotate(spin); ctx.fillStyle = '#2A2E35';\n for (let i = 0; i \u003c 5; i++) { ctx.rotate(TAU / 5); ctx.beginPath(); ctx.moveTo(-6, -6); ctx.lineTo(-4, -29); ctx.lineTo(4, -29); ctx.lineTo(6, -6); ctx.closePath(); ctx.globalAlpha *= .9; ctx.fill(); ctx.globalAlpha /= .9; }\n ctx.beginPath(); ctx.arc(0, 0, 7, 0, TAU); ctx.fillStyle = '#C9CDD2'; ctx.fill();\n ctx.restore();\n }\n}\n\nconst OS = { skin: '#D6B8A8', skin2: '#B9998A', beak: '#E6C9A2', sand: '#E3DBCB', sand2: '#CDBFA6', ink: '#17191E' };\nfunction drawOstrich(ctx, it, t) {\n const s = it.s || 1, hx = it.mound ?? 300; ctx.scale(s * (it.dir || 1), s);\n const down = it.downFrom != null ? E.io(seg(t, it.downFrom, it.downTo ?? it.downFrom + .8)) : 0;\n const up = it.upAt != null ? S(t - it.upAt, 10) : 0, P = clamp(down * (1 - up));\n const buried = it.downTo != null && t > it.downTo && (it.upAt == null || t \u003c it.upAt);\n const wig = buried ? Math.sin((t - it.downTo) * 9) * 4 : 0;\n // legs\n const leg = (hip, tw) => {\n const knee = [hip[0] + 34 + 10 * P, -150 + 12 * P], foot = [hip[0] + 6, -6];\n ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.strokeStyle = OS.skin2;\n ctx.lineWidth = 24; ctx.beginPath(); ctx.moveTo(...hip); ctx.lineTo(...knee); ctx.stroke();\n ctx.lineWidth = 15; ctx.beginPath(); ctx.moveTo(...knee); ctx.lineTo(...foot); ctx.stroke();\n ctx.lineWidth = 12; ctx.beginPath(); ctx.moveTo(foot[0] - 8, -4); ctx.lineTo(foot[0] + 46, -2); ctx.stroke();\n ctx.lineWidth = 8; ctx.beginPath(); ctx.moveTo(foot[0], -4); ctx.lineTo(foot[0] + 28 + tw, -12); ctx.stroke();\n };\n ctx.save(); ctx.translate(wig, 0);\n leg([-96, -262], 0); ctx.globalAlpha *= .92; leg([-18, -262], 6); ctx.globalAlpha /= .92;\n // body (pitches forward as the head goes down)\n const bx = -50, by = -340, rot = lerp(-.1, .3, P);\n ctx.save(); ctx.translate(bx, by); ctx.rotate(rot);\n // tail plume\n const sway = Math.sin(t * 3.1) * .05 + (buried ? Math.sin((t - it.downTo) * 7) * .12 : 0);\n for (let i = 0; i \u003c 6; i++) {\n const a = -2.55 + i * .2 + sway, L = 120 + 18 * Math.sin(i * 1.7);\n ctx.save(); ctx.translate(-150, -20); ctx.rotate(a);\n ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(L * .5, -26, L, -4); ctx.quadraticCurveTo(L * .5, 16, 0, 0);\n ctx.fillStyle = i % 2 ? '#FFFFFF' : '#F1EEE8'; ctx.fill(); ctx.strokeStyle = 'rgba(20,22,27,.14)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n }\n ctx.save(); drop(ctx, .24, 36, 18);\n ctx.beginPath(); ctx.ellipse(0, 0, 170, 112, 0, 0, TAU);\n const g = ctx.createRadialGradient(-30, -60, 20, 0, 0, 190); g.addColorStop(0, '#3A3E46'); g.addColorStop(1, OS.ink);\n ctx.fillStyle = g; ctx.fill(); ctx.restore();\n ctx.save(); ctx.beginPath(); ctx.ellipse(0, 0, 170, 112, 0, 0, TAU); ctx.clip(); // feather scallops\n ctx.strokeStyle = 'rgba(255,255,255,.08)'; ctx.lineWidth = 3;\n for (let row = 0; row \u003c 4; row++) for (let i = 0; i \u003c 9; i++) { const x = -150 + i * 38 + (row % 2) * 19, y = -50 + row * 34; ctx.beginPath(); ctx.arc(x, y, 22, .2, Math.PI - .2); ctx.stroke(); }\n ctx.restore();\n for (let i = 0; i \u003c 5; i++) { // white wing plumes on the flank\n const a = .25 + i * .12; ctx.save(); ctx.translate(-30 + i * 12, 40); ctx.rotate(Math.PI - a);\n ctx.beginPath(); ctx.moveTo(0, 0); ctx.quadraticCurveTo(60, -18, 118, 0); ctx.quadraticCurveTo(60, 14, 0, 0); ctx.fillStyle = '#FFFFFF'; ctx.fill(); ctx.restore();\n }\n ctx.restore();\n // neck from the shoulder to the head, clipped at the ground line so the buried head disappears under the sand\n ctx.save(); ctx.beginPath(); ctx.rect(-3000, -3000, 6000, 3000); ctx.clip();\n const sh = [bx + Math.cos(rot) * 128 - Math.sin(rot) * -58, by + Math.sin(rot) * 128 + Math.cos(rot) * -58];\n const H = [lerp(sh[0] + 66, hx - wig, P), lerp(-760, 34, P)], Cc = [lerp(sh[0] + 22, sh[0] + 250, P), lerp(-560, sh[1] - 230, P)];\n const q = u => [(1 - u) ** 2 * sh[0] + 2 * (1 - u) * u * Cc[0] + u * u * H[0], (1 - u) ** 2 * sh[1] + 2 * (1 - u) * u * Cc[1] + u * u * H[1]];\n ctx.lineCap = 'round';\n for (let i = 0; i \u003c 24; i++) { const a = q(i / 24), b = q((i + 1) / 24); ctx.beginPath(); ctx.moveTo(...a); ctx.lineTo(...b); ctx.lineWidth = lerp(40, 20, i / 24); ctx.strokeStyle = OS.skin; ctx.stroke(); }\n ctx.save(); ctx.beginPath(); ctx.ellipse(sh[0] - 6, sh[1] + 8, 40, 30, rot, 0, TAU); ctx.fillStyle = OS.ink; ctx.fill(); ctx.restore(); // feather collar\n // head along the neck tangent\n const tg = Math.atan2(H[1] - Cc[1], H[0] - Cc[0]), hd = lerp(.12, tg, P); // upright: beak forward; buried: along the neck\n ctx.save(); ctx.translate(...H); ctx.rotate(hd);\n ctx.beginPath(); ctx.moveTo(22, -10); ctx.quadraticCurveTo(62, -8, 70, 2); ctx.quadraticCurveTo(58, 12, 20, 10); ctx.closePath(); ctx.fillStyle = OS.beak; ctx.fill();\n ctx.strokeStyle = OS.skin2; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(26, 1); ctx.lineTo(64, 2); ctx.stroke();\n ctx.beginPath(); ctx.ellipse(0, 0, 34, 27, 0, 0, TAU); ctx.fillStyle = OS.skin; ctx.fill();\n const wide = 1 + .35 * (it.upAt != null ? Math.max(0, 1 - Math.abs(t - it.upAt - .5) / 1.2) * up : 0);\n const blink = (t * 1.3 + .4) % 3.1 \u003c .12 ? .15 : 1;\n ctx.beginPath(); ctx.ellipse(6, -7, 11 * wide, 11 * wide * blink, 0, 0, TAU); ctx.fillStyle = '#FFFFFF'; ctx.fill(); ctx.strokeStyle = OS.ink; ctx.lineWidth = 2.5; ctx.stroke();\n if (blink > .5) { ctx.beginPath(); ctx.arc(9, -7, 5.5, 0, TAU); ctx.fillStyle = OS.ink; ctx.fill(); }\n ctx.lineWidth = 2.5; ctx.beginPath(); for (const a of [-2.2, -1.85, -1.5]) { ctx.moveTo(6 + Math.cos(a) * 11 * wide, -7 + Math.sin(a) * 11 * wide); ctx.lineTo(6 + Math.cos(a) * 18 * wide, -7 + Math.sin(a) * 18 * wide); } ctx.stroke();\n ctx.restore();\n ctx.restore();\n ctx.restore();\n // sand strip and mound (drawn over the buried head)\n ctx.save(); drop(ctx, .1, 14, 4);\n ctx.beginPath(); ctx.roundRect(-430, -4, 900, 26, 13); ctx.fillStyle = OS.sand; ctx.fill(); ctx.restore();\n ctx.beginPath(); ctx.moveTo(hx - 120, 2); ctx.bezierCurveTo(hx - 80, -62, hx + 80, -62, hx + 120, 2); ctx.closePath();\n const mg = ctx.createLinearGradient(0, -50, 0, 4); mg.addColorStop(0, '#EAE3D5'); mg.addColorStop(1, OS.sand2); ctx.fillStyle = mg; ctx.fill();\n ctx.fillStyle = 'rgba(120,100,70,.25)'; for (let i = 0; i \u003c 14; i++) { ctx.beginPath(); ctx.arc(hx - 90 + 180 * hash(i + 1), -6 - 34 * hash(i + 9) * (1 - Math.abs(hash(i + 1) - .5) * 1.6), 2.4, 0, TAU); ctx.fill(); }\n // sand bursts (head in, head out)\n for (const [bt, n] of [[it.downTo, 14], [it.upAt, 18]]) {\n if (bt == null || t \u003c bt || t > bt + 1.1) continue; const age = t - bt;\n for (let i = 0; i \u003c n; i++) {\n const vx = (hash(i * 3 + n) - .5) * 520, vy = -(260 + 420 * hash(i * 5 + n)), x = hx + vx * age, y = -40 + vy * age + 1500 * age * age;\n if (y > 10) continue; ctx.globalAlpha = 1 - age / 1.1; ctx.beginPath(); ctx.arc(x, y, 4 + 7 * hash(i + n * 7), 0, TAU); ctx.fillStyle = i % 3 ? OS.sand2 : '#A89878'; ctx.fill();\n }\n ctx.globalAlpha = 1;\n }\n // wake-up ticks (the blue accent)\n if (it.upAt != null && t > it.upAt + .15) {\n const k = S(t - it.upAt - .15, 12), hxu = H[0], hyu = H[1];\n ctx.strokeStyle = C.blue; ctx.lineCap = 'round'; ctx.lineWidth = 9; ctx.globalAlpha = k;\n for (const a of [-2.3, -1.6, -.9]) { ctx.beginPath(); ctx.moveTo(hxu + Math.cos(a) * 70, hyu + Math.sin(a) * 70); ctx.lineTo(hxu + Math.cos(a) * (70 + 44 * k), hyu + Math.sin(a) * (70 + 44 * k)); ctx.stroke(); }\n ctx.globalAlpha = 1;\n }\n}\n\n// glass shards of a size x size icon centered on 0: rays from the impact point cut by 3 jittered rings, each shard with\n// an outward velocity (faster near the impact), an upward kick and a spin. Deterministic per seed.\nfunction shards(size, hit, n, seed) {\n const h = size / 2, ix = hit[0] * h, iy = hit[1] * h, rings = [size * .28, size * .62, size * 1.6], rnd = i => hash(i * 7.13 + seed * 31.7);\n const ang = Array.from({ length: n }, (_, i) => (i + .5 * (rnd(i) - .5)) / n * TAU);\n const pt = (r, i) => { const j = ((i % n) + n) % n, rj = r * (.82 + .36 * rnd(j * 3 + r)); return [ix + Math.cos(ang[j] + (i >= n ? TAU : 0)) * rj, iy + Math.sin(ang[j] + (i >= n ? TAU : 0)) * rj]; };\n const out = [];\n for (let ri = 0; ri \u003c rings.length; ri++) for (let i = 0; i \u003c n; i++) {\n const r0 = ri ? rings[ri - 1] : 0, r1 = rings[ri], poly = ri ? [pt(r0, i), pt(r1, i), pt(r1, i + 1), pt(r0, i + 1)] : [[ix, iy], pt(r1, i), pt(r1, i + 1)];\n const path = new Path2D(); poly.forEach(([x, y], k) => path[k ? 'lineTo' : 'moveTo'](x, y)); path.closePath();\n const cx = poly.reduce((a, p) => a + p[0], 0) / poly.length, cy = poly.reduce((a, p) => a + p[1], 0) / poly.length;\n const dx = cx - ix, dy = cy - iy, d = Math.hypot(dx, dy) || 1, sp = (1500 + 1100 * rnd(ri * n + i + 50)) / (1 + d / size);\n out.push({ path, cx: clamp(cx, -h, h), cy: clamp(cy, -h, h), vx: dx / d * sp, vy: dy / d * sp - 520 - 380 * rnd(i + 90), w: (rnd(i + ri * 17 + 7) - .5) * 9 });\n }\n return out;\n}\nfunction logoAt(R, ctx, src, cx, cy, size, gray) { // real app icon centered on (cx, cy), rounded like an app icon\n const im = R?.img('/p/' + src); if (!im) return;\n const flat = logoFinish() === 'flat';\n if (!flat) lift(ctx, cx - size / 2, cy - size / 2, size, size, size * .22, size > 100 ? 1 : .35);\n ctx.save(); ctx.beginPath(); ctx.roundRect(cx - size / 2, cy - size / 2, size, size, size * .22); ctx.clip();\n if (gray) ctx.filter = `grayscale(${gray})`; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, cx - size / 2, cy - size / 2, size, size); ctx.restore();\n if (flat) { ctx.beginPath(); ctx.roundRect(cx - size / 2, cy - size / 2, size, size, size * .22); ctx.strokeStyle = C.line; ctx.lineWidth = Math.max(1.5, size / 90); ctx.stroke(); }\n}\nconst smoother = x => x \u003c= 0 ? 0 : x >= 1 ? 1 : x * x * x * (x * (x * 6 - 15) + 10);\nfunction drawItem(ctx, it, t, R) {\n const f = DRAW[it.k]; if (!f) return;\n // morph: the item (a screen) grows out of the previous scene's media rect instead of rising in (shared-element move)\n const mo = it._morph, mq = mo ? smoother(seg(t, mo.at, mo.at + (it.morphDur ?? .62))) : 1, mr = mo && mq \u003c 1 ? mo.rect(R) : null;\n // cut: in and out on the frame (badges that follow hard camera cuts); still: never translates on enter or exit\n const p = mr || mo && t >= mo.at ? 1 : it.shown || it.cut ? +(t >= it.at) : S(t - it.at, it.w8 || 13), out = it.to == null ? 0 : it.cut ? +(t >= it.to) : leave(t - it.to);\n if (mo && t \u003c mo.at) return;\n const a = Math.min(1, p * 1.35) * (1 - out); if (a \u003c= .002) return;\n let [x, y] = it.posKeys ? track(t, it.posKeys, it.posW || 10) : [it.x ?? W / 2, it.y ?? 600];\n if (mr) { // rect lerp: center, size and radius from the source rect to the item's own\n const w = it.w || 900, h = it.h || 620;\n x = lerp(mr[0] + mr[2] / 2, x, mq); y = lerp(mr[1] + mr[3] / 2, y, mq);\n it = { ...it, w: lerp(mr[2], w, mq), h: lerp(mr[3], h, mq), r: lerp(mo.r ?? 22, it.r ?? 30, mq), lit: mq };\n }\n const sc = (it.scaleKeys ? track(t, it.scaleKeys, it.posW || 10) : 1) * (it.pop != null ? lerp(it.pop, 1, p) : 1), dim = it.dimAt != null ? S(t - it.dimAt, 12) : 0;\n const rot = (it.rotKeys ? track(t, it.rotKeys, it.posW || 10) : it.k !== 'stamp' ? it.rot || 0 : 0) + (it.spin || 0) * (1 - p);\n ctx.save(); ctx.globalAlpha *= a * (1 - .62 * dim);\n const bare = it.strikeAt != null && it.k === 'text' && !String(it.text ?? '').trim(); // strike-only item: never rises in\n if (it.still) ctx.translate(x, y);\n else ctx.translate(x + (it.dx || 0) * (1 - p), y + (it.rise ?? (bare ? 0 : 40)) * (1 - p) - 28 * out * out); // exits lift away, accelerating\n if (rot) ctx.rotate(rot);\n if (sc !== 1) ctx.scale(sc, sc);\n f(ctx, it, t, R);\n if (it.strikeAt != null) { const q = E.io(seg(t, it.strikeAt, it.strikeAt + .35)), sw = it.strikeW || 400; if (q > .002) {\n // the drawn part is the first q of the final curve (de Casteljau), so the stroke draws left to right and never bends\n const x0 = -sw / 2, y0 = 6, x1 = 0, y1 = -6, x2 = sw / 2, y2 = -2, u = 1 - q;\n ctx.beginPath(); ctx.moveTo(x0, y0); ctx.quadraticCurveTo(x0 + (x1 - x0) * q, y0 + (y1 - y0) * q, u * u * x0 + 2 * u * q * x1 + q * q * x2, u * u * y0 + 2 * u * q * y1 + q * q * y2);\n stroke(ctx, col(it.strikeColor || 'blue'), 9); ctx.stroke(); } }\n ctx.restore();\n}\n\n// Entrance times, resolved once per board (times are short seconds). The choreography rules live here so every spec\n// gets them for free:\n// - gate: after a previous scene's exit (s._gate, set by linkScenes), nothing enters before the old beat is mostly gone.\n// - counters enter with their count (countFrom - .12), never parked on \"0\" while the voice has not said the number;\n// hold: true keeps the authored entrance.\n// - same slot: an item entering where another one just left (within .6 s, |dx| \u003c 300, |dy| \u003c 90) waits for it to be\n// gone (to + .24, leave() lasts .26 s), so swaps read as one leaves, the next arrives, never two values stacked\n// in a crossfade. slot: false opts an item out.\nconst SLOT = .24;\nconst slotXY = it => it.posKeys ? it.posKeys[0][1] : [it.x ?? W / 2, it.y ?? 600];\nfunction boardItems(s) {\n if (s._items) return s._items;\n const items = s.items.map(it => {\n let at = (it.at ?? s.from) - (it.pre ?? 0);\n if (it.k === 'counter' && !it.hold && it.countFrom != null && it.countFrom - at > .3) at = it.countFrom - .12;\n if (!it._morph && s._gate != null) at = Math.max(at, s._gate);\n return { ...it, at };\n });\n for (const b of items) for (const a of items) {\n if (a === b || a.to == null || b._morph || b.slot === false) continue;\n const [ax, ay] = slotXY(a), [bx, by] = slotXY(b);\n if (b.at > a.to - .3 && b.at \u003c a.to + .6 && Math.abs(ax - bx) \u003c 300 && Math.abs(ay - by) \u003c 90) b.at = Math.max(b.at, a.to + SLOT);\n }\n return (s._items = items);\n}\n\n// slow push-in over the board's life, so a held beat never sits still (default 2.5% on boards of 3 s and more; push: 0 = off)\nconst boardEnd = (R, s) => s.to ?? R.tl?.duration ?? s.from + 4;\nconst boardPush = (R, s) => s.push ?? (boardEnd(R, s) - s.from >= 3 ? .025 : 0);\nconst board = {\n zooms: (R, s) => boardPush(R, s) > 0 && !boardItems(s).some(it => it.k === 'glass'), // glass samples the frame under it: plain transform\n draw(R, s, t) {\n const { ctx } = R, out = s.to == null ? 0 : s.hard ? +(t >= s.to) : leave(t - s.to), items = boardItems(s);\n alpha(ctx, 1 - out, () => {\n if (s.outDx) ctx.translate(s.outDx * out * out, 0); else ctx.translate(0, -36 * out * out); // outDx: whip out sideways\n const end = boardEnd(R, s), push = boardPush(R, s);\n if (push) {\n const [px, py] = s.pushAt || [W / 2, 700], k = 1 + push * clamp((t - s.from) / Math.max(.5, end - s.from));\n zoomAt(ctx, k, px, py);\n }\n for (const it of items) drawItem(ctx, it, t, R); });\n },\n};\n\nexport const LIFE = { board };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":90927,"content_sha256":"cdd0d57b41ee744d5a75cc9708300c4331983138e8c46913071f95df53e1d2e6"},{"filename":"engine/lib/scenes-master.js","content":"// Masterclass scene: the creator's masterclass card (ported from the adMaster ads of short-tella-cmuqep / short-monteur-arc-fleche),\n// reusable by any short. Same contract as scenes.js, times already mapped to SHORT time.\n// masterCard a sheet with a dark head (eyebrow, title, logo + subline, round avatar, duration badge), the lessons, then\n// the bottom row that tells the story of the CTA, every step optional and keyed on a word:\n// arrowAt \"Premier lien sous la vidéo\" + an arrow down out of the card\n// btnAt the row becomes the blue button, a cursor flies in (cursorFrom [x, y])\n// clickAt the cursor presses it, the button runs \"Inscription\" with a filling bar\n// unlockAt \"Accès débloqué\" with a drawn check\n// playAt..playTo the masterclass plays: a progress bar and timer in the head, the current lesson lights\n// up and every finished one gets its check\n// {from, to, pre, y 236, eyebrow, title, sub, logo, avatar (image path, never the creator's real face in\n// faceless), badge, badgeAt, lessons [[label, 'N min']], chaptersFrom, chapterStep, linkText, btnText,\n// busyText, unlockText, total (seconds shown by the timer, default sum of the lesson minutes), outDx}\nimport { C, W, clamp, lerp, seg, E, S, leave, press, track, sheet, shadow, text, measure, midBase, chip, cursor, cover, checkIcon } from './kit.js';\n\nconst TAU = Math.PI * 2, MONO = 'JetBrains Mono';\nconst hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));\nconst mix = (a, b, p) => { const x = hex(a), y = hex(b); return `rgb(${x.map((v, i) => Math.round(lerp(v, y[i], clamp(p)))).join(',')})`; };\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\nconst live = (s, t) => t >= s.from - (s.pre || 0) - 1.5 && t \u003c= (s.to ?? 1e9) + 1;\nconst at = v => v ?? 1e9;\nconst mmss = x => `${Math.floor(x / 60)}:${String(Math.floor(x % 60)).padStart(2, '0')}`;\nfunction logoImg(R, ctx, src, x, y, size, r = size * .22) {\n const im = R.img('/p/' + src); if (!im) return;\n const k = Math.min(size / im.width, size / im.height), w = im.width * k, h = im.height * k;\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, size, size, r); ctx.clip();\n ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, x + (size - w) / 2, y + (size - h) / 2, w, h); ctx.restore();\n}\nfunction arrow(ctx, x, y0, y1, p, o = {}) {\n if (p \u003c= .002) return; const y = lerp(y0, y1, E.out(p)), hl = o.head || 24;\n ctx.save(); ctx.strokeStyle = o.color || C.blue; ctx.lineWidth = o.lw || 9; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.beginPath(); ctx.moveTo(x, y0); ctx.lineTo(x, y); ctx.moveTo(x - hl * .8, y - hl); ctx.lineTo(x, y); ctx.lineTo(x + hl * .8, y - hl); ctx.stroke(); ctx.restore();\n}\nfunction spinner(ctx, cx, cy, r, t, col) {\n ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = r * .32; ctx.lineCap = 'round';\n const a = t * 7.5; ctx.beginPath(); ctx.arc(cx, cy, r, a, a + 4.2); ctx.stroke(); ctx.restore();\n}\n\nconst masterCard = {\n needs(s, t) { return live(s, t) ? [s.logo, s.avatar].filter(Boolean).map(p => '/p/' + p) : []; },\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 236, h = 900;\n const out = s.to != null ? leave(t - s.to) : 0, pin = S(t - s.from + (s.pre || 0), 11);\n if (pin \u003c= .002 || out >= 1) return;\n const lessons = s.lessons || [], total = s.total ?? lessons.reduce((a, [, d]) => a + parseFloat(d) * 60, 0);\n // play: 0..1 across playAt..playTo, the lesson boundaries come from their durations\n const pT = s.playTo ?? at(s.playAt) + 4, play = s.playAt != null ? clamp((t - s.playAt) / (pT - s.playAt)) : 0, playing = t >= at(s.playAt);\n const bounds = []; let acc = 0; for (const [, d] of lessons) { bounds.push([acc / total, (acc += parseFloat(d) * 60) / total]); }\n const cur = playing ? Math.max(0, bounds.findIndex(([a, b]) => play >= a && play \u003c b)) : -1, curI = playing && play >= 1 ? lessons.length - 1 : cur;\n alpha(ctx, (1 - out) * Math.min(1, pin * 1.35), () => {\n ctx.translate((s.outDx ?? -820) * out * out, 70 * (1 - pin));\n sheet(ctx, x, y, w, h, 42, { elev: 1.5 });\n // ---- head panel\n const hx = x + 16, hy = y + 16, hw = w - 32, hh = 300;\n ctx.beginPath(); ctx.roundRect(hx, hy, hw, hh, 30); ctx.fillStyle = C.ink; ctx.fill();\n text(ctx, s.eyebrow || 'MASTERCLASS', hx + 44, hy + 74, { size: 32, weight: 700, ls: 3, color: '#7FB6FF', fam: MONO });\n // the title shrinks to fit the room left of the avatar, never slides under it\n const title = s.title || 'Ton monteur IA', room = hw - 44 - (s.avatar ? 112 + 76 + 30 : 44), tw = measure(ctx, title, 78, 800, -2.4);\n const ts = tw > room ? Math.floor(78 * room / tw) : 78;\n text(ctx, title, hx + 44, hy + 166, { size: ts, weight: 800, ls: -2.4 * ts / 78, color: C.white });\n if (s.logo) logoImg(R, ctx, s.logo, hx + 44, hy + 200, 50, 12);\n if (s.sub) text(ctx, s.sub, hx + (s.logo ? 110 : 44), midBase(hy + 225, 36), { size: 36, weight: 600, color: '#A9ABB2' });\n // avatar, with a blue ring that closes while the masterclass plays\n const ax = hx + hw - 112, ay = hy + 122, ar = 76, im = s.avatar && R.img('/p/' + s.avatar);\n ctx.save(); ctx.beginPath(); ctx.arc(ax, ay, ar, 0, TAU); ctx.fillStyle = '#2A2D35'; ctx.fill(); ctx.clip();\n if (im) cover(ctx, im, ax - ar, ay - ar, ar * 2, ar * 2, 0, [.5, .4]); ctx.restore();\n ctx.save(); ctx.beginPath(); ctx.arc(ax, ay, ar + 2, 0, TAU); ctx.lineWidth = 5; ctx.strokeStyle = '#FFFFFF'; ctx.stroke();\n if (playing) { ctx.beginPath(); ctx.arc(ax, ay, ar + 12, -Math.PI / 2, -Math.PI / 2 + TAU * play); ctx.lineWidth = 9; ctx.lineCap = 'round'; ctx.strokeStyle = C.blue; ctx.stroke(); }\n ctx.restore();\n const bp = S(t - at(s.badgeAt), 11);\n if (bp > .002) { ctx.save(); ctx.translate(ax, hy + hh - 8); ctx.rotate(-.05); chip(ctx, 0, 0, s.badge || '10 min', { size: 42, fill: C.blue, color: C.white, scale: 1 + .3 * (1 - bp), alpha: Math.min(1, bp * 1.5), elev: 1 }); ctx.restore(); }\n // play bar + timer along the bottom of the head\n const pp = S(t - at(s.playAt), 12);\n if (pp > .002) alpha(ctx, Math.min(1, pp * 1.4), () => {\n const bx = hx + 44, bw = hw - 400, byy = hy + hh - 30;\n ctx.beginPath(); ctx.roundRect(bx, byy - 5, bw, 10, 5); ctx.fillStyle = '#3A3D45'; ctx.fill();\n ctx.beginPath(); ctx.roundRect(bx, byy - 5, Math.max(10, bw * play), 10, 5); ctx.fillStyle = C.blue; ctx.fill();\n ctx.beginPath(); ctx.arc(bx + bw * play, byy, 13, 0, TAU); ctx.fillStyle = C.white; ctx.fill();\n text(ctx, mmss(total * play), bx + bw + 24, midBase(byy, 30), { size: 30, weight: 700, color: C.white, fam: MONO });\n });\n // ---- lessons\n const top = y + 392, lh = 100;\n if (curI >= 0) { // the current lesson's tint glides from row to row\n const ry = track(t, [[0, top], ...bounds.slice(1).map(([a], i) => [s.playAt + a * (pT - s.playAt), top + (i + 1) * lh])], 16);\n const hp = S(t - s.playAt, 12);\n ctx.save(); ctx.globalAlpha *= hp; ctx.beginPath(); ctx.roundRect(x + 24, ry - lh / 2 + 6, (w - 48) * E.out(hp), lh - 12, 22); ctx.fillStyle = C.tint; ctx.fill(); ctx.restore();\n }\n lessons.forEach(([lab, dur], i) => {\n const p = S(t - (at(s.chaptersFrom) + i * (s.chapterStep || .3)), 14), ry = top + i * lh;\n const hot = playing && i === curI ? 1 : 0, doneAt = playing && i \u003c curI ? s.playAt + bounds[i][1] * (pT - s.playAt) : null;\n alpha(ctx, Math.min(1, p * 1.4), () => {\n ctx.translate(40 * (1 - p), 0);\n if (i) { ctx.fillStyle = C.line; ctx.fillRect(x + 48, ry - lh / 2, w - 96, 2); }\n if (doneAt != null) checkIcon(ctx, x + 74, ry, 38, C.blue, E.out(seg(t, doneAt, doneAt + .3)), 6.5);\n else text(ctx, String(i + 1).padStart(2, '0'), x + 56, midBase(ry, 36), { size: 36, weight: 700, color: hot ? C.blue : C.ink3, fam: MONO });\n text(ctx, lab, x + 140, midBase(ry, 42), { size: 42, weight: 700, ls: -.6, color: hot ? C.blue2 : doneAt != null ? C.ink2 : C.ink });\n text(ctx, dur, x + w - 56, midBase(ry, 34), { size: 34, weight: 500, align: 'right', color: hot ? C.blue2 : C.ink3, fam: MONO });\n });\n });\n // ---- bottom row: link + arrow, then the button that gets clicked\n const by = y + h - 92, bx = x + 40, bw = w - 80, bh = 116, bcx = bx + bw / 2;\n ctx.fillStyle = C.line; ctx.fillRect(x + 48, by - 82, w - 96, 2);\n const lp = S(t - at(s.arrowAt), 12), btn = S(t - at(s.btnAt), 12);\n alpha(ctx, Math.min(1, lp * 1.4) * (1 - btn), () => {\n const lab = s.linkText || 'Premier lien sous la vidéo', tw = measure(ctx, lab, 46, 800, -1);\n text(ctx, lab, bcx - 30, midBase(by, 46), { size: 46, weight: 800, ls: -1, align: 'center' });\n arrow(ctx, bcx - 30 + tw / 2 + 40, by - 34, by + 34, seg(t, at(s.arrowAt) + .08, at(s.arrowAt) + .4));\n });\n if (btn > .002) {\n const pr = press(t, at(s.clickAt), .2), busy = S(t - at(s.clickAt), 16), done = S(t - at(s.unlockAt), 14);\n ctx.save(); ctx.globalAlpha *= Math.min(1, btn * 1.4); ctx.translate(bcx, by); const k = (.94 + .06 * btn) * (1 - .05 * pr); ctx.scale(k, k); ctx.translate(-bcx, -by);\n shadow(ctx, bx, by - bh / 2, bw, bh, bh / 2, 30, .28 * (1 - done), 12);\n ctx.beginPath(); ctx.roundRect(bx, by - bh / 2, bw, bh, bh / 2); ctx.fillStyle = mix(C.blue, '#EDEAE2', done); ctx.fill();\n // \"Inscription\": the bar fills the button between the click and the unlock\n if (busy > .002 && done \u003c 1) {\n const f = clamp((t - at(s.clickAt)) / Math.max(.3, at(s.unlockAt) - at(s.clickAt)));\n ctx.save(); ctx.beginPath(); ctx.roundRect(bx, by - bh / 2, bw, bh, bh / 2); ctx.clip();\n ctx.globalAlpha *= busy * (1 - done); ctx.fillStyle = C.blue2; ctx.fillRect(bx, by - bh / 2, bw * E.cubic(f), bh); ctx.restore();\n }\n alpha(ctx, 1 - S(t - at(s.clickAt), 40), () => text(ctx, s.btnText || 'Rejoindre la masterclass', bcx, midBase(by, 46), { size: 46, weight: 800, ls: -.8, align: 'center', color: C.white }));\n alpha(ctx, S(t - at(s.clickAt) - .12, 16) * (1 - S(t - at(s.unlockAt), 40)), () => { const lab = s.busyText || 'Inscription', tw = measure(ctx, lab, 46, 800, -.8) + 64;\n spinner(ctx, bcx - tw / 2 + 18, by, 18, t, C.white); text(ctx, lab, bcx - tw / 2 + 64, midBase(by, 46), { size: 46, weight: 800, ls: -.8, color: C.white }); });\n alpha(ctx, S(t - at(s.unlockAt) - .1, 16), () => { const lab = s.unlockText || 'Accès débloqué', tw = measure(ctx, lab, 46, 800, -.8) + 60;\n checkIcon(ctx, bcx - tw / 2 + 18, by, 36, C.blue, E.out(seg(t, at(s.unlockAt) + .06, at(s.unlockAt) + .4)), 6);\n text(ctx, lab, bcx - tw / 2 + 60, midBase(by, 46), { size: 46, weight: 800, ls: -.8 }); });\n ctx.restore();\n // the cursor: flies in from the mascot's side, hesitates, clicks, leaves after the unlock\n const c0 = s.cursorFrom || [W + 80, by + 320], b0 = at(s.btnAt), c1 = at(s.clickAt);\n const [mx, my] = track(t, [[0, c0], [b0 + .2, [bcx + 250, by + 60]], [b0 + .7, [bcx + 170, by + 30]], [c1 - .45, [bcx + 120, by + 18]], [c1 + .1, [bcx + 120, by + 18]], [at(s.unlockAt) + .5, c0]], 9);\n cursor(ctx, mx, my, { alpha: S(t - b0, 10) * (1 - S(t - at(s.unlockAt) - .7, 8)), press: pr });\n }\n });\n },\n};\n\nexport const MASTER = { masterCard };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":11586,"content_sha256":"9393d0d21155fef0f07422464ad3a100c4ae1d4d08a6b4e32b077276f8325184"},{"filename":"engine/lib/scenes-meta.js","content":"// Meta scenes: \"the AI edits the video you are watching\" beats.\n// Same contract as scenes.js: {type, from, to, layer, ...} with times already mapped to SHORT time.\n// capStyles (over) the live caption cycles through N caption styles, with a \"Style 07 / 20\" counter and a dot rail\n// camTile (over) the camera as a framed tile on full paper that moves on spoken words (top, bottom, left, right)\n// camSplit (over) split screen flipped: the camera rises from its bottom slot to the TOP of the frame, paper below\nimport { C, F, W, H, E, clamp, lerp, seg, S, leave, track, sheet, text, measure, midBase, chip, cover } from './kit.js';\nimport * as K from './kit.js';\n\nconst TAU = Math.PI * 2;\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\nconst envA = (s, t) => S(t - s.from + (s.pre || 0), 12) * (1 - leave(t - (s.to ?? 1e9)));\n\n// ---------------------------------------------------------------- capStyles (over)\n// {layer: 'over', from, to, cycleFrom, cycleTo, n: 20, y: 1180, counterY: 300}\n// Before cycleFrom it shows style 0 (the house sticker). Hide the normal captions over [from, to].\nconst MONO = 'JetBrains Mono', SERIF = 'Georgia';\nconst words = c => c.words.map(w => w.text);\nfunction lineW(ctx, ws, size, wt, ls, fam = F.sans) { const sp = measure(ctx, ' ', size, wt, ls, fam || F.sans); return ws.reduce((a, w, i) => a + measure(ctx, w, size, wt, ls, fam) + (i ? sp : 0), 0); }\n// lay words out centred on (cx, base); fn(word, x, w, i) draws each one\nfunction lay(ctx, ws, cx, size, wt, ls, fam, fn) { fam = fam || F.sans;\n const sp = measure(ctx, ' ', size, wt, ls, fam || F.sans); let x = cx - lineW(ctx, ws, size, wt, ls, fam) / 2;\n ws.forEach((w, i) => { const ww = measure(ctx, w, size, wt, ls, fam); fn(w, x, ww, i); x += ww + sp; });\n}\nfunction sticker(ctx, s, x, y, o) {\n ctx.save(); ctx.font = `${o.weight || 800} ${o.size}px ${o.fam || F.sans}`; ctx.letterSpacing = (o.ls ?? -1.5) + 'px'; ctx.textAlign = o.align || 'left';\n ctx.lineJoin = 'round';\n if (o.stroke) { ctx.lineWidth = o.lw || o.size * .22; ctx.strokeStyle = o.stroke; if (o.shadow !== false) { ctx.shadowColor = 'rgba(20,16,8,.28)'; ctx.shadowBlur = 22; ctx.shadowOffsetY = 6; } ctx.strokeText(s, x, y); ctx.shadowColor = 'transparent'; }\n if (o.fill !== null) { ctx.fillStyle = o.fill || C.ink; ctx.fillText(s, x, y); }\n ctx.restore();\n}\n// 'clean' (21st, not in the capStyles cycle): a white pill with sentence-case ink text, the clean look's caption.\n// Designed at 42 px (base), so captions.size is the real text size. Emphasised words take the accent.\nfunction cleanPill(ctx, c, y) {\n const ws = words(c), sz = 42, wt = 500, ls = -.4, w = lineW(ctx, ws, sz, wt, ls), h = 76, padX = 30;\n ctx.save(); K.drop(ctx, .10, 26, 7);\n ctx.beginPath(); ctx.roundRect(W / 2 - w / 2 - padX, y - h / 2, w + padX * 2, h, 22); ctx.fillStyle = C.white; ctx.fill();\n ctx.shadowColor = 'transparent'; ctx.strokeStyle = `rgba(${K.edgeRGB()},.09)`; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n lay(ctx, ws, W / 2, sz, wt, ls, null, (s, x, ww, i) => text(ctx, s, x, midBase(y, sz), { size: sz, weight: c.words[i].emph ? 600 : wt, ls, color: c.words[i].emph ? C.blue : C.ink }));\n}\ncleanPill.base = 42;\n// 20 styles, all inside the palette (ink, paper, one blue). Each draws chunk c centred at y; t for per-word timing.\nconst STYLES = [\n (ctx, c, y, t) => lay(ctx, words(c), W / 2, 84, 800, -1.5, null, (w, x, ww, i) => sticker(ctx, w, x, midBase(y, 84), { size: 84, stroke: C.sheet, fill: c.words[i].emph ? C.blue : C.ink })),\n (ctx, c, y) => { const s = words(c).join(' '), sz = 76, w = measure(ctx, s, sz, 800, -1.2); sheet(ctx, W / 2 - w / 2 - 40, y - 62, w + 80, 124, 62, { fill: C.blue, elev: .8, border: false }); text(ctx, s, W / 2, midBase(y, sz), { size: sz, weight: 800, ls: -1.2, align: 'center', color: C.white }); },\n (ctx, c, y) => { const s = words(c).join(' '), sz = 76, w = measure(ctx, s, sz, 800, -1.2); sheet(ctx, W / 2 - w / 2 - 36, y - 60, w + 72, 120, 18, { fill: C.ink, elev: .8, border: false }); text(ctx, s, W / 2, midBase(y, sz), { size: sz, weight: 800, ls: -1.2, align: 'center', color: C.white }); },\n (ctx, c, y) => sticker(ctx, words(c).join(' ').toUpperCase(), W / 2, midBase(y, 80), { size: 80, weight: 900, ls: 4, align: 'center', stroke: C.sheet }),\n (ctx, c, y, t) => lay(ctx, words(c), W / 2, 84, 800, -1.5, null, (w, x, ww, i) => sticker(ctx, w, x, midBase(y, 84), { size: 84, stroke: C.sheet, fill: t >= c.words[i].start ? C.blue : C.ink3 })),\n (ctx, c, y, t) => lay(ctx, words(c), W / 2, 84, 800, -1.5, null, (w, x, ww, i) => {\n const p = S(t - c.words[i].start, 18); sticker(ctx, w, x, midBase(y, 84), { size: 84, stroke: C.sheet });\n if (p > .01) { ctx.fillStyle = C.blue; ctx.beginPath(); ctx.roundRect(x, y + 50, ww * p, 14, 7); ctx.fill(); }\n }),\n (ctx, c, y) => sticker(ctx, words(c).join(' '), W / 2, midBase(y, 92), { size: 92, weight: 700, ls: -1, fam: SERIF, align: 'center', stroke: C.sheet, fill: C.ink }),\n (ctx, c, y) => { const s = words(c).join(' ').toLowerCase(); chip(ctx, W / 2, y, s, { size: 58, weight: 700, ls: 0, h: 120, padX: 44 }); },\n (ctx, c, y) => lay(ctx, words(c), W / 2, 80, 800, -1.2, null, (w, x, ww) => { ctx.fillStyle = C.tint; ctx.beginPath(); ctx.roundRect(x - 10, y - 52, ww + 20, 104, 14); ctx.fill(); text(ctx, w, x, midBase(y, 80), { size: 80, weight: 800, ls: -1.2, color: C.blue2 }); }),\n (ctx, c, y) => sticker(ctx, words(c).join(' '), W / 2, midBase(y, 92), { size: 92, weight: 900, align: 'center', stroke: C.ink, lw: 5, shadow: false, fill: C.sheet }),\n (ctx, c, y) => sticker(ctx, words(c).at(-1), W / 2, midBase(y, 150), { size: 150, weight: 900, ls: -5, align: 'center', stroke: C.sheet, fill: C.blue }),\n (ctx, c, y) => sticker(ctx, words(c).join(' ').toLowerCase(), W / 2, midBase(y, 84), { size: 84, weight: 500, ls: -1, align: 'center', stroke: C.sheet, fill: C.ink }),\n (ctx, c, y) => lay(ctx, words(c), W / 2, 84, 800, -1.5, null, (w, x, ww, i) => sticker(ctx, w, x, midBase(y, 84), { size: 84, stroke: C.sheet, fill: i ? C.blue : C.ink })),\n (ctx, c, y) => lay(ctx, words(c), W / 2, 66, 800, -1, null, (w, x, ww) => { sheet(ctx, x - 16, y - 50, ww + 32, 100, 20, { elev: .6 }); text(ctx, w, x, midBase(y, 66), { size: 66, weight: 800, ls: -1 }); }),\n (ctx, c, y) => { ctx.save(); ctx.translate(W / 2, y); ctx.rotate(-.06); const s = words(c).join(' '), w = measure(ctx, s, 78, 900, -1.5); sheet(ctx, -w / 2 - 34, -60, w + 68, 120, 14, { elev: 1 }); text(ctx, s, 0, midBase(0, 78), { size: 78, weight: 900, ls: -1.5, align: 'center' }); ctx.restore(); },\n (ctx, c, y, t) => { const s = words(c).join(' '), n = Math.round(s.length * seg(t, c.start, c.start + .35)), sz = 72, w = measure(ctx, s, sz, 600, 0, MONO), x = W / 2 - w / 2;\n sheet(ctx, x - 34, y - 58, w + 68, 116, 18, { elev: .8 }); text(ctx, s.slice(0, n), x, midBase(y, sz), { size: sz, weight: 600, fam: MONO });\n if (n \u003c s.length || (t * 2 | 0) % 2) { ctx.fillStyle = C.blue; ctx.fillRect(x + measure(ctx, s.slice(0, n), sz, 600, 0, MONO) + 4, y - 34, 6, 68); } },\n (ctx, c, y) => sticker(ctx, words(c).join(' '), W / 2, midBase(y, 88), { size: 88, weight: 900, align: 'center', stroke: C.sheet, fill: C.blue }),\n (ctx, c, y) => { const ws = words(c), lh = 86, y0 = y - (ws.length - 1) * lh / 2; ws.forEach((w, i) => sticker(ctx, w, W / 2, midBase(y0 + i * lh, 80), { size: 80, weight: 900, ls: -2, align: 'center', stroke: C.sheet, fill: i === ws.length - 1 ? C.blue : C.ink })); },\n (ctx, c, y) => sticker(ctx, '[ ' + words(c).join(' ') + ' ]', W / 2, midBase(y, 66), { size: 66, weight: 700, ls: 0, fam: MONO, align: 'center', stroke: C.sheet, fill: C.blue }),\n (ctx, c, y) => { const s = words(c).join(' '), sz = 72, w = measure(ctx, s, sz, 800, -1.2), x = W / 2 - w / 2 - 50;\n sheet(ctx, x, y - 64, w + 100, 128, 22, { elev: 1 }); ctx.fillStyle = C.blue; ctx.beginPath(); ctx.roundRect(x, y - 64, 14, 128, [22, 0, 0, 22]); ctx.fill();\n text(ctx, s, x + 60, midBase(y, sz), { size: sz, weight: 800, ls: -1.2 }); },\n cleanPill,\n];\n\nconst capStyles = {\n draw(R, s, t) {\n const { ctx } = R, a = envA(s, t); if (a \u003c= .002 || !R.allChunks) return;\n const n = Math.min(s.n || 20, STYLES.length), y = s.y ?? 1180;\n let c = null; for (const ch of R.allChunks) if (ch.start - .02 \u003c= t) c = ch; else break;\n const k = t \u003c s.cycleFrom ? 0 : Math.min(n - 1, Math.floor(seg(t, s.cycleFrom, s.cycleTo) * n * .9999));\n const showCap = c && t >= s.from - .1 && (t \u003c= c.end + .4 || t \u003c s.cycleTo);\n alpha(ctx, a, () => {\n // fade per chunk only: style switches cut hard (each lasts ~.25 s)\n if (showCap) { const p = S(t - c.start + .12, 30); alpha(ctx, Math.min(1, p * 1.4), () => { ctx.translate(0, (1 - p) * 18); STYLES[k](ctx, c, y, t); }); }\n // counter + dot rail, only once the cycling starts\n const q = S(t - s.cycleFrom + .15, 14); if (q \u003c= .002) return;\n alpha(ctx, q, () => {\n const cy = s.counterY ?? 300; ctx.translate(0, (1 - q) * 30);\n chip(ctx, W / 2, cy, `Style ${String(k + 1).padStart(2, '0')} / ${n}`, { size: 44, h: 92, weight: 800, ls: -.5 });\n const gap = 34, x0 = W / 2 - (n - 1) * gap / 2;\n for (let i = 0; i \u003c n; i++) { ctx.beginPath(); ctx.arc(x0 + i * gap, cy + 84, i === k ? 10 : 7, 0, TAU); ctx.fillStyle = i \u003c= k ? C.blue : 'rgba(252,251,248,.85)'; ctx.fill(); }\n });\n });\n },\n};\n\n// ---------------------------------------------------------------- camTile (over)\n// {layer: 'over', from, to, w: 460, h: 580, keys: [[t, [cx, cy]]], labelKeys: [[t, 'En haut']], r: 24, zoom: 1}\n// The camera in a paper frame; it glides to each key. The label chip sits mirrored across the screen centre.\n// r = corner radius (w / 2 on a square tile = a round bubble), zoom > 1 frames the face tighter.\n// sizeKeys: [[t, [w, h], spring?]] animates the tile size (e.g. full frame shrinking into a picture-in-picture);\n// frame: 'line' drops the paper sheet for a bare rounded window with a 1px border (lineColor), no shadow, no rise.\n// cut: true enters at full opacity on `from` (a hard cut, e.g. a full-frame camera that then shrinks to reveal what is under it).\nconst camTile = {\n draw(R, s, t) {\n const { ctx } = R, a = s.cut ? (t \u003c s.from ? 0 : 1 - leave(t - (s.to ?? 1e9))) : envA(s, t); if (a \u003c= .002) return;\n const [w, h] = s.sizeKeys ? track(t, s.sizeKeys, 9) : [s.w || 460, s.h || 580];\n const x = track(t, s.keys.map(([k, v]) => [k, v[0]]), 9), y = track(t, s.keys.map(([k, v]) => [k, v[1]]), 9);\n const rise = s.frame === 'line' ? 0 : 1 - S(t - s.from + (s.pre || 0), 12), [, , CW, CH] = R.spec.cam?.crop || [0, 0, 1200, 1080], eyes = R.spec.cam?.eyes || [600, 400];\n alpha(ctx, Math.min(1, a * 1.3), () => {\n ctx.translate(0, rise * 80);\n const r = s.r ?? 24;\n if (s.frame !== 'line') sheet(ctx, x - w / 2 - 14, y - h / 2 - 14, w + 28, h + 28, r + 12, { elev: 1.4 });\n cover(ctx, R.cam, x - w / 2, y - h / 2, w, h, r, [eyes[0] / CW, clamp(eyes[1] / CH + .08)], s.zoom ?? 1);\n if (s.frame === 'line') { ctx.beginPath(); ctx.roundRect(x - w / 2 + .5, y - h / 2 + .5, w - 1, h - 1, r); ctx.lineWidth = 1; ctx.strokeStyle = s.lineColor || 'rgba(255,255,255,.22)'; ctx.stroke(); }\n // label: newest key wins, cross-fade\n let cur = null, at = -1e9; for (const [k, v] of s.labelKeys || []) if (t >= k) { cur = v; at = k; }\n if (cur) {\n const p = S(t - at, 16), lx = lerp(W / 2, W - x, .9), ly = lerp(H / 2, H - y, .75);\n chip(ctx, clamp(lx, 260, W - 260), ly + (1 - p) * 24, cur, { size: 48, h: 104, weight: 800, dot: C.blue, alpha: Math.min(1, p * 1.4) });\n }\n });\n },\n};\n\n// ---------------------------------------------------------------- camSplit (over)\n// {layer: 'over', from, to, h: 820, enterFrom: 1240, dur: .7}\n// The camera panel slides from its split slot (top = enterFrom, the seam before) up to the top of the frame, h tall,\n// with the same torn paper edge and a soft shadow; the paper below is free for an 'over' board drawn after it.\n// Pair it with a layout key [from, 1920, 400] (the bottom camera leaves at once, this panel replaces it pixel for\n// pixel) and captions.yKeys [[from, h + 60]] so the captions sit on the split line.\nconst camSplit = {\n draw(R, s, t) {\n const { ctx } = R; if (t \u003c s.from || !R.cam || !R.camFit) return;\n const a = 1 - leave(t - (s.to ?? 1e9)); if (a \u003c= .002) return;\n const h = s.h || 820, e0 = s.enterFrom ?? 1240, p = E.io(seg(t, s.from, s.from + (s.dur ?? .7)));\n const top = lerp(e0, 0, p), bot = lerp(H, h, p);\n const A = R.camFit(e0), B = R.camFit(H - h); B.y0 -= H - h;\n const T = { s: lerp(A.s, B.s, p), x0: lerp(A.x0, B.x0, p), y0: lerp(A.y0, B.y0, p) };\n alpha(ctx, a, () => {\n if (bot \u003c H - 2) { // shadow the panel casts on the paper under its torn edge\n const g = ctx.createLinearGradient(0, bot - 6, 0, bot + 46); g.addColorStop(0, 'rgba(40,28,10,.22)'); g.addColorStop(1, 'rgba(40,28,10,0)');\n ctx.fillStyle = g; ctx.fillRect(0, bot - 6, W, 52);\n }\n ctx.save(); ctx.beginPath(); ctx.rect(0, top, W, H - top); ctx.clip(); if (bot \u003c H - 2) ctx.clip(R.camEdge(bot, false));\n ctx.drawImage(R.cam, T.x0, T.y0, R.CW * T.s, R.CHh * T.s); ctx.restore();\n });\n },\n};\n\nexport const META = { capStyles, camTile, camSplit };\n// the same 20 looks as live captions (spec.captions.style, names in lib/style.js CAPTION_STYLES)\nexport const CAPTION_DRAW = STYLES;\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":13501,"content_sha256":"f5fcbfdf5c0c697c1c3ea460e97b87be9068865441bf0c203d1c256e2370a8f8"},{"filename":"engine/lib/scenes-story.js","content":"// Story scenes: hand-drawn illustration scenes for narrative shorts (first used by short-chapeau-alu).\n// Same contract as scenes.js: {type, from, to, layer, ...} with times already mapped to SHORT time.\n// foilHat (over) a crinkled tinfoil hat with a little antenna that drops onto the speaker's head (face-tracked)\n// stampOver (over) a rubber stamp over the camera\n// sea Eiffel tower line drawing, rising water, year counter, stamp, sea gauge, \"simulation\" wireframe\n// giec flip-book of yearly reports that always say \"dans 5 ans\"\n// bike recycling bins struck out, then a 100 km commute with a moving bike, km counter and a passing sun\n// equation CQFD: IA = climat x10, then a callback stamp\n// crowd GPT -> Mistral: a crowd of dots flows between two logo tiles, then regroups into a pyramid (elite on top)\n// cheque a company cheque: handwritten payee, amount counts up, rolls back to 0, then a stamp lands\n// {rect: [x, y, w], logo, payer, city, no, date, payee, words, memo, amount, currency '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, writeAt,\n// countFrom, countTo, zeroAt (count rolls to 0, words struck), stampAt, stamp} (560 px tall)\nimport { C, W, H, clamp, lerp, seg, E, S, leave, track, sheet, shadow, text, measure, midBase, chip, hash } from './kit.js';\n\nconst TAU = Math.PI * 2;\nconst hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));\nconst mix = (a, b, p) => { const x = hex(a), y = hex(b); return `rgb(${x.map((v, i) => Math.round(lerp(v, y[i], clamp(p)))).join(',')})`; };\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\nfunction rise(ctx, p, dist, fn, dx = 0) { alpha(ctx, Math.min(1, p * 1.35), () => { ctx.translate(dx * (1 - p), dist * (1 - p)); fn(); }); }\nfunction env(ctx, s, t, fn) {\n const out = s.to != null ? leave(t - s.to) : 0;\n alpha(ctx, 1 - out, () => { ctx.translate(0, -36 * out); fn(S(t - s.from + (s.pre || 0), 12)); });\n}\nconst P = (t, at, w = 13) => at == null ? 0 : S(t - at, w);\n\n// rubber stamp: lands from 1.35x to 1 (critically damped, no overshoot), slight angle\nfunction stamp(ctx, x, y, str, p, o = {}) {\n if (p \u003c= .002) return;\n const size = o.size || 64, w = measure(ctx, str, size, 800, -1) + 76, h = size + 54;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.6); ctx.translate(x, y); ctx.rotate(o.rot ?? -.07); const k = 1 + .35 * (1 - p); ctx.scale(k, k);\n if (o.solid) shadow(ctx, -w / 2, -h / 2, w, h, 22, 22, .2, 8);\n ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, 22); ctx.fillStyle = o.solid ? C.sheet : 'rgba(37,142,255,.08)'; ctx.fill();\n ctx.lineWidth = 8; ctx.strokeStyle = C.blue; ctx.stroke();\n ctx.beginPath(); ctx.roundRect(-w / 2 + 12, -h / 2 + 12, w - 24, h - 24, 14); ctx.lineWidth = 2.5; ctx.stroke();\n text(ctx, str, 0, midBase(0, size), { size, weight: 800, ls: -1, align: 'center', color: C.blue });\n ctx.restore();\n}\n\n// ---------------------------------------------------------------- face track (assets/faces.json from engine/faces.swift)\nlet FACES = null;\nfunction faces() {\n if (FACES) return FACES;\n try { const x = new XMLHttpRequest(); x.open('GET', '/p/assets/faces.json', false); x.send(); FACES = x.status === 200 ? JSON.parse(x.responseText) : []; }\n catch { FACES = []; }\n return FACES;\n}\n\n// ---------------------------------------------------------------- foilHat (over)\n// {layer: 'over', from, to, dropAt, lift: .72 (base above face centre, in face heights), size: 1}\nfunction foilCone(ctx, fw, seed) {\n const bw = fw * .8, hh = fw * 1.18, ax = fw * .06; // base half-width, height, apex lean\n // faceted cone: rows x cols of jittered vertices, lit from the upper left\n const rows = 6, cols = 9, V = [];\n for (let r = 0; r \u003c= rows; r++) {\n const v = r / rows, row = [];\n for (let c = 0; c \u003c= cols; c++) {\n const u = c / cols, edge = c === 0 || c === cols || r === rows, jx = edge ? 0 : (hash(seed + r * 31 + c * 7) - .5) * fw * .07, jy = r === 0 || r === rows ? 0 : (hash(seed + r * 13 + c * 17) - .5) * fw * .06;\n const half = bw * (1 - v), x = lerp(-half, half, u) + ax * v + jx, y = -hh * v + Math.sin(u * Math.PI) * fw * .1 * (1 - v) + jy;\n row.push([x, y, u]);\n }\n V.push(row);\n }\n for (let r = 0; r \u003c rows; r++) for (let c = 0; c \u003c cols; c++) {\n const a = V[r][c], b = V[r][c + 1], d = V[r + 1][c], e = V[r + 1][c + 1];\n for (const [tri, k] of [[[a, b, e], 0], [[a, e, d], 1]]) {\n const u = (tri[0][2] + tri[1][2] + tri[2][2]) / 3, light = .55 + .45 * Math.cos((u - .32) * Math.PI * 1.1) - .12 * (r / rows);\n const n = hash(seed + r * 97 + c * 41 + k * 5) * .5 - .25, L = clamp(light + n, .05, 1);\n const g = Math.round(lerp(92, 250, L));\n ctx.beginPath(); ctx.moveTo(tri[0][0], tri[0][1]); ctx.lineTo(tri[1][0], tri[1][1]); ctx.lineTo(tri[2][0], tri[2][1]); ctx.closePath();\n ctx.fillStyle = `rgb(${g},${g + 2},${g + 6})`; ctx.fill();\n ctx.strokeStyle = `rgba(255,255,255,${.18 + .3 * hash(seed + r * 11 + c)})`; ctx.lineWidth = 1.2; ctx.stroke();\n }\n }\n // folded rim: a crinkled band around the base\n ctx.beginPath();\n for (let i = 0; i \u003c= 40; i++) { const u = i / 40, x = lerp(-bw * 1.04, bw * 1.04, u), y = Math.sin(u * Math.PI) * fw * .12 + (hash(seed + i * 3) - .5) * fw * .025; i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }\n for (let i = 40; i >= 0; i--) { const u = i / 40, x = lerp(-bw * 1.04, bw * 1.04, u), y = Math.sin(u * Math.PI) * fw * .12 - fw * .1 + (hash(seed + i * 5) - .5) * fw * .03; ctx.lineTo(x, y); }\n ctx.closePath();\n const rg = ctx.createLinearGradient(-bw, 0, bw, 0);\n rg.addColorStop(0, '#9A9EA6'); rg.addColorStop(.3, '#F4F5F7'); rg.addColorStop(.55, '#B9BDC4'); rg.addColorStop(.8, '#E8E9EC'); rg.addColorStop(1, '#8C9098');\n ctx.fillStyle = rg; ctx.fill(); ctx.strokeStyle = 'rgba(60,64,72,.35)'; ctx.lineWidth = 1.5; ctx.stroke();\n // outline\n ctx.beginPath(); ctx.moveTo(-bw, 0); ctx.lineTo(ax, -hh); ctx.lineTo(bw, 0); ctx.strokeStyle = 'rgba(40,44,52,.35)'; ctx.lineWidth = 2; ctx.stroke();\n // specular streak\n ctx.save(); ctx.globalAlpha = .55; ctx.beginPath(); ctx.moveTo(-bw * .55, -fw * .02); ctx.lineTo(ax - fw * .05, -hh * .92); ctx.lineTo(ax - fw * .02, -hh * .9); ctx.lineTo(-bw * .42, -fw * .02); ctx.closePath(); ctx.fillStyle = '#fff'; ctx.fill(); ctx.restore();\n // antenna + blue ball (the martian touch)\n const tx = ax, ty = -hh;\n ctx.beginPath(); ctx.moveTo(tx, ty + 2); ctx.quadraticCurveTo(tx + fw * .05, ty - fw * .22, tx + fw * .02, ty - fw * .4); ctx.strokeStyle = '#6E737C'; ctx.lineWidth = Math.max(3, fw * .025); ctx.lineCap = 'round'; ctx.stroke();\n const br = fw * .075, bx = tx + fw * .02, by = ty - fw * .4 - br * .6;\n const bg = ctx.createRadialGradient(bx - br * .35, by - br * .35, br * .1, bx, by, br);\n bg.addColorStop(0, '#9CCBFF'); bg.addColorStop(.45, C.blue); bg.addColorStop(1, C.blue2);\n ctx.beginPath(); ctx.arc(bx, by, br, 0, TAU); ctx.fillStyle = bg; ctx.fill();\n}\nconst foilHat = {\n draw(R, s, t) {\n const { ctx } = R, F = faces(); if (!F.length || R.seam >= H - 2) return;\n const fps = R.tl.fps || 30, f = F[clamp(Math.round(t * fps), 0, F.length - 1)]; if (!f || !f[2]) return;\n const drop = S(t - (s.dropAt ?? s.from), 9), out = s.to != null ? S(t - s.to, 10) : 0;\n const a = Math.min(1, drop * 3) * (1 - out); if (a \u003c= .002) return;\n const T = R.camT(t, R.seam), fw = f[2] * T.s * (s.size || .82), fh = f[3] * T.s;\n const cx = T.x0 + f[0] * T.s, cy = T.y0 + f[1] * T.s - fh * (s.lift ?? .66);\n const dy = -(1 - drop) * 1100 - out * 700;\n ctx.save(); ctx.beginPath(); ctx.rect(0, R.seam, W, H - R.seam); ctx.clip();\n ctx.globalAlpha = a; ctx.translate(cx, cy + dy); ctx.rotate((f[4] || 0) * .9 + (1 - drop) * -.25);\n shadow(ctx, -fw * .8, -fw * .1, fw * 1.6, fw * .3, fw * .15, 18, .22, 12);\n foilCone(ctx, fw, 17);\n ctx.restore();\n },\n};\n\n// ---------------------------------------------------------------- stampOver (over): {layer: 'over', from, to, text, y, size, rot}\nconst stampOver = {\n draw(R, s, t) {\n const out = s.to != null ? S(t - s.to, 14) : 0, p = S(t - s.from, 11) * (1 - out);\n stamp(R.ctx, s.x ?? W / 2, s.y ?? 420, s.text, p, { size: s.size || 76, rot: s.rot ?? -.06, solid: true });\n },\n};\n\n// ---------------------------------------------------------------- Eiffel tower (line art). base at (cx, by), height h\nfunction eiffel(ctx, cx, by, h, col, lw) {\n const hw = h * .36; // half base\n const leg = (sgn, inner) => { // curved leg edge from base to the top neck\n const x0 = cx + sgn * (inner ? hw * .55 : hw), x1 = cx + sgn * h * (inner ? .022 : .04), y1 = by - h * .86;\n ctx.beginPath(); ctx.moveTo(x0, by); ctx.bezierCurveTo(cx + sgn * hw * (inner ? .42 : .7), by - h * .25, cx + sgn * h * .08, by - h * .6, x1, y1); ctx.stroke();\n };\n const xAt = (y, sgn, inner) => { // approximate edge x at height y (for platforms and bracing)\n const v = clamp((by - y) / (h * .86)); const w0 = inner ? hw * .55 : hw, w1 = h * (inner ? .022 : .04);\n return cx + sgn * lerp(w0, w1, 1 - Math.pow(1 - v, 2.1));\n };\n ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n leg(-1, false); leg(1, false); leg(-1, true); leg(1, true);\n // base arch\n ctx.beginPath(); ctx.moveTo(xAt(by - h * .16, -1, true), by - h * .16); ctx.quadraticCurveTo(cx, by - h * .36, xAt(by - h * .16, 1, true), by - h * .16); ctx.stroke();\n // platforms\n for (const [v, ext] of [[.25, .07], [.52, .045]]) {\n const y = by - h * v, xl = xAt(y, -1) - h * ext, xr = xAt(y, 1) + h * ext;\n ctx.beginPath(); ctx.moveTo(xl, y); ctx.lineTo(xr, y); ctx.moveTo(xl + h * .01, y + h * .03); ctx.lineTo(xr - h * .01, y + h * .03); ctx.stroke();\n }\n // cross bracing in the legs\n ctx.save(); ctx.lineWidth = lw * .45;\n for (const sgn of [-1, 1]) for (let k = 0; k \u003c 7; k++) {\n const y0 = by - h * (.28 + k * .08), y1 = by - h * (.28 + (k + 1) * .08);\n if (y1 \u003c by - h * .84) break;\n ctx.beginPath(); ctx.moveTo(xAt(y0, sgn), y0); ctx.lineTo(xAt(y1, sgn, true), y1); ctx.moveTo(xAt(y0, sgn, true), y0); ctx.lineTo(xAt(y1, sgn), y1); ctx.stroke();\n }\n for (const sgn of [-1, 1]) for (let k = 0; k \u003c 3; k++) {\n const y0 = by - h * (k * .08), y1 = by - h * ((k + 1) * .08);\n ctx.beginPath(); ctx.moveTo(xAt(y0, sgn), y0); ctx.lineTo(xAt(y1, sgn, true), y1); ctx.moveTo(xAt(y0, sgn, true), y0); ctx.lineTo(xAt(y1, sgn), y1); ctx.stroke();\n }\n ctx.restore();\n // top: neck, lantern, spire\n const ny = by - h * .86;\n ctx.beginPath(); ctx.rect(cx - h * .03, ny - h * .04, h * .06, h * .04); ctx.stroke();\n ctx.beginPath(); ctx.moveTo(cx, ny - h * .04); ctx.lineTo(cx, by - h); ctx.stroke();\n ctx.restore();\n}\n// wavy water surface path, filled down to `bottom`\nfunction water(ctx, y, bottom, t, amp = 10, x0 = 0, x1 = W) {\n ctx.beginPath(); ctx.moveTo(x0, bottom);\n for (let x = x0; x \u003c= x1; x += 12) ctx.lineTo(x, y + Math.sin(x * .012 + t * 1.6) * amp + Math.sin(x * .031 - t * 1.1) * amp * .45);\n ctx.lineTo(x1, bottom); ctx.closePath();\n}\n\n// sea: {from, to, base: 1150, h: 700, year: {fromYear, toYear, countFrom, countTo, y}, waterKeys: [[t, level 0..1]],\n// stamp: {text, at}, gauge: {at}, sim: {at}}\nconst sea = {\n draw(R, s, t) {\n const { ctx } = R, by = s.base ?? 1150, th = s.h ?? 700, cx = W / 2;\n env(ctx, s, t, pin => {\n const sim = s.sim ? E.io(seg(t, s.sim.at, s.sim.at + .9)) : 0, scanY = lerp(150, 1260, sim);\n // simulation grid (only where the scan line has passed)\n if (sim > 0) {\n ctx.save(); ctx.beginPath(); ctx.rect(0, 0, W, scanY); ctx.clip();\n ctx.fillStyle = 'rgba(37,142,255,.05)'; ctx.fillRect(0, 0, W, scanY);\n ctx.strokeStyle = 'rgba(37,142,255,.22)'; ctx.lineWidth = 1.5;\n for (let x = 0; x \u003c= W; x += 60) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, 1300); ctx.stroke(); }\n for (let y = 0; y \u003c= 1300; y += 60) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke(); }\n ctx.restore();\n if (sim \u003c 1) { ctx.fillStyle = C.blue; ctx.fillRect(0, scanY - 2, W, 4); alpha(ctx, .25, () => { const g = ctx.createLinearGradient(0, scanY - 90, 0, scanY); g.addColorStop(0, 'rgba(37,142,255,0)'); g.addColorStop(1, 'rgba(37,142,255,.5)'); ctx.fillStyle = g; ctx.fillRect(0, scanY - 90, W, 90); }); }\n }\n // tower: draws on from the ground up\n const draw = E.out(seg(t, s.from - (s.pre || 0), s.from - (s.pre || 0) + .9));\n ctx.save(); ctx.beginPath(); ctx.rect(0, lerp(by + 10, by - th - 20, draw), W, th + 60); ctx.clip();\n eiffel(ctx, cx, by, th, C.ink, 6);\n if (sim > 0) { ctx.beginPath(); ctx.rect(0, 0, W, scanY); ctx.clip(); eiffel(ctx, cx, by, th, C.blue, 6); }\n ctx.restore();\n // ground line\n rise(ctx, pin, 0, () => { ctx.strokeStyle = C.ink; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(110, by); ctx.lineTo(W - 110, by); ctx.stroke(); });\n // water\n if (s.waterKeys) {\n const lv = track(t, s.waterKeys, 5), wy = by + 14 - th * lv;\n if (lv > .003) {\n ctx.save(); ctx.globalCompositeOperation = 'multiply';\n water(ctx, wy + 14, 1320, t + 1.3, 8); ctx.fillStyle = 'rgba(37,142,255,.13)'; ctx.fill();\n water(ctx, wy, 1320, t, 11); ctx.fillStyle = 'rgba(37,142,255,.2)'; ctx.fill();\n ctx.restore();\n ctx.save(); water(ctx, wy, 1320, t, 11); ctx.strokeStyle = C.blue; ctx.lineWidth = 5; ctx.stroke(); ctx.restore();\n }\n }\n // year counter\n if (s.year) {\n const y = s.year, k = E.io(seg(t, y.countFrom, y.countTo)), yr = Math.round(lerp(y.fromYear, y.toYear, k));\n rise(ctx, P(t, s.from - (s.pre || 0), 12) * (1 - (s.sim ? P(t, s.sim.at, 12) : 0)), 40, () => {\n text(ctx, String(yr), cx, y.y ?? 330, { size: 120, weight: 800, ls: -4, align: 'center', color: k >= 1 ? C.blue : C.ink });\n });\n }\n // sea gauge (right side): needle stuck at 0\n if (s.gauge) {\n const g = P(t, s.gauge.at, 12), gx = 900, top = by - 420;\n rise(ctx, g, 40, () => {\n sheet(ctx, gx - 60, top - 40, 120, 460, 28, { elev: .8 });\n ctx.strokeStyle = C.ink3; ctx.lineWidth = 3;\n for (let i = 0; i \u003c= 10; i++) { const y = top + i * 38, big = i % 5 === 0; ctx.beginPath(); ctx.moveTo(gx - (big ? 34 : 22), y); ctx.lineTo(gx + (big ? 34 : 22), y); ctx.stroke(); }\n const ny = top + 380;\n ctx.fillStyle = C.blue; ctx.beginPath(); ctx.roundRect(gx - 44, ny - 7, 88, 14, 7); ctx.fill();\n chip(ctx, gx - 80, ny, '0 cm', { align: 'right', size: 40, h: 80, fill: C.blue, color: C.white, border: false });\n });\n }\n if (s.stamp) stamp(ctx, s.stamp.x ?? cx, s.stamp.y ?? 760, s.stamp.text, P(t, s.stamp.at, 11), { size: 84, rot: -.08, solid: true });\n if (s.sim) {\n const q = P(t, s.sim.at + .5, 13), str = '> simulation.exe', n = Math.round(str.length * E.out(seg(t, s.sim.at + .5, s.sim.at + 1.2)));\n alpha(ctx, q, () => { sheet(ctx, W / 2 - 280, 250, 560, 96, 22, { elev: .6, fill: C.ink }); text(ctx, str.slice(0, n), W / 2 - 246, midBase(298, 40), { size: 40, weight: 600, fam: 'JetBrains Mono', color: C.white }); });\n }\n });\n },\n};\n\n// ---------------------------------------------------------------- giec: flip-book of yearly reports\n// {from, to, rect: [x, y, w, h], yearKeys: [[at, 2021], ...], tally: {text, at}}\nfunction report(ctx, x, y, w, h, year) {\n sheet(ctx, x, y, w, h, 30, { elev: 1.3 });\n text(ctx, 'RAPPORT DU GIEC', x + 60, y + 92, { size: 38, weight: 800, ls: 4, color: C.ink2 });\n text(ctx, String(year), x + 56, y + 250, { size: 170, weight: 800, ls: -7, color: C.ink });\n ctx.fillStyle = C.line; ctx.fillRect(x + 60, y + 300, w - 120, 3);\n for (let i = 0; i \u003c 3; i++) { ctx.beginPath(); ctx.roundRect(x + 60, y + 340 + i * 42, (w - 120) * [1, .86, .62][i], 16, 8); ctx.fillStyle = C.dim; ctx.fill(); }\n text(ctx, 'Fin du monde :', x + 60, y + h - 70, { size: 46, weight: 800, color: C.ink });\n chip(ctx, x + w - 60, y + h - 86, 'dans 5 ans', { align: 'right', size: 46, h: 92, fill: C.blue, color: C.white, border: false });\n}\nconst giec = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, w, h] = s.rect || [120, 340, 840, 620];\n env(ctx, s, t, pin => {\n rise(ctx, pin, 60, () => {\n const ys = s.yearKeys; let i = 0; while (i + 1 \u003c ys.length && t >= ys[i + 1][0]) i++;\n const nextAt = ys[i + 1]?.[0], curAt = ys[i][0];\n // outgoing report slides left, the new one arrives from the right\n if (i > 0) { const p = S(t - curAt, 13); if (p \u003c .999) alpha(ctx, 1 - p, () => { ctx.save(); ctx.translate(-260 * p, 0); ctx.rotate(-.02 * p); report(ctx, x, y, w, h, ys[i - 1][1]); ctx.restore(); }); }\n const p = i > 0 ? S(t - curAt, 13) : 1;\n alpha(ctx, Math.min(1, p * 1.4), () => { ctx.save(); ctx.translate(320 * (1 - p), 0); report(ctx, x, y, w, h, ys[i][1]); ctx.restore(); });\n void nextAt;\n // tally row: one dot per report seen so far\n const n = ys.filter(([a]) => t >= a).length, dx = 44, x0 = W / 2 - (ys.length - 1) * dx / 2;\n for (let k = 0; k \u003c ys.length; k++) { const q = P(t, ys[k][0], 14); ctx.beginPath(); ctx.arc(x0 + k * dx, y + h + 70, 11, 0, TAU); ctx.fillStyle = k \u003c n ? mix(C.dim, C.ink, q) : C.dim; ctx.fill(); }\n });\n if (s.tally) { const q = P(t, s.tally.at, 13); if (q > .002) rise(ctx, q, 24, () => chip(ctx, W / 2, y + h + 160, s.tally.text, { size: 42, h: 88 })); }\n });\n },\n};\n\n// ---------------------------------------------------------------- bike: bins, then a 100 km commute\nfunction bin(ctx, x, y, w, h, lbl) {\n sheet(ctx, x, y + 40, w, h - 40, 26, { elev: .9 });\n ctx.beginPath(); ctx.roundRect(x - 14, y, w + 28, 46, 16); ctx.fillStyle = C.ink; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + w / 2 - 34, y - 18, 68, 26, 10); ctx.strokeStyle = C.ink; ctx.lineWidth = 7; ctx.stroke();\n ctx.strokeStyle = C.dim; ctx.lineWidth = 8; ctx.lineCap = 'round';\n for (let i = 1; i \u003c 4; i++) { const lx = x + w * i / 4; ctx.beginPath(); ctx.moveTo(lx, y + 90); ctx.lineTo(lx, y + h - 40); ctx.stroke(); }\n text(ctx, lbl, x + w / 2, y + h + 64, { size: 40, weight: 700, align: 'center', color: C.ink2 });\n}\nfunction bikeIcon(ctx, x, y, s, col, rot) { // (x, y) = ground contact centre\n ctx.save(); ctx.translate(x, y); ctx.rotate(rot); ctx.scale(s, s); ctx.strokeStyle = col; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n const r = 26;\n for (const wx of [-38, 38]) { ctx.beginPath(); ctx.arc(wx, -r, r, 0, TAU); ctx.stroke(); }\n ctx.beginPath(); ctx.moveTo(-38, -r); ctx.lineTo(-8, -r); ctx.lineTo(18, -62); ctx.lineTo(-18, -62); ctx.lineTo(-8, -r); ctx.moveTo(-18, -62); ctx.lineTo(-38, -r); ctx.moveTo(18, -62); ctx.lineTo(38, -r); ctx.moveTo(18, -62); ctx.lineTo(14, -78); ctx.lineTo(26, -80); ctx.moveTo(-20, -68); ctx.lineTo(-10, -68); ctx.stroke();\n ctx.restore();\n}\nfunction house(ctx, x, y, col) { ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 6; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(x - 44, y); ctx.lineTo(x - 44, y - 56); ctx.lineTo(x, y - 94); ctx.lineTo(x + 44, y - 56); ctx.lineTo(x + 44, y); ctx.closePath(); ctx.stroke(); ctx.strokeRect(x - 12, y - 36, 24, 36); ctx.restore(); }\nfunction office(ctx, x, y, col) {\n ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = 6; ctx.strokeRect(x - 40, y - 130, 80, 130);\n ctx.fillStyle = col; for (let r = 0; r \u003c 4; r++) for (let c = 0; c \u003c 2; c++) ctx.fillRect(x - 24 + c * 30, y - 112 + r * 26, 16, 12); ctx.restore();\n}\n// {from, to, bins: {from, to, labels, strikeAt}, route: {from, countFrom, countTo, km, header}, sun: {from, to}}\nconst bike = {\n draw(R, s, t) {\n const { ctx } = R;\n env(ctx, s, t, () => {\n const b = s.bins;\n if (b) {\n const pin = S(t - b.from + (s.pre || 0), 12), out = b.to != null ? S(t - b.to, 14) : 0;\n alpha(ctx, 1 - out, () => {\n ctx.translate(0, -40 * out);\n const bw = 220, bh = 300, gap = 70, x0 = W / 2 - (bw * 3 + gap * 2) / 2, y = 470;\n (b.labels || ['Verre', 'Papier', 'Plastique']).forEach((l, i) => rise(ctx, S(t - b.from + (s.pre || 0) - i * .08, 12), 60, () => bin(ctx, x0 + i * (bw + gap), y, bw, bh, l)));\n void pin;\n const st = E.out(seg(t, b.strikeAt, b.strikeAt + .35));\n if (st > 0) {\n ctx.save(); ctx.strokeStyle = C.blue; ctx.lineWidth = 16; ctx.lineCap = 'round';\n const ax = x0 - 30, ay = y + bh + 40, bx = x0 + bw * 3 + gap * 2 + 30, by2 = y - 60;\n ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(lerp(ax, bx, st), lerp(ay, by2, st)); ctx.stroke(); ctx.restore();\n }\n });\n }\n const r = s.route;\n if (r && t > r.from - .6) {\n const pin = S(t - r.from, 12);\n const pts = [[150, 1000], [330, 1000], [420, 760], [620, 860], [760, 620], [880, 650]];\n const path = u => { // catmull-rom through pts, u 0..1\n const n = pts.length - 1, f = clamp(u) * n, i = Math.min(n - 1, Math.floor(f)), k = f - i;\n const p0 = pts[Math.max(0, i - 1)], p1 = pts[i], p2 = pts[i + 1], p3 = pts[Math.min(n, i + 2)];\n const cr = (a, b2, c, d) => .5 * ((2 * b2) + (-a + c) * k + (2 * a - 5 * b2 + 4 * c - d) * k * k + (-a + 3 * b2 - 3 * c + d) * k * k * k);\n return [cr(p0[0], p1[0], p2[0], p3[0]), cr(p0[1], p1[1], p2[1], p3[1])];\n };\n const prog = E.io(seg(t, r.countFrom, r.countTo));\n rise(ctx, pin, 50, () => {\n // sun crossing the sky (a day passes)\n if (s.sun) {\n const q = seg(t, s.sun.from, s.sun.to), a = Math.PI * (1 - q), sx = W / 2 + Math.cos(a) * 330, sy = 760 - Math.sin(a) * 290;\n alpha(ctx, P(t, s.sun.from - .3, 10) * (1 - S(t - s.sun.to, 10)), () => {\n ctx.strokeStyle = C.line; ctx.lineWidth = 3; ctx.setLineDash([4, 14]); ctx.beginPath(); ctx.ellipse(W / 2, 760, 330, 290, 0, Math.PI, TAU); ctx.stroke(); ctx.setLineDash([]);\n ctx.beginPath(); ctx.arc(sx, sy, 38, 0, TAU); ctx.fillStyle = C.sheet; ctx.fill(); ctx.strokeStyle = C.ink; ctx.lineWidth = 6; ctx.stroke();\n ctx.lineCap = 'round'; for (let i = 0; i \u003c 8; i++) { const an = i * TAU / 8; ctx.beginPath(); ctx.moveTo(sx + Math.cos(an) * 54, sy + Math.sin(an) * 54); ctx.lineTo(sx + Math.cos(an) * 70, sy + Math.sin(an) * 70); ctx.stroke(); }\n });\n }\n // road\n ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.beginPath(); for (let i = 0; i \u003c= 80; i++) { const [x, y] = path(i / 80); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); }\n ctx.strokeStyle = C.dim; ctx.lineWidth = 22; ctx.stroke();\n ctx.setLineDash([2, 22]); ctx.strokeStyle = C.sheet; ctx.lineWidth = 6; ctx.stroke(); ctx.setLineDash([]);\n if (prog > 0) { ctx.beginPath(); for (let i = 0; i \u003c= 80 * prog; i++) { const [x, y] = path(i / 80); i ? ctx.lineTo(x, y) : ctx.moveTo(x, y); } ctx.strokeStyle = C.blue; ctx.lineWidth = 10; ctx.stroke(); }\n house(ctx, 110, 1000, C.ink); office(ctx, 975, 640, C.ink);\n const [bx, byy] = path(prog), [nx, ny] = path(Math.min(1, prog + .01));\n bikeIcon(ctx, bx, byy - 12, 1.1, C.ink, Math.atan2(ny - byy, nx - bx) * .6);\n // km counter\n const km = Math.round((r.km ?? 100) * prog);\n text(ctx, String(km), W / 2 + 40, 330, { size: 150, weight: 800, ls: -6, align: 'right', color: C.ink });\n text(ctx, 'km', W / 2 + 64, 330, { size: 150, weight: 800, ls: -6, color: C.blue });\n });\n }\n });\n },\n};\n\n// ---------------------------------------------------------------- equation: CQFD, IA = climat x10\nfunction thermo(ctx, cx, cy, s, fill) {\n ctx.save(); ctx.translate(cx, cy); ctx.scale(s, s); ctx.lineWidth = 8; ctx.strokeStyle = C.ink; ctx.lineCap = 'round';\n ctx.beginPath(); ctx.arc(0, 60, 34, -Math.PI * .32, Math.PI * 1.32); ctx.lineTo(-18, -80); ctx.arc(0, -80, 18, Math.PI, 0); ctx.closePath(); ctx.stroke();\n ctx.beginPath(); ctx.arc(0, 60, 22, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n const top = lerp(40, -84, fill); ctx.beginPath(); ctx.roundRect(-8, top, 16, 60 - top, 8); ctx.fill();\n for (let i = 0; i \u003c 4; i++) { ctx.beginPath(); ctx.moveTo(26, -70 + i * 26); ctx.lineTo(40, -70 + i * 26); ctx.lineWidth = 5; ctx.stroke(); }\n ctx.restore();\n}\n// {from, to, cqfd: {at}, ia: {at}, climate: {at, fillFrom, fillTo}, boost: {at, text}, end: {at, text}}\nconst equation = {\n draw(R, s, t) {\n const { ctx } = R, size = 290, y = 540, xl = 120, xr = W - 120 - size;\n env(ctx, s, t, () => {\n if (s.cqfd) stamp(ctx, W / 2, 320, 'CQFD', P(t, s.cqfd.at, 11), { size: 96, rot: -.05, solid: true });\n const pa = P(t, s.ia?.at, 12), pc = P(t, s.climate?.at, 12);\n if (pa > .002) rise(ctx, pa, 60, () => {\n sheet(ctx, xl, y, size, size, 62, { elev: 1.2 });\n text(ctx, 'IA', xl + size / 2, midBase(y + size / 2, 150), { size: 150, weight: 800, ls: -6, align: 'center', color: C.blue });\n text(ctx, \"L'IA\", xl + size / 2, y + size + 70, { size: 44, weight: 800, align: 'center', color: C.ink });\n });\n const eq = P(t, s.climate?.at != null ? s.climate.at - .1 : null, 13);\n if (eq > .002) rise(ctx, eq, 30, () => text(ctx, '=', W / 2, midBase(y + size / 2, 140), { size: 140, weight: 800, align: 'center', color: C.ink3 }));\n if (pc > .002) rise(ctx, pc, 60, () => {\n sheet(ctx, xr, y, size, size, 62, { elev: 1.2 });\n thermo(ctx, xr + size / 2 - 8, y + size / 2 + 6, 1.05, E.io(seg(t, s.climate.fillFrom ?? s.climate.at, s.climate.fillTo ?? s.climate.at + 1)));\n text(ctx, 'Le climat', xr + size / 2, y + size + 70, { size: 44, weight: 800, align: 'center', color: C.ink });\n });\n if (s.boost) { const q = P(t, s.boost.at, 12); if (q > .002) rise(ctx, q, 30, () => chip(ctx, xr + size + 16, y - 10, s.boost.text || '×10', { align: 'right', size: 58, h: 108, padX: 34, fill: C.blue, color: C.white, border: false, scale: .9 + .1 * q })); }\n if (s.end) stamp(ctx, W / 2, 1080, s.end.text, P(t, s.end.at, 11), { size: 70, rot: .05, solid: true });\n });\n },\n};\n\n// ---------------------------------------------------------------- crowd: GPT -> Mistral, then the pyramid\nconst OPENAI = typeof Path2D !== 'undefined' ? new Path2D('M252.794 108.802C289.191 99.0484 326.265 110.305 351.148 135.135C385.113 126.072 422.85 134.862 449.492 161.505C476.136 188.149 484.925 225.888 475.862 259.85V259.854C500.696 284.735 511.95 321.81 502.198 358.207C492.447 394.602 464.161 421.084 430.215 430.217C421.083 464.162 394.603 492.448 358.206 502.199C321.812 511.951 284.734 500.693 259.852 475.864C225.887 484.927 188.15 476.137 161.507 449.495C134.864 422.851 126.073 385.111 135.136 351.149C110.304 326.266 99.0496 289.192 108.801 252.795C118.552 216.4 146.84 189.918 180.784 180.785C189.917 146.841 216.396 118.553 252.794 108.802ZM374.292 407.145C374.292 411.271 372.092 415.086 368.517 417.148L283.723 466.102C302.487 480.585 327.555 486.459 352.217 479.852C386.997 470.532 410.068 439.312 410.555 405.006V317.717C410.555 315.08 409.125 312.621 406.843 311.303L374.292 292.509V407.145ZM251.868 415.897C248.296 417.959 243.893 417.959 240.317 415.897L155.526 366.942C152.366 390.436 159.811 415.08 177.866 433.136H177.863C203.325 458.594 241.896 462.962 271.85 446.232L347.449 402.586C349.735 401.268 351.148 398.8 351.148 396.163V358.579L251.868 415.897ZM368.602 220.628C366.319 219.309 363.474 219.318 361.191 220.637L328.641 239.431L427.921 296.749C431.496 298.811 433.697 302.627 433.697 306.752V404.661C455.622 395.654 473.244 376.881 479.851 352.218C489.169 317.442 473.668 281.85 444.201 264.274L368.602 220.628ZM177.303 206.34C155.377 215.348 137.756 234.122 131.148 258.783C121.832 293.561 137.331 329.153 166.799 346.727L242.398 390.373C244.68 391.692 247.525 391.684 249.807 390.366L282.357 371.572L183.078 314.253C179.504 312.189 177.303 308.375 177.303 304.251V206.34ZM259.849 279.145V331.858L305.5 358.213L351.15 331.858V279.145L305.5 252.789L259.849 279.145ZM327.276 144.9C308.512 130.418 283.445 124.543 258.782 131.15C224.002 140.471 200.931 171.691 200.445 205.995V293.286C200.445 295.923 201.875 298.381 204.158 299.7L236.707 318.493V203.856C236.707 199.731 238.909 195.916 242.483 193.853L327.276 144.9ZM433.137 177.867C407.675 152.407 369.103 148.038 339.149 164.769L263.55 208.415C261.265 209.734 259.852 212.202 259.852 214.838V252.423L359.132 195.105C362.703 193.041 367.108 193.041 370.682 195.105L455.473 244.06C458.635 220.567 451.189 195.922 433.135 177.867H433.137Z') : null;\nfunction openaiLogo(ctx, cx, cy, size, col) { ctx.save(); const k = size / 420; ctx.translate(cx - 305.5 * k, cy - 305.5 * k); ctx.scale(k, k); ctx.fillStyle = col; ctx.fill(OPENAI, 'evenodd'); ctx.restore(); }\n// Mistral \"M\" in pixel blocks (5 x 5 grid of 46.5 px cells on a 256 x 233 art board), front face ink, shadow ink3\nconst MISTRAL = [[0, 0], [4, 0], [0, 1], [1, 1], [3, 1], [4, 1], [0, 2], [2, 2], [4, 2], [0, 3], [4, 3], [0, 4], [4, 4]];\nfunction mistralLogo(ctx, cx, cy, size, col, col2) {\n const c = size / 5.5, x0 = cx - size / 2, y0 = cy - c * 2.5;\n for (const [gx, gy] of MISTRAL) { ctx.fillStyle = col2; ctx.fillRect(x0 + gx * c, y0 + gy * c, c, c); ctx.fillStyle = col; ctx.fillRect(x0 + gx * c + c * .5, y0 + gy * c, c, c); }\n}\nfunction logoTile(ctx, x, y, size, kind, act, lbl) {\n sheet(ctx, x, y, size, size, 58, { elev: .5 + .7 * act });\n if (act > .01) alpha(ctx, act, () => { ctx.beginPath(); ctx.roundRect(x - 9, y - 9, size + 18, size + 18, 66); ctx.strokeStyle = C.blue; ctx.lineWidth = 6; ctx.stroke(); });\n const col = mix(C.ink3, C.ink, .35 + .65 * act);\n if (kind === 'openai') openaiLogo(ctx, x + size / 2, y + size / 2, size * .56, col);\n else mistralLogo(ctx, x + size / 2, y + size / 2, size * .5, col, mix(C.dim, C.ink3, act));\n text(ctx, lbl, x + size / 2, y + size + 66, { size: 46, weight: 800, ls: -.8, align: 'center', color: col });\n}\n// {from, to, header: {text, at}, tilesAt, leaveAt, flowAt, pyrAt, lowAt, eliteAt, samAt, bestAt, n: 40, elite: 4}\nconst crowd = {\n draw(R, s, t) {\n const { ctx } = R, n = s.n || 40, ne = s.elite || 4, size = 250, ty = 420, xl = 150, xr = W - 150 - size;\n env(ctx, s, t, pin => {\n const pyr = S(t - s.pyrAt, 7), tilesOut = S(t - s.pyrAt, 12);\n // phase A: tiles + header\n alpha(ctx, 1 - tilesOut, () => {\n ctx.translate(0, -60 * tilesOut);\n if (s.header) rise(ctx, P(t, s.header.at - (s.pre || 0), 12), 40, () => text(ctx, s.header.text, W / 2, 320, { size: 64, weight: 800, ls: -1.5, align: 'center' }));\n const flow = S(t - s.flowAt, 8), leave = P(t, s.leaveAt, 10);\n rise(ctx, P(t, s.tilesAt, 12), 50, () => {\n logoTile(ctx, xl, ty, size, 'openai', 1 - flow, 'GPT');\n logoTile(ctx, xr, ty, size, 'mistral', flow, 'Mistral');\n // arrow between the tiles\n const ax0 = xl + size + 40, ax1 = xr - 40, ay = ty + size / 2, g = E.out(seg(t, s.leaveAt, s.leaveAt + .5));\n if (g > 0) { ctx.strokeStyle = C.ink; ctx.lineWidth = 7; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; const xe = lerp(ax0, ax1, g); ctx.beginPath(); ctx.moveTo(ax0, ay); ctx.lineTo(xe, ay); ctx.moveTo(xe - 22, ay - 22); ctx.lineTo(xe, ay); ctx.lineTo(xe - 22, ay + 22); ctx.stroke(); }\n void leave;\n });\n });\n // pyramid frame (phase B)\n const apex = [W / 2, 430], baseY = 1150, baseW = 880, cutY = 690;\n const fr = E.out(seg(t, s.pyrAt + .1, s.pyrAt + .9));\n if (fr > 0) {\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 5; ctx.lineJoin = 'round';\n ctx.beginPath(); ctx.moveTo(W / 2 - baseW / 2, baseY); ctx.lineTo(apex[0], apex[1]); ctx.lineTo(W / 2 + baseW / 2, baseY); ctx.closePath();\n ctx.setLineDash([2400 * fr, 2400]); ctx.stroke(); ctx.setLineDash([]);\n const half = (cutY - apex[1]) / (baseY - apex[1]) * baseW / 2;\n alpha(ctx, fr, () => { ctx.setLineDash([10, 12]); ctx.lineWidth = 3; ctx.strokeStyle = C.ink3; ctx.beginPath(); ctx.moveTo(W / 2 - half, cutY); ctx.lineTo(W / 2 + half, cutY); ctx.stroke(); ctx.setLineDash([]); });\n ctx.restore();\n const ql = P(t, s.lowAt, 12);\n if (ql > .002) rise(ctx, ql, 24, () => { mistralLogo(ctx, W / 2 - 150, 1070, 58, C.ink, C.ink3); text(ctx, '& co', W / 2 - 100, midBase(1070, 44), { size: 44, weight: 800, color: C.ink2 }); });\n const qb = P(t, s.bestAt, 12);\n if (qb > .002) rise(ctx, qb, 24, () => { sheet(ctx, W / 2 - 85, 215, 170, 170, 44, { elev: 1.1 }); openaiLogo(ctx, W / 2, 300, 108, C.ink); });\n }\n // the crowd\n const cols = 8, r = 17, gap = 44;\n for (let i = 0; i \u003c n; i++) {\n const isE = i >= n - ne, j = isE ? i - (n - ne) : i;\n const gA = [xl + size / 2 + ((i % cols) - (cols - 1) / 2) * gap * .78, ty + size + 150 + Math.floor(i / cols) * gap * .9];\n const k = n - ne, colsB = 8, gB = [xr + size / 2 + ((j % colsB) - (colsB - 1) / 2) * gap * .78, ty + size + 150 + Math.floor(j / colsB) * gap * .9];\n // pyramid slots: the crowd fills the lower trapezoid, the elite sits near the apex\n let gC;\n if (isE) gC = [W / 2 + (j - (ne - 1) / 2) * 50, 610];\n else {\n const rowsC = [14, 12, 10], ri = j \u003c rowsC[0] ? 0 : j \u003c rowsC[0] + rowsC[1] ? 1 : 2, ci = j - rowsC.slice(0, ri).reduce((a, b) => a + b, 0), cnt = Math.min(rowsC[ri], k - rowsC.slice(0, ri).reduce((a, b) => a + b, 0));\n gC = [W / 2 + (ci - (cnt - 1) / 2) * 54, 1100 - ri * 58 - 70];\n }\n const f = isE ? 0 : S(t - s.flowAt - j * .025, 9), p = pyr > 0 ? S(t - s.pyrAt - .2 - (isE ? .3 : j * .012), 8) : 0;\n let x = lerp(gA[0], gB[0], f), y = lerp(gA[1], gB[1], f) - Math.sin(f * Math.PI) * 90;\n x = lerp(x, gC[0], p); y = lerp(y, gC[1], p);\n const appear = P(t, s.tilesAt + .15 + i * .012, 12); if (appear \u003c= .002) continue;\n const el = isE ? P(t, s.eliteAt, 12) : 0;\n alpha(ctx, Math.min(1, appear * 1.4) * (1 - tilesOut * 0) , () => {\n ctx.beginPath(); ctx.arc(x, y + (1 - appear) * 30, r * (isE ? 1 + .25 * el : 1), 0, TAU); ctx.fillStyle = isE ? mix(C.ink2, C.blue, el) : mix(C.ink2, C.ink3, p * .4); ctx.fill();\n });\n }\n // elite ring + labels\n const qe = P(t, s.eliteAt, 12);\n if (qe > .002) alpha(ctx, qe, () => { ctx.beginPath(); ctx.roundRect(W / 2 - 120, 610 - 44, 240, 88, 44); ctx.strokeStyle = C.blue; ctx.lineWidth = 5; ctx.stroke(); });\n const qs = P(t, s.samAt, 12);\n if (qs > .002) rise(ctx, qs, 20, () => { ctx.strokeStyle = C.ink; ctx.lineWidth = 4; ctx.beginPath(); ctx.moveTo(W / 2 + 124, 610); ctx.lineTo(W / 2 + 190, 610); ctx.stroke(); chip(ctx, W / 2 + 196, 610, 'Sam Altman', { align: 'left', size: 42, h: 86, fill: C.ink, color: C.white, border: false }); });\n void pin;\n });\n },\n};\n\n// ---------------------------------------------------------------- cheque\n// {rect: [x, y, w], logo: 'assets/logos/x.png', payer, city, no, date, payee, words, memo, amount, currency: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

,\n// writeAt (payee + words + signature written), countFrom, countTo (amount 0 -> amount), zeroAt (rolls back to 0),\n// stampAt, stamp: 'SANS PROVISION'}. Height is fixed (560).\nconst HAND = '\"Bradley Hand\", \"Snell Roundhand\", cursive';\nconst money = (v, cur) => Math.round(v).toString().replace(/\\B(?=(\\d{3})+(?!\\d))/g, ' ') + ' ' + cur;\nfunction handwrite(ctx, str, x, y, size, p, color) { // revealed left to right like a pen\n if (p \u003c= .002) return; const w = measure(ctx, str, size, 700, 0, HAND) + 20;\n ctx.save(); ctx.beginPath(); ctx.rect(x - 10, y - size * 1.3, w * p, size * 1.9); ctx.clip();\n text(ctx, str, x, y, { size, weight: 700, fam: HAND, color }); ctx.restore();\n}\nfunction scribble(ctx, x, y, w, p, color) { // abstract signature, drawn progressively\n if (p \u003c= .002) return; const n = 70; ctx.save(); ctx.beginPath();\n for (let i = 0; i \u003c= n * p; i++) { const u = i / n, xx = x + u * w + Math.sin(u * TAU * 4.3) * 20, yy = y - Math.sin(u * TAU * 2.1 + 1) * 22 * (1 - u * .5) - Math.cos(u * TAU * 6.4) * 9; i ? ctx.lineTo(xx, yy) : ctx.moveTo(xx, yy); }\n ctx.strokeStyle = color; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.stroke(); ctx.restore();\n}\nconst cheque = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, w] = s.rect || [60, 300, W - 120], h = 560, pad = 48, cur = s.currency || '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

;\n env(ctx, s, t, p => rise(ctx, p, 90, () => {\n sheet(ctx, x, y, w, h, 34, { elev: 1.4 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 34); ctx.clip(); // guilloche + blue edge band\n ctx.strokeStyle = 'rgba(37,142,255,.075)'; ctx.lineWidth = 2;\n for (let k = 0; k \u003c 16; k++) { ctx.beginPath(); for (let i = 0; i \u003c= 80; i++) { const u = i / 80, xx = x + u * w, yy = y + 20 + k * 34 + Math.sin(u * TAU * 2.5 + k * .55) * 16; i ? ctx.lineTo(xx, yy) : ctx.moveTo(xx, yy); } ctx.stroke(); }\n ctx.fillStyle = C.blue; ctx.fillRect(x, y, 16, h); ctx.restore();\n const lx = x + pad + 8;\n // header: logo, payer, number, date\n const im = s.logo ? R.img('/p/' + s.logo) : null;\n if (im) { ctx.save(); ctx.beginPath(); ctx.roundRect(lx, y + 40, 84, 84, 20); ctx.clip(); ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, lx, y + 40, 84, 84); ctx.restore(); }\n const nx = lx + (im ? 84 + 24 : 0);\n text(ctx, s.payer || '', nx, y + 82, { size: 42, weight: 800, ls: -.8 });\n if (s.city) text(ctx, s.city, nx, y + 122, { size: 34, weight: 500, color: C.ink3 });\n if (s.no) text(ctx, s.no, x + w - pad, y + 82, { size: 34, weight: 600, color: C.ink3, align: 'right' });\n if (s.date) text(ctx, s.date, x + w - pad, y + 122, { size: 34, weight: 600, color: C.ink2, align: 'right' });\n ctx.fillStyle = C.line; ctx.fillRect(lx, y + 164, w - pad * 2 - 8, 2);\n // payee\n const pw = E.io(seg(t, s.writeAt ?? 1e9, (s.writeAt ?? 1e9) + .8));\n text(ctx, \"Payez à l'ordre de\", lx, y + 226, { size: 34, weight: 500, color: C.ink3 });\n ctx.fillStyle = C.ink3; ctx.fillRect(lx, y + 312, w - pad * 2 - 8, 2);\n handwrite(ctx, s.payee || '', lx + 10, y + 298, 84, pw, C.ink);\n // amount in words (struck when the amount rolls back) + amount box\n const zw = s.zeroAt != null ? E.io(seg(t, s.zeroAt, s.zeroAt + .45)) : 0;\n text(ctx, 'La somme de', lx, y + 376, { size: 34, weight: 500, color: C.ink3 });\n ctx.fillStyle = C.ink3; ctx.fillRect(lx, y + 448, 470, 2);\n const ww = E.io(seg(t, (s.writeAt ?? 1e9) + .5, (s.writeAt ?? 1e9) + 1.3));\n handwrite(ctx, s.words || '', lx + 6, y + 436, 50, ww, mix(C.ink, C.ink3, zw));\n if (zw > .002 && s.words) { const sw = measure(ctx, s.words, 50, 700, 0, HAND); ctx.fillStyle = C.blue; ctx.fillRect(lx, y + 420, (sw + 16) * zw, 5); }\n const bw = 360, bx = x + w - pad - bw, by = y + 352, bh = 116;\n ctx.beginPath(); ctx.roundRect(bx, by, bw, bh, 22); ctx.fillStyle = C.tint; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = C.blue; ctx.stroke();\n const up = E.out(seg(t, s.countFrom ?? 1e9, s.countTo ?? 1e9)), v = (s.amount || 0) * up * (1 - zw);\n const vs = v >= 1e5 ? Math.round(v / 1e4) * 1e4 : Math.round(v / 10) * 10;\n text(ctx, money(vs, cur), bx + bw / 2, midBase(by + bh / 2, 58), { size: 58, weight: 800, ls: -1.6, align: 'center', color: mix(C.blue, C.ink3, zw) });\n // memo + signature\n if (s.memo) text(ctx, s.memo, lx, y + 516, { size: 34, weight: 500, color: C.ink2 });\n ctx.fillStyle = C.ink3; ctx.fillRect(x + w - pad - 260, y + 520, 260, 2);\n scribble(ctx, x + w - pad - 236, y + 498, 210, E.io(seg(t, (s.writeAt ?? 1e9) + .9, (s.writeAt ?? 1e9) + 1.7)), C.ink);\n if (s.stamp) stamp(ctx, x + w / 2, y + h / 2 + 10, s.stamp, P(t, s.stampAt, 11), { size: 74, rot: -.09, solid: true });\n }));\n },\n};\n\nexport const STORY = { foilHat, stampOver, sea, giec, bike, equation, crowd, cheque };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":40030,"content_sha256":"f99e6aac952bd7de0874ac5ae62a37b570b7a9870e55ea8e952f79d200b13f7c"},{"filename":"engine/lib/scenes.js","content":"// Scene library. A scene is {type, from, to, layer?: 'stage' | 'over', ...params} in spec.scenes (times already\n// mapped to SHORT time). Stage scenes live on the paper above the seam; 'over' scenes float above everything.\n// R = runtime handle: {ctx, t, seam, spec, tl, frame(mediaId, clipT) -> bitmap|null, cam -> bitmap}.\nimport { C, D, W, H, clamp, lerp, seg, E, S, leave, vis, press, track, sheet, shadow, text, measure, midBase, wrap, chip, cursor, cover, checkIcon, sheetFill, hash, noise1, zoomLayer, zoomAt } from './kit.js';\nimport { typing } from './timeline.js';\nimport { STORY } from './scenes-story.js';\nimport { LIFE } from './scenes-life.js';\nimport { META } from './scenes-meta.js';\nimport { FACE } from './scenes-faceless.js';\nimport { MASTER } from './scenes-master.js';\nimport { CRASH } from './scenes-crash.js';\nimport { CLEAN } from './scenes-clean.js';\n\nconst TAU = Math.PI * 2;\nconst hex = c => [1, 3, 5].map(i => parseInt(c.slice(i, i + 2), 16));\nexport const mix = (a, b, p) => { const x = hex(a), y = hex(b); return `rgb(${x.map((v, i) => Math.round(lerp(v, y[i], clamp(p)))).join(',')})`; };\nfunction alpha(ctx, a, fn) { if (a \u003c= .002) return; ctx.save(); ctx.globalAlpha *= a; fn(); ctx.restore(); }\n// Rise-in: p 0..1 from a spring. Content slides up `dist` px and fades in. No overshoot, ever.\nfunction rise(ctx, p, dist, fn, dx = 0) { alpha(ctx, Math.min(1, p * 1.35), () => { ctx.translate(dx * (1 - p), dist * (1 - p)); fn(); }); }\n// Real logos: a `logo` that is a project image path ('assets/logos/x.png') is drawn as an app icon (contain, rounded clip).\nexport const isImgLogo = v => typeof v === 'string' && /\\.(png|jpe?g|webp)$/i.test(v);\nfunction logoImg(R, ctx, src, x, y, size, r = size * .22, o = {}) {\n const im = R.img('/p/' + src); if (!im) return;\n const k = Math.min(size / im.width, size / im.height), w = im.width * k, h = im.height * k;\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, size, size, r); ctx.clip();\n if (o.gray) ctx.filter = `grayscale(${o.gray})`;\n ctx.imageSmoothingQuality = 'high'; ctx.drawImage(im, x + (size - w) / 2, y + (size - h) / 2, w, h); ctx.restore();\n}\n// Scene-level envelope: enter spring, exit fade + small lift.\n// _gate (linkScenes): right after another scene's exit, the enter waits until the old one is mostly gone.\n// push: 0.04 = a slow push-in over the whole scene (scale 1 -> 1.04 from `from` to `to`, sine eased), around pushAt\n// (default the stage centre; give output px for an 'over' scene). Long-form: keeps a held card alive without a cut.\nfunction env(ctx, s, t, fn) {\n const out = s.to == null ? 0 : s.hard ? +(t >= s.to) : leave(t - s.to), at = Math.max(s.from - (s.pre || 0), s._gate ?? -1e9); // pre: enter early (hook on frame 1)\n alpha(ctx, 1 - out, () => {\n ctx.translate(0, -36 * out * out);\n if (s.push) {\n const u = Math.min(1, Math.max(0, (t - at) / Math.max(.5, (s.to ?? at + 6) - at))), k = 1 + s.push * (1 - Math.cos(Math.PI * u)) / 2;\n const [px, py] = s.pushAt || [540, 540];\n zoomAt(ctx, k, px, py);\n }\n fn(S(t - at, 12));\n });\n}\nconst clipTime = (t, from, offset = 0) => Math.max(0, offset + t - from);\n\n// ---------------------------------------------------------------- media card (\"actor\")\nexport function actorNeeds(a, t) {\n const pre = a.pre || 0;\n if (t \u003c a.from - pre - .05 || t > (a.to ?? 1e9) + .7) return [];\n return [[a.media, clipTime(t, a.from, a.offset)]];\n}\n\nfunction card(R, x, y, w, h, img, o = {}) {\n const { ctx } = R;\n if (o.stack) alpha(ctx, o.stack, () => {\n ctx.save(); ctx.translate(x + w / 2 + 10, y + h / 2 + 12); ctx.rotate(-.028); ctx.translate(-(x + w / 2), -(y + h / 2));\n sheet(ctx, x - 12, y - 12, w + 24, h + 24, 30, { elev: .7 }); ctx.restore();\n });\n sheet(ctx, x - 12, y - 12, w + 24, h + 24, 30, { elev: o.elev ?? 1.2 });\n cover(ctx, img, x, y, w, h, 20);\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 20); ctx.strokeStyle = 'rgba(20,22,27,.08)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n if (o.dim > .002) {\n ctx.save(); ctx.beginPath(); ctx.roundRect(x - 12, y - 12, w + 24, h + 24, 30); ctx.clip();\n ctx.globalCompositeOperation = 'saturation'; ctx.globalAlpha = o.dim; ctx.fillStyle = '#808080'; ctx.fillRect(x - 12, y - 12, w + 24, h + 24);\n ctx.globalCompositeOperation = 'source-over'; ctx.globalAlpha = o.dim * .5; ctx.fillStyle = C.paper; ctx.fillRect(x - 12, y - 12, w + 24, h + 24);\n ctx.restore();\n }\n}\n\n// Card label: a plain line of text above the card's sheet (never on top of the content). dot = colour or null.\nexport function cardLabel(ctx, x, y, label, dot, a = 1, dy = 0, logo = null) {\n if (a \u003c= .002) return;\n const size = 42, by = y - 12 - 26 + dy;\n alpha(ctx, a, () => {\n let tx = x - 4;\n if (logo) { logo(tx, by - size * .36 - 25, 50); tx += 66; }\n else if (dot) { ctx.beginPath(); ctx.arc(tx + 10, by - size * .36, 10, 0, TAU); ctx.fillStyle = dot; ctx.fill(); tx += 34; }\n text(ctx, label, tx, by, { size, weight: 700, ls: -.8, color: C.ink });\n });\n}\n\nfunction labelAt(list, t) { let cur = null, prev = null, at = -1e9; for (const [k, v] of list || []) if (t >= k) { prev = cur; cur = v; at = k; } return { cur, prev, at }; }\n\nexport function drawActor(R, a, t) {\n const { ctx } = R, pre = a.pre || 0;\n // hard (cuts: 'hard' style or the actor's own hard: true): the card is there in one frame and gone in one frame, no slide\n const pin = a.hard ? +(t >= a.from - pre) : S(t - (a.from - pre), 12), pout = a.to == null ? 0 : a.hard ? +(t >= a.to) : leave(t - a.to);\n if (pin \u003c= .001 || pout >= .999) return;\n const [x, y, w] = track(t, a.keys, 11), h = w * (a.ar || 9 / 16); // ar = h/w (16 / 9 for a phone-format card)\n const img = R.frame(a.media, clipTime(t, a.from, a.offset));\n const right = a.enter === 'right';\n ctx.save();\n ctx.globalAlpha *= Math.min(1, pin * 1.35) * (1 - pout);\n ctx.translate(right ? (1 - pin) * 700 : 0, (right ? 0 : (1 - pin) * 90) - pout * 50);\n const sc = .965 + .035 * pin, cx = x + w / 2, cy = y + h / 2;\n ctx.translate(cx, cy); ctx.scale(sc, sc); ctx.translate(-cx, -cy);\n const dim = a.dimAt != null ? S(t - a.dimAt, 10) : 0;\n card(R, x, y, w, h, img, { stack: a.stack ? S(t - (a.from - pre) - .12, 10) : 0, dim });\n // highlighter strokes sweeping over phrases in the card: rect = [dx, dy, w, h] from the card's top-left.\n // mark = one stroke, marks = several (a phrase that wraps = one stroke per line, staggered `at`)\n for (const mk of [].concat(a.mark || [], a.marks || [])) {\n const { at, dur = .6, rect: [dx, dy, mw, mh] } = mk, p = E.cubic(seg(t, at, at + dur));\n if (p > .002) { ctx.save(); ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = 'rgba(37,142,255,.3)'; ctx.beginPath(); ctx.roundRect(x + dx - 8, y + dy - 4, (mw + 16) * p, mh + 8, 10); ctx.fill(); ctx.restore(); }\n }\n if (a.ringAt != null) {\n const p = S(t - a.ringAt, 12);\n alpha(ctx, p, () => { ctx.beginPath(); ctx.roundRect(x - 12 - 7 * (1 - p) - 4, y - 12 - 7 * (1 - p) - 4, w + 32 + 14 * (1 - p), h + 32 + 14 * (1 - p), 34); ctx.strokeStyle = C.blue; ctx.lineWidth = 5; ctx.stroke(); });\n }\n const L = labelAt(a.labelKeys, t);\n if (L.cur) {\n const sw = S(t - L.at, 16), dot = a.dot === 'blue' ? C.blue : a.dot === 'gray' ? C.ink3 : null;\n const lg = isImgLogo(a.labelLogo) ? (lx, ly, sz) => logoImg(R, ctx, a.labelLogo, lx, ly, sz) : null; // labelLogo replaces the dot\n if (L.prev && sw \u003c 1) cardLabel(ctx, x, y, L.prev, dot, 1 - sw, -18 * sw, lg);\n cardLabel(ctx, x, y, L.cur, dot, L.prev ? sw : 1, L.prev ? 18 * (1 - sw) : 0, lg);\n }\n if (a.badge) {\n const p = S(t - a.badge.at - .1, 13);\n if (p > .002) chip(ctx, x + w + 12, y - 12 - 26 - 15, a.badge.text, { align: 'right', size: 38, h: 76, fill: C.blue, color: C.white, check: true, checkP: E.out(seg(t, a.badge.at + .15, a.badge.at + .5)), alpha: Math.min(1, p * 1.4), scale: .9 + .1 * p, border: false });\n }\n ctx.restore();\n}\n\n// ---------------------------------------------------------------- scenes\nconst stat = {\n draw(R, s, t) {\n const { ctx } = R;\n env(ctx, s, t, pin => {\n const fam = D.font || undefined, wt = D.font ? D.weight : 800; // style.display family when set\n const size = s.size || 300, ls = -size * (D.font ? .03 : .035), slot = measure(ctx, '0', size, wt, ls, fam) + ls;\n const fin = String(s.value), cnt = E.out(seg(t, s.countFrom, s.countTo)), cur = String(Math.round(s.value * cnt));\n const sw = s.suffix ? measure(ctx, s.suffix, size, wt, ls, fam) : 0, total = fin.length * slot + sw + size * .02;\n const x0 = W / 2 - total / 2, base = s.y;\n rise(ctx, pin, 70, () => {\n for (let i = 0; i \u003c fin.length; i++) { const j = i - (fin.length - cur.length); if (j >= 0) text(ctx, cur[j], x0 + i * slot + slot / 2, base, { size, weight: wt, ls, fam, align: 'center' }); }\n if (s.suffix) text(ctx, s.suffix, x0 + fin.length * slot + size * .02, base, { size, weight: wt, ls, fam, color: C.blue });\n });\n if (s.chip) { const p = S(t - s.chip.at, 13); if (p > .002) rise(ctx, p, 26, () => chip(ctx, W / 2, s.chip.y, s.chip.text, { size: 40, h: 80, fill: C.blue, color: C.white, border: false })); }\n });\n },\n};\n\nconst bars = {\n draw(R, s, t) {\n const { ctx } = R, bw = s.w || 900, x0 = (W - bw) / 2, bh = 108; // centred, so it also works on the 1920-wide format\n env(ctx, s, t, () => {\n // tags row: final layout computed up front so chips never shift when a new one lands\n const tags = s.tags || [], tw = tags.map(g => measure(ctx, g.text, 40, 700, -.3) + 60), gap = 16;\n let tx = W / 2 - (tw.reduce((a, b) => a + b, 0) + gap * (tags.length - 1)) / 2;\n tags.forEach((g, i) => {\n const p = S(t - g.at, 13), cx = tx + tw[i] / 2; tx += tw[i] + gap;\n if (p > .002) rise(ctx, p, 22, () => chip(ctx, cx, 250, g.text, { size: 40, h: 82, padX: 30, fill: g.blue ? C.blue : undefined, color: g.blue ? C.white : C.ink, border: !g.blue }));\n });\n const rowY = i => s.y + i * 230;\n s.rows.forEach((r, i) => {\n const p = S(t - r.at, 12), ly = rowY(i), by = ly + 30;\n rise(ctx, p, 40, () => {\n text(ctx, r.label, x0, ly, { size: 54, weight: 800, color: r.blue ? C.ink : C.ink2, ls: -.8 });\n ctx.beginPath(); ctx.roundRect(x0, by, bw, bh, 30); ctx.fillStyle = 'rgba(40,30,10,.05)'; ctx.fill();\n ctx.strokeStyle = 'rgba(40,30,10,.06)'; ctx.lineWidth = 1.5; ctx.stroke();\n const g = S(t - r.at - .08, 6.5), bwv = Math.max(bh, bw * r.value * g);\n if (g > .002) { ctx.beginPath(); ctx.roundRect(x0, by, bwv, bh, 30); ctx.fillStyle = r.blue ? C.blue : r.dark ? C.ink3 : C.dim; ctx.fill(); }\n });\n });\n if (s.gapAt != null && s.rows.length > 1) {\n const a = s.rows[1], b = s.rows[0], gx1 = x0 + bw * a.value, gx2 = x0 + bw * b.value, by1 = rowY(1) + 30;\n const p1 = E.out(seg(t, s.gapAt, s.gapAt + .5)), p2 = E.out(seg(t, s.gapAt + .15, s.gapAt + .7)), yb = rowY(1) + 30 + bh + 50;\n alpha(ctx, p1, () => { // ghost of the missing bar: tucked under the short bar's round end, flush with the long bar's end\n ctx.save(); const clip = new Path2D(); clip.rect(0, 0, W, 2000); clip.roundRect(x0, by1, gx1 - x0, bh, 30); ctx.clip(clip, 'evenodd');\n ctx.beginPath(); ctx.roundRect(gx1 - 30, by1, gx2 - gx1 + 30, bh, 30); ctx.fillStyle = 'rgba(37,142,255,.12)'; ctx.fill();\n ctx.setLineDash([10, 9]); ctx.strokeStyle = 'rgba(37,142,255,.7)'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.setLineDash([]); ctx.restore();\n ctx.save(); ctx.setLineDash([6, 8]); ctx.strokeStyle = 'rgba(37,142,255,.7)'; ctx.lineWidth = 2.5; // guide from the long bar's end\n ctx.beginPath(); ctx.moveTo(gx2 - 1, rowY(0) + 30 + bh + 8); ctx.lineTo(gx2 - 1, by1 - 8); ctx.stroke(); ctx.restore();\n });\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 3; ctx.lineCap = 'round';\n for (const gx of [gx1, gx2]) { ctx.beginPath(); ctx.moveTo(gx, yb - 16); ctx.lineTo(gx, lerp(yb - 16, yb + 16, p2)); ctx.stroke(); }\n const mx = (gx1 + gx2) / 2, hw = (gx2 - gx1) / 2 * p2;\n if (p2 > 0) { ctx.beginPath(); ctx.moveTo(mx - hw, yb); ctx.lineTo(mx + hw, yb); ctx.stroke(); }\n ctx.restore();\n if (s.gapChip) { const p = S(t - s.gapChip.at, 13); if (p > .002) rise(ctx, p, 20, () => chip(ctx, mx, yb + 62, s.gapChip.text, { size: 36, h: 72 })); }\n }\n });\n },\n};\n\nconst tiles = {\n draw(R, s, t) {\n const { ctx } = R, n = s.items.length, size = 250, gap = 40, x0 = W / 2 - (n * size + (n - 1) * gap) / 2, y = s.y;\n env(ctx, s, t, pin => {\n if (s.header) rise(ctx, S(t - s.header.at + (s.pre || 0), 12), 40, () => text(ctx, s.header.text, W / 2, y - 70, { size: 64, weight: 800, ls: -1.5, align: 'center' }));\n s.items.forEach((it, i) => {\n const p = S(t - it.at + (s.pre || 0), 13), x = x0 + i * (size + gap);\n if (p \u003c= .002) return;\n rise(ctx, p, 50, () => {\n const st = it.strikeAt != null ? E.out(seg(t, it.strikeAt, it.strikeAt + .3)) : 0, off = it.strikeAt != null ? S(t - it.strikeAt - .15, 12) : 0;\n sheet(ctx, x, y, size, size, 58, { elev: 1.1 - .5 * off });\n if (isImgLogo(it.logo)) alpha(ctx, 1 - .55 * off, () => logoImg(R, ctx, it.logo, x + 45, y + 45, 160, 38, { gray: off }));\n else text(ctx, it.glyph, x + size / 2, midBase(y + size / 2, 116), { size: 116, weight: 800, ls: -3, align: 'center', color: mix(C.ink, C.ink3, off) });\n text(ctx, it.label, x + size / 2, y + size + 66, { size: 42, weight: 700, align: 'center', color: mix(C.ink2, C.ink3, off) });\n if (st > 0) {\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 11; ctx.lineCap = 'round';\n const ax = x - 14, ay = y + size + 14, bx = x + size + 14, by = y - 14;\n ctx.beginPath(); ctx.moveTo(ax, ay); ctx.lineTo(lerp(ax, bx, st), lerp(ay, by, st)); ctx.stroke(); ctx.restore();\n }\n });\n });\n });\n },\n};\n\nconst title = {\n draw(R, s, t) {\n const { ctx } = R, size = s.size || 124, ls = -size * .03, lh = size * 1.12, cy = s.y ?? 470;\n env(ctx, s, t, () => {\n s.lines.forEach((ln, li) => {\n const words = ln.text.split(' '), ws = words.map(w => measure(ctx, w, size, 800, ls)), sp = measure(ctx, ' ', size, 800, ls);\n let x = W / 2 - (ws.reduce((a, b) => a + b, 0) + sp * (words.length - 1)) / 2;\n const base = midBase(cy - (s.lines.length - 1) * lh / 2 + li * lh, size);\n words.forEach((w, i) => {\n const p = S(t - ln.at + (s.pre || 0) - i * .06, 13), isE = ln.emph && ln.emph.split(' ').includes(w);\n const col = isE ? (ln.emphAt != null ? mix(C.ink, C.blue, S(t - ln.emphAt, 14)) : C.blue) : C.ink, wx = x;\n if (p > .002) rise(ctx, p, 60, () => text(ctx, w, wx, base, { size, weight: 800, ls, color: col }));\n x += ws[i] + sp;\n });\n });\n });\n },\n};\n\n// ---- chat mock: new chat -> focus -> word-synced typing -> send -> working -> result card\nconst chat = {\n needs(s, t) { const r = s.result; return r && t >= r.at - .3 && t \u003c= (s.to ?? 1e9) + .7 ? [[r.media, clipTime(t, r.at, r.offset)]] : []; },\n prep(s, R) {\n if (s._p) return s._p;\n const { ctx } = R, [X, Y, Wd, Hd] = s.rect, fs = 40, lh = 54, tw = Wd - 72 - 72 - 96;\n const lines = wrap(ctx, s.prompt.text, tw, fs, 500);\n const toks = typing(s.prompt.text, R.tl.words, s.prompt.from, s.prompt.to);\n const charAt = []; let c = 0; // cumulative char count at the end of each token\n toks.forEach((k, i) => { c += k.tok.length + (i ? 1 : 0); charAt.push(c); });\n const lineStart = []; let acc = 0; lines.forEach(l => { lineStart.push(acc); acc += l.length + 1; });\n const hKeys = [[0, 1]];\n lines.forEach((l, i) => { if (i) { const ti = charAt.findIndex(v => v > lineStart[i]); if (ti >= 0) hKeys.push([toks[ti].start, i + 1]); } });\n hKeys.push([s.sendAt, 1]);\n const bfs = 36, blh = 48, bl0 = wrap(ctx, s.prompt.text, Wd - 180, bfs, 500), bl = bl0.length > 3 ? [...bl0.slice(0, 2), bl0[2].replace(/\\s*\\S*$/, '') + '…'] : bl0, bw = Math.max(...bl.map(l => measure(ctx, l, bfs, 500))) + 56, bh = bl.length * blh + 44;\n return (s._p = { X, Y, Wd, Hd, fs, lh, tw, lines, toks, charAt, lineStart, hKeys, bfs, blh, bl, bw, bh });\n },\n draw(R, s, t) {\n const { ctx } = R, P = chat.prep(s, R), { X, Y, Wd, Hd, fs, lh, lines, toks } = P;\n env(ctx, s, t, pin => rise(ctx, pin, 90, () => {\n sheet(ctx, X, Y, Wd, Hd, 40, { elev: 1.5 });\n // header\n const hy = Y + 66;\n ctx.beginPath(); ctx.arc(X + 58, hy, 11, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n text(ctx, 'Claude', X + 82, midBase(hy, 40), { size: 40, weight: 700, ls: -.5 });\n const pr = press(t, s.newChatAt, .22);\n chip(ctx, X + Wd - 30, hy, '+ Nouveau chat', { align: 'right', size: 34, h: 70, padX: 26, fill: pr > 0 ? mix('#FCFBF8', '#E6F0FD', pr) : undefined, scale: 1 - .05 * pr, elev: .3 });\n ctx.fillStyle = C.line; ctx.fillRect(X + 30, Y + 130, Wd - 60, 1.5);\n // old conversation skeleton, swept away by \"Nouveau chat\"\n const old = 1 - S(t - s.newChatAt, 16);\n alpha(ctx, old, () => {\n ctx.translate(0, -40 * (1 - old));\n const bar = (x, y, w, h, c) => { ctx.beginPath(); ctx.roundRect(x, y, w, h, h / 2); ctx.fillStyle = c; ctx.fill(); };\n bar(X + Wd - 44 - 380, Y + 176, 380, 64, C.tint);\n [700, 640, 730, 420].forEach((w, i) => bar(X + 44, Y + 290 + i * 44, w, 18, '#E8E3D8'));\n bar(X + Wd - 44 - 280, Y + 500, 280, 64, C.tint);\n [720, 600, 680].forEach((w, i) => bar(X + 44, Y + 614 + i * 44, w, 18, '#E8E3D8'));\n });\n // greeting\n const gOut = S(t - s.sendAt + .05, 30), gP = S(t - s.newChatAt - .12, 12) * (1 - gOut);\n if (gP > .002) rise(ctx, Math.min(1, S(t - s.newChatAt - .12, 12)), 30, () => alpha(ctx, 1 - gOut, () => {\n ctx.translate(0, -30 * gOut);\n text(ctx, s.greeting ?? \"Qu'est-ce qu'on crée ?\", X + Wd / 2, Y + 420, { size: 54, weight: 700, ls: -1.2, align: 'center' });\n }));\n // composer\n const nL = track(t, P.hKeys, 20), ch = 44 + nL * lh + 40, cb = Y + Hd - 34, ct = cb - ch, cx = X + 34, cw = Wd - 68;\n const focus = S(t - s.focusAt, 20), sent = t >= s.sendAt;\n shadow(ctx, cx, ct, cw, ch, 30, 10, .06, 3);\n ctx.beginPath(); ctx.roundRect(cx, ct, cw, ch, 30); ctx.fillStyle = C.white; ctx.fill();\n ctx.strokeStyle = focus > .01 && !sent ? mix('#E3DED3', '#258EFF', focus) : C.line; ctx.lineWidth = 1.5 + 1.5 * (sent ? 0 : focus); ctx.stroke();\n let n = 0; // revealed chars\n if (!sent) toks.forEach((k, i) => { const sp = i ? 1 : 0, d = Math.min(.26, Math.max(.08, k.end - k.start)); n += t >= k.start ? sp + Math.round(k.tok.length * clamp((t - k.start) / d)) : 0; });\n const tx = cx + 36, ty0 = ct + 44 + fs * .74;\n if (n === 0) text(ctx, sent ? 'Répondre…' : s.placeholder ?? 'Décris ta vidéo…', tx, ty0, { size: fs, weight: 500, color: C.ink3 });\n let cxr = tx, cyr = ty0;\n lines.forEach((l, i) => {\n const vis_ = clamp(n - P.lineStart[i], 0, l.length); if (vis_ \u003c= 0) return;\n const str = l.slice(0, vis_), yy = ty0 + i * lh;\n text(ctx, str, tx, yy, { size: fs, weight: 500, color: C.ink }); cxr = tx + measure(ctx, str, fs, 500); cyr = yy;\n });\n if (focus > .01 && !sent) alpha(ctx, focus, () => { ctx.fillStyle = C.blue; ctx.fillRect(cxr + 3, cyr - fs * .8, 3.5, fs * .98); });\n const sbx = cx + cw - 32 - 34, sby = cb - 32 - 34, has = n > 0 ? 1 : 0, sp = press(t, s.sendAt, .2);\n ctx.save(); ctx.translate(sbx, sby); ctx.scale(1 - .1 * sp, 1 - .1 * sp);\n ctx.beginPath(); ctx.arc(0, 0, 34, 0, TAU); ctx.fillStyle = has ? C.blue : C.dim; ctx.fill();\n ctx.strokeStyle = C.white; ctx.lineWidth = 5; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.beginPath(); ctx.moveTo(0, 13); ctx.lineTo(0, -13); ctx.moveTo(-11, -2); ctx.lineTo(0, -13); ctx.lineTo(11, -2); ctx.stroke(); ctx.restore();\n // sent bubble travels from the composer to the thread (FLIP-style)\n if (sent) {\n const p = S(t - s.sendAt, 11), bx = X + Wd - 44 - P.bw, by = Y + 168, sc = lerp(fs / P.bfs, 1, p);\n const fy = lerp(ct, by, p), fx = lerp(tx - 28, bx, p);\n alpha(ctx, Math.min(1, p * 4), () => {\n ctx.translate(fx, fy); ctx.scale(sc, sc);\n ctx.beginPath(); ctx.roundRect(0, 0, P.bw, P.bh, 30); ctx.fillStyle = C.tint; ctx.fill();\n P.bl.forEach((l, i) => text(ctx, l, 28, 22 + P.bfs * .76 + i * P.blh + 2, { size: P.bfs, weight: 500, color: C.ink }));\n });\n const ry = by + P.bh + 50;\n const wP = S(t - s.workFrom, 13) * (s.result ? 1 - S(t - s.result.at, 18) : 1);\n alpha(ctx, wP, () => {\n ctx.beginPath(); ctx.arc(X + 56, ry, 10, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n text(ctx, s.workText || 'Opus 5.5 crée ta vidéo', X + 80, midBase(ry, 40), { size: 40, weight: 700, color: C.ink2 });\n ctx.beginPath(); ctx.roundRect(X + 44, ry + 48, 520, 10, 5); ctx.fillStyle = C.line; ctx.fill();\n const g = E.io(seg(t, s.workFrom + .1, s.workTo));\n if (g > 0) { ctx.beginPath(); ctx.roundRect(X + 44, ry + 48, Math.max(10, 520 * g), 10, 5); ctx.fillStyle = C.blue; ctx.fill(); }\n });\n const r = s.result, rp = r ? S(t - r.at, 12) : 0;\n if (rp > .002) rise(ctx, rp, 60, () => {\n const w = Math.min(760, Wd - 112), h = w * 9 / 16, x = X + 56, y = ry + 44;\n card(R, x, y, w, h, R.frame(r.media, clipTime(t, r.at, r.offset)), { elev: 1 });\n cardLabel(ctx, x, y, r.label || 'Opus 5.5', C.blue);\n });\n }\n // pointer: springs between targets, presses on each click\n const pill = [X + Wd - 130, hy + 6], comp = [X + 360, ct + 60], send = [sbx + 6, sby + 8], rest = [X + Wd + 40, Y + Hd + 200];\n const keys = [[s.from, rest], [s.newChatAt - .6, pill], [s.focusAt - .55, comp], [s.focusAt + .5, [X + Wd - 150, Y + Hd - 260]], [s.sendAt - .6, send], [s.sendAt + .5, rest]];\n const [px, py] = track(t, keys, 9), ca = vis(t, s.from + .3, s.sendAt + .55, 10, 10);\n cursor(ctx, px, py, { alpha: ca, press: Math.max(press(t, s.newChatAt, .2), press(t, s.focusAt, .2), press(t, s.sendAt, .2)) });\n }));\n },\n};\n\n// ---- convo: a live Claude thread. The creator's lines type into blue bubbles on his own spoken words, Opus streams its\n// replies word by word on its TTS clip (spec.voices), and an image reply resolves from coarse pixels. The thread\n// scrolls up as it grows; every message keeps its final size from the moment it appears.\nlet CNOISE = null;\nconst convo = {\n needs(s, t) { return t > s.from - 1 && t \u003c (s.to ?? 1e9) + 1 ? s.msgs.filter(m => m.image && t >= m.at - .4).map(m => '/p/' + m.image) : []; },\n prep(s, R) {\n if (s._p) return s._p;\n const { ctx } = R, [X, Y, Wd, Hd] = s.rect, fs = s.size || 42, lh = Math.round(fs * 1.3), gap = s.gap ?? 30, ls = -.3;\n const head = 128, top = Y + head + 34, view = Y + Hd - 36 - top, tx = 92;\n const place = (lines, wt) => lines.flatMap((l, li) => { let x = 0; return l.split(' ').map(w => { const o = { w, x, li }; x += measure(ctx, w + ' ', fs, wt, ls); return o; }); });\n let y = 0;\n const items = s.msgs.map(m => {\n let it;\n if (m.who === 'me') {\n const padX = 34, padY = 24, lines = wrap(ctx, m.text, Math.min(740, Wd - 180) - padX * 2, fs, 600, ls);\n const bw = Math.max(...lines.map(l => measure(ctx, l, fs, 600, ls))) + padX * 2, h = lines.length * lh + padY * 2 - (lh - fs) + 4;\n it = { y, h, bw, padX, padY, ws: place(lines, 600), toks: typing(m.text, R.tl.words, m.from, m.to), t0: m.from - .12 };\n } else if (m.image) it = { y, h: m.size || 560, t0: m.at };\n else {\n const lines = wrap(ctx, m.text, Wd - tx - 60, fs, 500, ls);\n it = { y, h: lines.length * lh - (lh - fs) + 8, ws: place(lines, 500), t0: m.at };\n }\n it.m = m; y += it.h + gap; return it;\n });\n const sKeys = [[-1e9, 0]];\n for (const it of items) sKeys.push([it.t0, Math.max(0, it.y + it.h - view), 9]);\n return (s._p = { X, Y, Wd, Hd, fs, lh, ls, head, top, view, tx, items, sKeys });\n },\n draw(R, s, t) {\n const { ctx } = R, P = convo.prep(s, R), { X, Y, Wd, Hd, fs, lh, ls, head, top, tx } = P;\n env(ctx, s, t, pin => rise(ctx, pin, 90, () => {\n sheet(ctx, X, Y, Wd, Hd, 40, { elev: 1.5 });\n const hy = Y + 66;\n LOGO.claude(ctx, X + 66, hy, 26, C.ink);\n text(ctx, s.title || 'Claude', X + 108, midBase(hy, 42), { size: 42, weight: 800, ls: -.6 });\n chip(ctx, X + Wd - 32, hy, s.model || 'Opus 5.5', { align: 'right', size: 34, h: 70, padX: 26, dot: C.blue, elev: .3 });\n ctx.fillStyle = C.line; ctx.fillRect(X + 30, Y + head, Wd - 60, 1.5);\n const sc = track(t, P.sKeys, 9);\n ctx.save(); ctx.beginPath(); ctx.roundRect(X, Y + head + 2, Wd, Hd - head - 2, [0, 0, 40, 40]); ctx.clip();\n ctx.translate(0, top - sc);\n for (const it of P.items) {\n const m = it.m, p = S(t - it.t0, 13);\n if (p \u003c= .002 || it.y + it.h \u003c sc - 40 || it.y > sc + P.view + 60) continue;\n if (m.who === 'me') rise(ctx, p, 30, () => {\n const bx = X + Wd - 36 - it.bw, by = it.y;\n shadow(ctx, bx, by, it.bw, it.h, 30, 10, .08, 3);\n ctx.beginPath(); ctx.roundRect(bx, by, it.bw, it.h, [32, 32, 10, 32]); ctx.fillStyle = C.blue; ctx.fill();\n it.ws.forEach((w, k) => {\n const q = S(t - (it.toks[k]?.start ?? it.t0), 24);\n if (q > .002) alpha(ctx, Math.min(1, q * 1.3), () => text(ctx, w.w, bx + it.padX + w.x, by + it.padY + fs * .8 + w.li * lh + (1 - q) * 10, { size: fs, weight: 600, ls, color: C.white }));\n });\n });\n else if (m.image) rise(ctx, p, 40, () => {\n const x = X + tx, y = it.y, sz = it.h, img = R.img('/p/' + m.image), q = E.io(seg(t, m.genFrom, m.sharpAt));\n sheet(ctx, x - 10, y - 10, sz + 20, sz + 20, 32, { elev: 1.1 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, sz, sz, 22); ctx.clip();\n ctx.fillStyle = '#E9E5DB'; ctx.fillRect(x, y, sz, sz);\n if (img && t >= m.genFrom) {\n const px = Math.max(1, Math.round(80 * Math.pow(1 - q, 2.2)));\n if (px \u003c= 1) cover(ctx, img, x, y, sz, sz);\n else { const n = Math.ceil(sz / px), oc = new OffscreenCanvas(n, n); cover(oc.getContext('2d'), img, 0, 0, n, n); ctx.imageSmoothingEnabled = false; ctx.drawImage(oc, x, y, n * px, n * px); ctx.imageSmoothingEnabled = true; }\n if (!CNOISE) { CNOISE = new OffscreenCanvas(256, 256); const g = CNOISE.getContext('2d'), d = g.createImageData(256, 256); for (let i = 0; i \u003c d.data.length; i += 4) { const v = hash(i * .37) * 255; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; } g.putImageData(d, 0, 0); }\n const na = (1 - q) * .5;\n if (na > .01) { ctx.globalAlpha = na; ctx.globalCompositeOperation = 'overlay'; const f = Math.floor(t * 30), ox = (f * 97) % 256, oy = (f * 61) % 256; for (let yy = y - oy; yy \u003c y + sz; yy += 256) for (let xx = x - ox; xx \u003c x + sz; xx += 256) ctx.drawImage(CNOISE, xx, yy); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; }\n const fl = t >= m.sharpAt ? Math.exp(-(t - m.sharpAt) * 5) : 0;\n if (fl > .01) { ctx.fillStyle = `rgba(255,255,255,${fl * .7})`; ctx.fillRect(x, y, sz, sz); }\n }\n ctx.restore();\n const gp = vis(t, m.at + .1, m.sharpAt + .15, 14, 14);\n if (gp > .002) chip(ctx, x + 24, y + sz - 54, `${m.label || 'Dessin'} · ${Math.round(q * 100)} %`, { align: 'left', size: 34, h: 70, padX: 26, alpha: gp, elev: .5 });\n });\n else {\n alpha(ctx, Math.min(1, p * 1.4), () => LOGO.claude(ctx, X + 56, it.y + fs * .45, 20, C.ink));\n it.ws.forEach((w, k) => {\n const q = S(t - m.at - (m.offs?.[k] ?? k * .18), 22);\n if (q > .002) alpha(ctx, Math.min(1, q * 1.3), () => text(ctx, w.w, X + tx + w.x, it.y + fs * .8 + w.li * lh + (1 - q) * 10, { size: fs, weight: 500, ls, color: C.ink }));\n });\n }\n }\n ctx.restore();\n }));\n },\n};\n\n// ---- scatter: benchmark chart (e.g. intelligence vs cost). Axes grow in, points land one by one, then a\n// vertical bracket (dy) and a horizontal bracket (dx) measure the gap between a reference point and a challenger.\nconst scatter = {\n draw(R, s, t) {\n const { ctx } = R, P = s.plot || {}, px0 = P.x0 ?? 170, px1 = P.x1 ?? 960, py0 = P.y0 ?? 420, py1 = py0 + (P.h ?? 680);\n const [xa, xb] = s.xRange, [ya, yb] = s.yRange;\n const lx = v => s.xLog ? Math.log(v) : v; // xLog: log-scale cost axis, like Artificial Analysis\n const X = v => px0 + (lx(v) - lx(xa)) / (lx(xb) - lx(xa)) * (px1 - px0), Y = v => py1 - (v - ya) / (yb - ya) * (py1 - py0);\n const col = c => c === 'blue' ? C.blue : c === 'ink' ? C.ink : C.ink3;\n env(ctx, s, t, pin => {\n if (s.header) rise(ctx, S(t - s.header.at, 12), 40, () => text(ctx, s.header.text, W / 2, 240, { size: 64, weight: 800, ls: -1.8, align: 'center' }));\n if (s.sub) rise(ctx, S(t - s.sub.at, 12), 30, () => text(ctx, s.sub.text, W / 2, 300, { size: 34, weight: 600, color: C.ink2, align: 'center' }));\n // axes + grid\n const g = E.out(seg(t, s.from + .05, s.from + .6)), ga = S(t - s.from - .15, 12);\n alpha(ctx, ga, () => {\n (s.yTicks || []).forEach(v => {\n ctx.fillStyle = 'rgba(40,30,10,.07)'; ctx.fillRect(px0, Y(v) - 1, (px1 - px0) * g, 2);\n text(ctx, String(v), px0 - 22, midBase(Y(v), 34), { size: 34, weight: 600, color: C.ink3, align: 'right' });\n });\n (s.xTicks || []).forEach(v => text(ctx, (s.xPrefix || '') + v + (s.xSuffix || ''), X(v), py1 + 54, { size: 34, weight: 600, color: C.ink3, align: 'center' }));\n if (s.yLabel) text(ctx, s.yLabel, px0 - 80, py0 - 34, { size: 34, weight: 700, color: C.ink2 });\n if (s.xLabel) text(ctx, s.xLabel, px1, py1 + 118, { size: 34, weight: 700, color: C.ink2, align: 'right' });\n });\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 3; ctx.lineCap = 'round';\n ctx.beginPath(); ctx.moveTo(px0, py1); ctx.lineTo(px0, lerp(py1, py0 - 10, g)); ctx.moveTo(px0, py1); ctx.lineTo(lerp(px0, px1 + 10, g), py1); ctx.stroke(); ctx.restore();\n // brackets (behind the points)\n const pts = s.points, ref = pts[s.ref ?? 0], vs = pts[s.vs ?? 1];\n const rx = X(ref.x), ry = Y(ref.y), vx = X(vs.x), vy = Y(vs.y);\n if (s.dyAt != null) {\n const bx = Math.max(rx, vx) + 50, p0 = E.out(seg(t, s.dyAt, s.dyAt + .45)), p1 = E.out(seg(t, s.dyAt + .15, s.dyAt + .6));\n ctx.save(); ctx.setLineDash([10, 9]); ctx.strokeStyle = 'rgba(37,142,255,.6)'; ctx.lineWidth = 2.5;\n if (p0 > 0) { ctx.beginPath(); ctx.moveTo(rx + 20, ry); ctx.lineTo(lerp(rx + 20, bx + 16, p0), ry); ctx.stroke(); }\n ctx.setLineDash([]); ctx.strokeStyle = C.ink; ctx.lineWidth = 3; ctx.lineCap = 'round';\n for (const yy of [ry, vy]) if (p1 > 0) { ctx.beginPath(); ctx.moveTo(bx - 14, yy); ctx.lineTo(bx - 14 + 28 * p1, yy); ctx.stroke(); }\n const my = (ry + vy) / 2, hh = (vy - ry) / 2 * p1;\n if (p1 > 0) { ctx.beginPath(); ctx.moveTo(bx, my - hh); ctx.lineTo(bx, my + hh); ctx.stroke(); }\n ctx.restore();\n if (s.dyChip) { const p = S(t - s.dyChip.at, 13); if (p > .002) rise(ctx, p, 20, () => chip(ctx, bx + 26, my, s.dyChip.text, { align: 'left', size: 36, h: 72, padX: 24 })); }\n }\n if (s.dxAt != null) {\n const by = Math.max(ry, vy) + 90, p1 = E.out(seg(t, s.dxAt, s.dxAt + .5)), x1 = Math.min(rx, vx), x2 = Math.max(rx, vx);\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 3; ctx.lineCap = 'round';\n for (const xx of [x1, x2]) { ctx.beginPath(); ctx.moveTo(xx, by - 16); ctx.lineTo(xx, lerp(by - 16, by + 16, p1)); ctx.stroke(); }\n const mx = (x1 + x2) / 2, hw = (x2 - x1) / 2 * p1;\n if (p1 > 0) { ctx.beginPath(); ctx.moveTo(mx - hw, by); ctx.lineTo(mx + hw, by); ctx.stroke(); }\n ctx.restore();\n if (s.dxChip) { const p = S(t - s.dxChip.at, 13); if (p > .002) rise(ctx, p, 20, () => chip(ctx, mx, by + 62, s.dxChip.text, { size: 36, h: 72, padX: 24 })); }\n }\n // arrows (behind the points): a jump from one model to another, with a fixed-size delta card\n (s.arrows || []).forEach(ar => {\n const A = pts[ar.pair[0]], B = pts[ar.pair[1]], ax = X(A.x), ay = Y(A.y), bx = X(B.x), by = Y(B.y);\n const d = Math.hypot(bx - ax, by - ay), ux = (bx - ax) / d, uy = (by - ay) / d, p = E.out(seg(t, ar.at, ar.at + .55));\n if (p > .002) {\n const x0 = ax + ux * 30, y0 = ay + uy * 30, L = (d - 62) * p, ex = x0 + ux * L, ey = y0 + uy * L;\n ctx.save(); ctx.strokeStyle = C.blue; ctx.fillStyle = C.blue; ctx.lineWidth = 7; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(ex - ux * 14, ey - uy * 14); ctx.stroke();\n const hs = 30 * Math.min(1, p * 1.6);\n ctx.beginPath(); ctx.moveTo(ex, ey); ctx.lineTo(ex - ux * hs - uy * hs * .55, ey - uy * hs + ux * hs * .55); ctx.lineTo(ex - ux * hs + uy * hs * .55, ey - uy * hs - ux * hs * .55); ctx.closePath(); ctx.fill();\n ctx.restore();\n }\n if (!ar.card) return;\n const c = ar.card, pc = S(t - c.at, 13); if (pc \u003c= .002) return;\n const cw = c.w || 210, ch = c.h || 128, cx = (ax + bx) / 2 + (c.dx || 0), cy = (ay + by) / 2 + (c.dy || 0), left = c.align === 'right' ? cx - cw : cx;\n rise(ctx, pc, 20, () => {\n shadow(ctx, left, cy - ch / 2, cw, ch, 22, 24, .12, 6);\n ctx.beginPath(); ctx.roundRect(left, cy - ch / 2, cw, ch, 22); ctx.fillStyle = C.sheet; ctx.fill(); ctx.strokeStyle = C.blue; ctx.lineWidth = 3; ctx.stroke();\n text(ctx, c.text, left + cw / 2, cy - 10, { size: 40, weight: 800, ls: -1, color: C.blue, align: 'center' });\n if (c.sub) { const ps = S(t - c.sub.at, 13); if (ps > .002) rise(ctx, ps, 10, () => text(ctx, c.sub.text, left + cw / 2, cy + 40, { size: 34, weight: 700, color: C.ink2, align: 'center' })); }\n });\n });\n // points + labels (a point without label is a small grey context dot)\n pts.forEach(pt => {\n const p = S(t - pt.at, 13); if (p \u003c= .002) return;\n if (!pt.label) { alpha(ctx, Math.min(1, p * 1.4) * .6, () => { ctx.beginPath(); ctx.arc(X(pt.x), Y(pt.y), 11 * (.4 + .6 * p), 0, TAU); ctx.fillStyle = C.ink3; ctx.fill(); }); return; }\n const x = X(pt.x), y = Y(pt.y), big = pt.color !== 'gray', r = (big ? 17 : 13) * (.4 + .6 * p), c = col(pt.color);\n alpha(ctx, Math.min(1, p * 1.4), () => {\n ctx.beginPath(); ctx.arc(x, y, r + 5, 0, TAU); ctx.fillStyle = C.sheet; ctx.fill();\n ctx.beginPath(); ctx.arc(x, y, r, 0, TAU); ctx.fillStyle = c; ctx.fill();\n });\n const left = pt.side === 'left', tx = x + (left ? -34 : 34) + (pt.dx || 0), ty = y + (pt.dy || 0), al = left ? 'right' : 'left', ns = big ? 44 : 38;\n rise(ctx, S(t - pt.at - .08, 13), 16, () => {\n text(ctx, pt.label, tx, ty + (pt.sub ? -4 : ns * .36), { size: ns, weight: 800, ls: -.8, color: pt.color === 'blue' ? C.blue : big ? C.ink : C.ink2, align: al, halo: 10 });\n if (pt.sub) text(ctx, pt.sub, tx, ty + 42, { size: 34, weight: 600, color: C.ink2, align: al, halo: 10 });\n });\n });\n if (s.ringAt != null) {\n const p = S(t - s.ringAt, 12);\n alpha(ctx, p, () => { ctx.beginPath(); ctx.arc(rx, ry, 30 + 12 * (1 - p), 0, TAU); ctx.strokeStyle = C.blue; ctx.lineWidth = 4.5; ctx.stroke(); });\n }\n });\n },\n};\n\nconst chipScene = {\n draw(R, s, t) {\n const { ctx } = R;\n env(ctx, s, t, pin => rise(ctx, pin, 30, () => chip(ctx, W / 2, s.y, s.text, { size: s.size || 44, h: (s.size || 44) * 2, fill: s.blue ? C.blue : undefined, color: s.blue ? C.white : C.ink, border: !s.blue })));\n },\n};\n\nconst cta = {\n // avatar: 'assets/images/mascot-avatar.jpg' replaces the face cropped from the camera (faceless styles)\n needs(s, t) { return s.avatar && t >= s.from - 1.5 && t \u003c= (s.to ?? 1e9) + 1 ? ['/p/' + s.avatar] : []; },\n draw(R, s, t) {\n const { ctx } = R, w = 840, h = 196, x = W / 2 - w / 2 - 30, y = s.y - h / 2;\n env(ctx, s, t, pin => rise(ctx, pin, 90, () => {\n sheet(ctx, x, y, w, h, 48, { elev: 1.8 });\n const ax = x + 38 + 62, ay = y + h / 2;\n ctx.save(); ctx.beginPath(); ctx.arc(ax, ay, 62, 0, TAU); ctx.clip();\n const e = R.spec.cam?.eyes || [600, 400], av = s.avatar && R.img('/p/' + s.avatar);\n if (av) ctx.drawImage(av, ax - 62, ay - 62, 124, 124);\n else if (R.cam && !s.avatar) ctx.drawImage(R.cam, e[0] - 250, e[1] - 210, 500, 500, ax - 62, ay - 62, 124, 124); ctx.restore();\n ctx.beginPath(); ctx.arc(ax, ay, 62, 0, TAU); ctx.strokeStyle = 'rgba(20,22,27,.08)'; ctx.lineWidth = 2; ctx.stroke();\n // a long name or sub shrinks to stay clear of the button (short legacy texts keep 48 / 36)\n const room = w - 34 - (measure(ctx, s.button, 38, 700) + 64) - 190 - 14, fit = (str, size, wt, min) => Math.max(min, Math.min(size, size * room / Math.max(1, measure(ctx, str, size, wt))));\n text(ctx, s.name, x + 190, y + 86, { size: fit(s.name, 48, 800, 34), weight: 800, ls: -.8 });\n text(ctx, s.sub, x + 190, y + 138, { size: fit(s.sub, 36, 600, 26), weight: 600, color: C.ink2 });\n const d = S(t - s.clickAt, 16), pr = press(t, s.clickAt, .2);\n const w1 = measure(ctx, s.button, 38, 700) + 64, w2 = measure(ctx, s.done, 38, 700) + 64 + 40, bw = lerp(w1, w2, d), bx = x + w - 34 - bw, bh = 86;\n ctx.save(); const bcx = bx + bw / 2; ctx.translate(bcx, ay); ctx.scale(1 - .05 * pr, 1 - .05 * pr); ctx.translate(-bcx, -ay);\n ctx.beginPath(); ctx.roundRect(bx, ay - bh / 2, bw, bh, bh / 2); ctx.fillStyle = mix(C.blue, '#EDEAE2', d); ctx.fill();\n alpha(ctx, 1 - d, () => text(ctx, s.button, bcx, midBase(ay, 38), { size: 38, weight: 700, color: C.white, align: 'center' }));\n alpha(ctx, d, () => { checkIcon(ctx, bx + 32 + 14, ay, 26, C.ink, E.out(seg(t, s.clickAt + .1, s.clickAt + .45)), 4.5); text(ctx, s.done, bx + 32 + 40, midBase(ay, 38), { size: 38, weight: 700, color: C.ink }); });\n ctx.restore();\n const keys = [[s.from, [W + 60, y + h + 260]], [s.clickAt - .6, [bx + bw * .55, ay + 12]]];\n const [px, py] = track(t, keys, 9);\n cursor(ctx, px, py, { alpha: S(t - s.from - .15, 10), press: pr });\n }));\n },\n};\n\n// ---- switch: two app tiles with a toggle between them (Codex \u003c-> Claude). sideKeys [[t, 0 | 1]]: 0 = left active.\n// headerKeys [[t, text]] cross-fade above; chips [{text, at, side}] stack under their tile and dim with it.\nconst LOGO = {\n claude(ctx, cx, cy, r, col) { // starburst\n ctx.save(); ctx.strokeStyle = col; ctx.lineCap = 'round'; ctx.lineWidth = r * .2;\n for (let i = 0; i \u003c 11; i++) {\n const a = i * TAU / 11 - Math.PI / 2 + .12, l = r * (.62 + .38 * ((i * 7) % 5) / 4);\n ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * r * .14, cy + Math.sin(a) * r * .14); ctx.lineTo(cx + Math.cos(a) * l, cy + Math.sin(a) * l); ctx.stroke();\n }\n ctx.restore();\n },\n codex(ctx, cx, cy, r, col) { text(ctx, '>_', cx, midBase(cy, r * 1.1) - r * .06, { size: r * 1.1, weight: 800, align: 'center', color: col, fam: 'JetBrains Mono' }); },\n};\nfunction crossText(ctx, keys, t, x, y, o, pre = 0) {\n const { cur, prev, at } = labelAt(keys, t + pre); if (cur == null) return;\n const p = S(t - at + (prev == null ? pre : 0), 13); // pre: the first header is already on screen at frame 1\n if (prev != null && p \u003c 1) alpha(ctx, 1 - p, () => text(ctx, prev, x, y - 30 * p, o));\n alpha(ctx, Math.min(1, p * 1.35), () => text(ctx, cur, x, y + 30 * (1 - p), o));\n}\nconst switchScene = {\n draw(R, s, t) {\n const { ctx } = R, y = s.y ?? 420, size = 250, tw = 200, th = 104, gap = 60, x0 = W / 2 - (size * 2 + tw + gap * 2) / 2;\n const xs = [x0, x0 + size + gap * 2 + tw], k = clamp(track(t, s.sideKeys, s.w || 13)), cy = y + size / 2;\n env(ctx, s, t, pin => {\n if (s.headerKeys) crossText(ctx, s.headerKeys, t, W / 2, y - 80, { size: 64, weight: 800, ls: -1.5, align: 'center' }, s.pre || 0);\n rise(ctx, pin, 50, () => {\n s.tiles.forEach((it, i) => {\n const act = i ? k : 1 - k, x = xs[i];\n sheet(ctx, x, y, size, size, 58, { elev: .5 + .7 * act });\n if (act > .01) alpha(ctx, act, () => { ctx.beginPath(); ctx.roundRect(x - 9, y - 9, size + 18, size + 18, 66); ctx.strokeStyle = C.blue; ctx.lineWidth = 6; ctx.stroke(); });\n const col = mix(C.ink3, C.ink, act);\n if (isImgLogo(it.logo)) alpha(ctx, .55 + .45 * act, () => logoImg(R, ctx, it.logo, x + 45, y + 45, size - 90, 40, { gray: 1 - act }));\n else if (LOGO[it.logo]) LOGO[it.logo](ctx, x + size / 2, cy, 78, col);\n else text(ctx, it.glyph, x + size / 2, midBase(cy, 110), { size: 110, weight: 800, ls: -3, align: 'center', color: col });\n text(ctx, it.label, x + size / 2, y + size + 70, { size: 46, weight: 800, ls: -.8, align: 'center', color: mix(C.ink3, C.ink, act) });\n });\n // toggle: paper track, blue knob slides to the active side\n const tx = x0 + size + gap, ty = cy - th / 2;\n sheet(ctx, tx, ty, tw, th, th / 2, { elev: .35 });\n ctx.beginPath(); ctx.roundRect(tx + 6, ty + 6, tw - 12, th - 12, (th - 12) / 2); ctx.fillStyle = 'rgba(40,30,10,.05)'; ctx.fill();\n const kr = th / 2 - 12, kx = lerp(tx + th / 2, tx + tw - th / 2, k);\n shadow(ctx, kx - kr, cy - kr, kr * 2, kr * 2, kr, 14, .18, 4);\n ctx.beginPath(); ctx.arc(kx, cy, kr, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n });\n const n = [0, 0];\n for (const c of s.chips || []) {\n const p = S(t - c.at, 13), i = c.side ? 1 : 0, j = n[i]++; if (p \u003c= .002) continue;\n const act = i ? k : 1 - k, cw = measure(ctx, c.text, 38, 700, -.3) + 56, cx = clamp(xs[i] + size / 2, 60 + cw / 2, W - 60 - cw / 2);\n rise(ctx, p, 22, () => chip(ctx, cx, y + size + 160 + j * 96, c.text, { size: 38, h: 78, padX: 28, fill: c.blue ? C.blue : undefined, color: c.blue ? C.white : C.ink, border: !c.blue, alpha: .4 + .6 * act }));\n }\n });\n },\n};\n\n// ---- tree: a project folder in mono. lines [{text: '├── CLAUDE.md', at, link: {text: 'AGENTS.md', at}}]\n// Tree glyphs render in ink3, the name in ink, the symlink arrow + target in blue.\nconst tree = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, w] = s.rect || [70, 380, 940], size = s.size || 42, lh = size * 1.7, pad = 54;\n const h = s.lines.length * lh + pad * 2 - (lh - size);\n env(ctx, s, t, pin => rise(ctx, pin, 60, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.1 });\n const cw = measure(ctx, 'M', size, 500, 0, 'JetBrains Mono');\n s.lines.forEach((ln, i) => {\n const p = S(t - ln.at, 14); if (p \u003c= .002) return;\n const m = ln.text.match(/^([│├└─\\s]*)(.*)$/), pre = m[1], name = m[2], by = y + pad + size * .8 + i * lh;\n rise(ctx, p, 18, () => {\n const tx = x + pad;\n if (pre) text(ctx, pre, tx, by, { size, weight: 500, color: C.ink3, fam: 'JetBrains Mono' });\n text(ctx, name, tx + pre.length * cw, by, { size, weight: name.endsWith('/') ? 800 : 700, color: C.ink, fam: 'JetBrains Mono' });\n if (ln.link) {\n const q = S(t - ln.link.at, 14), lx = tx + (pre.length + name.length + 1) * cw;\n if (q > .002) rise(ctx, q, 0, () => text(ctx, '→ ' + ln.link.text, lx, by, { size, weight: 700, color: C.blue, fam: 'JetBrains Mono' }), -24);\n }\n }, 0);\n });\n }));\n },\n};\n\n// ---- snap: a finger-snap burst (over layer). {from: snap time, origin: [x, y] canvas px, r: 1}\nconst snap = {\n draw(R, s, t) {\n const { ctx } = R, [ox, oy] = s.origin || [W / 2, 700], k = s.r || 1, u = t - s.from;\n if (u \u003c 0 || u > .7) return;\n const e = E.out(seg(u, 0, .45)), a = 1 - seg(u, .12, .55);\n ctx.save(); ctx.lineCap = 'round';\n for (let i = 0; i \u003c 9; i++) {\n const an = i * TAU / 9 + .35 + (i % 2) * .12, r1 = (34 + 150 * e) * k, r2 = (60 + (i % 2 ? 190 : 250) * e) * k;\n const x1 = ox + Math.cos(an) * r1, y1 = oy + Math.sin(an) * r1, x2 = ox + Math.cos(an) * r2, y2 = oy + Math.sin(an) * r2;\n ctx.globalAlpha = a; ctx.strokeStyle = 'rgba(20,16,8,.25)'; ctx.lineWidth = 14; ctx.beginPath(); ctx.moveTo(x1, y1 + 3); ctx.lineTo(x2, y2 + 3); ctx.stroke();\n ctx.strokeStyle = i % 3 ? C.sheet : C.blue; ctx.lineWidth = 10; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();\n }\n ctx.globalAlpha = a * .9; ctx.strokeStyle = C.sheet; ctx.lineWidth = 5 * (1 - e) + 1.5; ctx.beginPath(); ctx.arc(ox, oy, (30 + 120 * e) * k, 0, TAU); ctx.stroke();\n ctx.restore();\n },\n};\n\n// eighth note, drawn (fonts may not carry the glyph)\nfunction note(ctx, x, y, k) {\n ctx.save(); ctx.translate(x, y); ctx.scale(k, k); ctx.fillStyle = C.blue; ctx.strokeStyle = C.blue; ctx.lineCap = 'round';\n ctx.beginPath(); ctx.ellipse(-8, 18, 17, 12, -.4, 0, TAU); ctx.fill();\n ctx.lineWidth = 6; ctx.beginPath(); ctx.moveTo(7, 14); ctx.lineTo(7, -38); ctx.quadraticCurveTo(22, -26, 30, -12); ctx.stroke();\n ctx.restore();\n}\n\n// ---- xylo: a paper xylophone. bars rise in, a mallet strikes them on the sung notes.\n// {y, notes: ['E', 'G', 'A', 'C'], midi: [76, 79, 81, 84], hitKeys: [[at, barIndex]], restAt}\nconst xylo = {\n draw(R, s, t) {\n const { ctx } = R, n = s.notes.length, bw = 170, gap = 34, x0 = W / 2 - (n * bw + (n - 1) * gap) / 2, cy = (s.y ?? 420) + 290;\n const hs = s.notes.map((_, i) => 560 - i * 60), hits = s.hitKeys || [];\n env(ctx, s, t, pin => {\n // rails\n rise(ctx, pin, 40, () => [-150, 150].forEach(dy => { ctx.beginPath(); ctx.roundRect(x0 - 50, cy + dy - 11, n * bw + (n - 1) * gap + 100, 22, 11); ctx.fillStyle = C.dim; ctx.fill(); }));\n s.notes.forEach((nt, i) => {\n const p = S(t - s.from - .1 - i * .08, 12); if (p \u003c= .002) return;\n const x = x0 + i * (bw + gap), h = hs[i];\n let glow = 0; for (const [at, j] of hits) if (j === i && t >= at) glow = Math.max(glow, Math.exp(-(t - at) * 3.2));\n const dip = hits.reduce((m, [at, j]) => j === i ? Math.max(m, press(t, at, .16)) : m, 0);\n rise(ctx, p, 70, () => {\n sheet(ctx, x, cy - h / 2 + 8 * dip, bw, h, 26, { elev: 1.1 - .6 * dip, fill: glow > .01 ? mix(C.sheet, C.blue, glow) : undefined });\n [-150, 150].forEach(dy => { ctx.beginPath(); ctx.arc(x + bw / 2, cy + dy + 8 * dip, 9, 0, TAU); ctx.fillStyle = glow > .4 ? 'rgba(255,255,255,.7)' : C.ink3; ctx.fill(); });\n text(ctx, nt, x + bw / 2, midBase(cy + 8 * dip, 64), { size: 64, weight: 800, align: 'center', color: mix(C.ink2, C.white, glow) });\n });\n for (const [at, j] of hits) if (j === i && t >= at && t \u003c at + 1.2) { // a note floats up from the bar\n const u = (t - at) / 1.2, nx = x + bw / 2 + 50 * Math.sin(u * 3 + i), ny = cy - h / 2 - 40 - 200 * E.out(u);\n alpha(ctx, (1 - u) * Math.min(1, u * 8), () => note(ctx, nx, ny, 1.1));\n }\n });\n // mallet: springs to the next bar just before each hit, taps down on it\n if (hits.length) {\n const bx = i => x0 + i * (bw + gap) + bw / 2, top = i => cy - hs[i] / 2;\n const keys = [[s.from, [W + 200, cy - 420]], ...hits.map(([at, j]) => [at - .22, [bx(j), top(j) - 120], 16]), [s.restAt ?? hits.at(-1)[0] + .6, [W + 200, cy - 420], 8]];\n const [mx, my] = track(t, keys, 14), hit = hits.reduce((m, [at]) => Math.max(m, press(t, at - .08, .2)), 0);\n const hx = mx, hy = my + 100 * hit, a = vis(t, s.from + .2, (s.restAt ?? hits.at(-1)[0] + .6) + .2, 10, 8);\n alpha(ctx, a, () => {\n ctx.save(); ctx.lineCap = 'round';\n ctx.strokeStyle = 'rgba(40,28,10,.18)'; ctx.lineWidth = 16; ctx.beginPath(); ctx.moveTo(hx + 20, hy + 10); ctx.lineTo(hx + 250, hy - 250); ctx.stroke();\n ctx.strokeStyle = C.ink2; ctx.lineWidth = 14; ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx + 230, hy - 260); ctx.stroke();\n shadow(ctx, hx - 38, hy - 38, 76, 76, 38, 12, .2, 6);\n ctx.beginPath(); ctx.arc(hx, hy, 38, 0, TAU); ctx.fillStyle = C.ink; ctx.fill();\n ctx.beginPath(); ctx.arc(hx - 11, hy - 12, 11, 0, TAU); ctx.fillStyle = 'rgba(255,255,255,.22)'; ctx.fill();\n ctx.restore();\n });\n }\n });\n },\n};\n\n// ---- sfxtrack: an audio editor lane per sound, a playhead sweeps and lights each clip as it passes.\n// {y, header: {text, at}, tag: {text, at}, rows: [{label, at, clips: [[x0, x1]] (0..1 of the lane), sound}], playFrom, playTo}\nconst sfxtrack = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 330, rh = 150, lx = x + 300, lw = w - 300 - 40, h = s.rows.length * rh + 60;\n const ph = seg(t, s.playFrom, s.playTo), phx = lx + lw * ph;\n env(ctx, s, t, pin => {\n if (s.header) rise(ctx, S(t - s.header.at, 12), 40, () => text(ctx, s.header.text, x + 10, y - 56, { size: 64, weight: 800, ls: -1.5 }));\n if (s.tag) { const p = S(t - s.tag.at, 13); if (p > .002) rise(ctx, p, 22, () => chip(ctx, x + w, y - 80, s.tag.text, { align: 'right', size: 38, h: 76, fill: C.blue, color: C.white, border: false })); }\n rise(ctx, pin, 70, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.2 });\n s.rows.forEach((r, i) => {\n const ry = y + 30 + i * rh, p = S(t - r.at, 13); if (p \u003c= .002) return;\n rise(ctx, p, 20, () => {\n if (i) { ctx.fillStyle = C.line; ctx.fillRect(x + 30, ry, w - 60, 1.5); }\n text(ctx, r.label, x + 40, midBase(ry + rh / 2, 42), { size: 42, weight: 700, ls: -.6 });\n ctx.beginPath(); ctx.roundRect(lx, ry + 28, lw, rh - 56, 18); ctx.fillStyle = 'rgba(40,30,10,.04)'; ctx.fill();\n r.clips.forEach(([a, b], k) => {\n const cx = lx + lw * a, cw = Math.max(18, lw * (b - a)), cy = ry + 36, chh = rh - 72;\n const lit = ph > a ? Math.exp(-Math.max(0, (t - s.playFrom) - (s.playTo - s.playFrom) * b) * 4) * (ph \u003c 1 || t \u003c s.playTo + .8 ? 1 : 0) : 0;\n const on = ph > a && ph \u003c= b + .02 ? 1 : lit;\n ctx.beginPath(); ctx.roundRect(cx, cy, cw, chh, 12); ctx.fillStyle = mix(C.tint, C.blue, on); ctx.fill();\n ctx.strokeStyle = 'rgba(37,142,255,.5)'; ctx.lineWidth = 2; ctx.stroke();\n ctx.fillStyle = on > .5 ? 'rgba(255,255,255,.9)' : 'rgba(37,142,255,.75)';\n const nb = Math.max(1, Math.floor((cw - 16) / 12));\n for (let q = 0; q \u003c nb; q++) { const hh = (chh - 24) * (.25 + .75 * Math.abs(Math.sin(q * 1.7 + i * 3 + k))) * (r.sound === 'snap' ? Math.exp(-q * .5) : 1); ctx.fillRect(cx + 10 + q * 12, cy + chh / 2 - hh / 2, 6, hh); }\n });\n });\n });\n if (t > s.playFrom - .3 && t \u003c s.playTo + .6) alpha(ctx, vis(t, s.playFrom - .3, s.playTo + .2, 14, 10), () => {\n ctx.fillStyle = C.blue; ctx.fillRect(phx - 2.5, y + 18, 5, h - 36);\n ctx.beginPath(); ctx.moveTo(phx - 16, y + 6); ctx.lineTo(phx + 16, y + 6); ctx.lineTo(phx, y + 26); ctx.closePath(); ctx.fill();\n });\n });\n });\n },\n};\n\n// ---- popover: a macOS alert that lands over the camera and follows where the speaker points.\n// {keys: [[t, [x, y]]] (top-left), w: 780, icon: 'claude', title, message: {text, from, to}, buttons: ['Annuler', 'OK'], clickAt}\nconst popover = {\n prep(s, R) {\n if (s._p) return s._p;\n const { ctx } = R, w = s.w || 780, fs = 40, lh = 54, lines = wrap(ctx, s.message.text, w - 120, fs, 500);\n const toks = typing(s.message.text, R.tl.words, s.message.from, s.message.to);\n const h = 60 + 110 + 36 + 60 + lines.length * lh + 40 + 96 + 44;\n return (s._p = { w, h, fs, lh, lines, toks });\n },\n draw(R, s, t) {\n const { ctx } = R, P = popover.prep(s, R), { w, h, fs, lh, lines, toks } = P;\n const [x, y] = track(t, s.keys, 9), pin = S(t - s.from, 13), out = s.to != null ? leave(t - s.to) : 0;\n const a = Math.min(1, pin * 1.4) * (1 - out); if (a \u003c= .002) return;\n ctx.save(); ctx.globalAlpha *= a;\n const sc = (.9 + .1 * pin) * (1 - .06 * out), cx = x + w / 2, cy = y + h / 2;\n ctx.translate(cx, cy); ctx.scale(sc, sc); ctx.translate(-cx, -cy - 30 * (1 - pin));\n sheet(ctx, x, y, w, h, 44, { elev: 2 });\n const ic = 110, ix = x + w / 2 - ic / 2, iy = y + 60;\n ctx.beginPath(); ctx.roundRect(ix, iy, ic, ic, 30); ctx.fillStyle = C.blue; ctx.fill();\n LOGO.claude(ctx, ix + ic / 2, iy + ic / 2, 38, C.white);\n text(ctx, s.title, x + w / 2, iy + ic + 36 + 44, { size: 46, weight: 800, ls: -.8, align: 'center' });\n let n = 0; toks.forEach((k, i) => { const d = Math.min(.26, Math.max(.08, k.end - k.start)); n += t >= k.start ? (i ? 1 : 0) + Math.round(k.tok.length * clamp((t - k.start) / d)) : 0; });\n let acc = 0, cxr = x + w / 2, cyr = iy + ic + 36 + 60 + fs;\n lines.forEach((l, i) => {\n const v = clamp(n - acc, 0, l.length); acc += l.length + 1; if (v \u003c= 0) return;\n const yy = iy + ic + 36 + 60 + fs + 10 + i * lh, full = measure(ctx, l, fs, 500), str = l.slice(0, v);\n text(ctx, str, x + w / 2 - full / 2, yy, { size: fs, weight: 500, color: C.ink2 }); cxr = x + w / 2 - full / 2 + measure(ctx, str, fs, 500); cyr = yy;\n });\n if (t \u003c s.message.to + .2 && t > s.message.from - .3) alpha(ctx, .5 + .5 * Math.round((Math.sin(t * 12) + 1) / 2), () => { ctx.fillStyle = C.blue; ctx.fillRect(cxr + 3, cyr - fs * .8, 3.5, fs * .98); });\n const by = y + h - 44 - 96, bs = s.buttons || ['Annuler', 'OK'], gw = (w - 60 * 2 - 24) / 2, pr = press(t, s.clickAt, .2);\n bs.forEach((b, i) => {\n const bx = x + 60 + i * (gw + 24), last = i === bs.length - 1;\n ctx.save(); if (last && pr) { ctx.translate(bx + gw / 2, by + 48); ctx.scale(1 - .05 * pr, 1 - .05 * pr); ctx.translate(-(bx + gw / 2), -(by + 48)); }\n ctx.beginPath(); ctx.roundRect(bx, by, gw, 96, 30); ctx.fillStyle = last ? (pr ? C.blue2 : C.blue) : '#ECE8DF'; ctx.fill();\n text(ctx, b, bx + gw / 2, midBase(by + 48, 40), { size: 40, weight: 700, align: 'center', color: last ? C.white : C.ink });\n ctx.restore();\n });\n ctx.restore();\n const okx = x + 60 + (bs.length - 1) * (gw + 24) + gw * .55, oky = by + 58;\n const [px, py] = track(t, [[s.from, [W + 80, y + h + 300]], [s.clickAt - .7, [okx, oky]]], 9);\n cursor(ctx, px, py, { alpha: vis(t, s.clickAt - 1.1, s.clickAt + .4, 10, 12) * (1 - out), press: pr });\n },\n};\n\n// ---- pause: the video stops. A paper play/pause badge with a blue ring that fills over the pause, then morphs to\n// play and leaves. {from, to, keys: [[t, [x, y, r]]]}. Use 'p+X' times.\nconst pauseScene = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, r] = track(t, s.keys || [[s.from, [W / 2, 960, 130]]], 11);\n const pin = S(t - s.from, 16), out = leave(t - s.to - .15), a = Math.min(1, pin * 1.5) * (1 - out); if (a \u003c= .002) return;\n const play = S(t - s.to + .45, 16), ring = E.io(seg(t, s.from + .2, s.to - .45));\n ctx.save(); ctx.globalAlpha *= a; ctx.translate(x, y); const sc = (.85 + .15 * pin) * (1 + .12 * out); ctx.scale(sc, sc);\n shadow(ctx, -r, -r, r * 2, r * 2, r, 30, .28, 12);\n ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fillStyle = sheetFill(); ctx.fill();\n ctx.beginPath(); ctx.arc(0, 0, r - 14, 0, TAU); ctx.strokeStyle = 'rgba(40,30,10,.07)'; ctx.lineWidth = 12; ctx.stroke();\n if (ring > 0) { ctx.beginPath(); ctx.arc(0, 0, r - 14, -Math.PI / 2, -Math.PI / 2 + TAU * ring); ctx.strokeStyle = C.blue; ctx.lineCap = 'round'; ctx.lineWidth = 12; ctx.stroke(); }\n // pause bars morph into the play triangle\n const bw = r * .2, bh = r * .78, gx = r * .17;\n ctx.fillStyle = C.ink; ctx.beginPath();\n const L = [[-gx - bw, -bh / 2], [-gx, -bh / 2], [-gx, bh / 2], [-gx - bw, bh / 2]], Rr = [[gx, -bh / 2], [gx + bw, -bh / 2], [gx + bw, bh / 2], [gx, bh / 2]];\n const T1 = [[-r * .28, -bh / 2], [r * .08, -bh * .26], [r * .08, bh * .26], [-r * .28, bh / 2]], T2 = [[r * .08, -bh * .26], [r * .42, 0], [r * .42, 0], [r * .08, bh * .26]];\n [[L, T1], [Rr, T2]].forEach(([A, B]) => { A.forEach(([ax, ay], i) => { const px = lerp(ax, B[i][0], play), py = lerp(ay, B[i][1], play); i ? ctx.lineTo(px, py) : ctx.moveTo(px, py); }); ctx.closePath(); });\n ctx.fill();\n ctx.restore();\n },\n};\n\n// ---- pipeline: who did what, one row per tool, landing on the narrator's words. {y, header: {text, at, logo}, rows: [{glyph | logo, title, sub, at, blue}]}\nconst pipeline = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 190, rh = 132, hh = 150, h = hh + s.rows.length * rh + 30;\n const talking = R.tl.words.some(wd => wd.narrator && t >= wd.start - .03 && t \u003c= wd.end + .05) ? 1 : 0;\n env(ctx, s, t, pin => rise(ctx, pin, 80, () => {\n sheet(ctx, x, y, w, h, 40, { elev: 1.5 });\n const hy = y + hh / 2 + 6, hp = S(t - s.header.at, 13);\n alpha(ctx, Math.min(1, hp * 1.4), () => {\n if (isImgLogo(s.header.logo)) logoImg(R, ctx, s.header.logo, x + 40, hy - 45, 90, 24);\n else { ctx.beginPath(); ctx.roundRect(x + 40, hy - 45, 90, 90, 26); ctx.fillStyle = C.blue; ctx.fill(); }\n if (isImgLogo(s.header.logo)) {}\n else if (s.header.glyph) text(ctx, s.header.glyph, x + 85, midBase(hy, 44), { size: 44, weight: 800, align: 'center', color: C.white });\n else LOGO.claude(ctx, x + 85, hy, 30, C.white);\n text(ctx, s.header.text, x + 158, midBase(hy, 60), { size: 60, weight: 800, ls: -1.4 });\n // live voice meter: the narrator is talking\n for (let i = 0; i \u003c 9; i++) {\n const v = talking ? .25 + .75 * Math.abs(Math.sin(t * (11 + i * 1.7) + i * 2.1)) * (.6 + .4 * Math.sin(t * 5 + i)) : .12;\n const bh = 12 + 58 * v; ctx.beginPath(); ctx.roundRect(x + w - 60 - (9 - i) * 22, hy - bh / 2, 12, bh, 6); ctx.fillStyle = C.blue; ctx.fill();\n }\n });\n ctx.fillStyle = C.line; ctx.fillRect(x + 36, y + hh, w - 72, 1.5);\n s.rows.forEach((r, i) => {\n const ry = y + hh + 14 + i * rh, cy = ry + rh / 2, p = S(t - r.at, 13); if (p \u003c= .002) return;\n rise(ctx, p, 30, () => {\n const tile = 92, tx = x + 40;\n if (isImgLogo(r.logo)) logoImg(R, ctx, r.logo, tx, cy - tile / 2, tile, 24);\n else { ctx.beginPath(); ctx.roundRect(tx, cy - tile / 2, tile, tile, 26); ctx.fillStyle = r.blue ? C.blue : C.tint; ctx.fill(); }\n if (isImgLogo(r.logo)) {}\n else if (r.logo && LOGO[r.logo]) LOGO[r.logo](ctx, tx + tile / 2, cy, 30, r.blue ? C.white : C.blue);\n else text(ctx, r.glyph, tx + tile / 2, midBase(cy, 44), { size: 44, weight: 800, align: 'center', color: r.blue ? C.white : C.blue, fam: r.mono ? 'JetBrains Mono' : undefined });\n text(ctx, r.title, tx + tile + 30, cy - 6, { size: 46, weight: 800, ls: -.8, color: r.blue ? C.blue : C.ink });\n text(ctx, r.sub, tx + tile + 30, cy + 40, { size: 36, weight: 600, color: C.ink2 });\n const cp = S(t - r.at - .25, 14);\n if (cp > .002) alpha(ctx, cp, () => { const kx = x + w - 80, kr = 30; ctx.beginPath(); ctx.arc(kx, cy, kr, 0, TAU); ctx.fillStyle = r.blue ? C.blue : C.tint; ctx.fill(); checkIcon(ctx, kx, cy, 30, r.blue ? C.white : C.blue, E.out(seg(t, r.at + .3, r.at + .7)), 5); });\n });\n });\n }));\n },\n};\n\n// ---- comment: a comment composer as the CTA (over layer). {y, placeholder, typed, clickAt, typeFrom, typeTo, button}\nconst comment = {\n draw(R, s, t) {\n const { ctx } = R, w = 920, h = 200, x = W / 2 - w / 2, y = s.y - h / 2;\n env(ctx, s, t, pin => rise(ctx, pin, 90, () => {\n sheet(ctx, x, y, w, h, 48, { elev: 1.8 });\n const ax = x + 40 + 52, ay = y + h / 2;\n ctx.save(); ctx.beginPath(); ctx.arc(ax, ay, 52, 0, TAU); ctx.clip();\n const e = R.spec.cam?.eyes || [600, 400];\n if (R.cam) ctx.drawImage(R.cam, e[0] - 250, e[1] - 210, 500, 500, ax - 52, ay - 52, 104, 104); ctx.restore();\n const fx = x + 176, fw = w - 176 - 40 - 170, focus = S(t - s.clickAt, 16);\n const n = Math.round((s.typed || '').length * clamp((t - s.typeFrom) / (s.typeTo - s.typeFrom)));\n const str = (s.typed || '').slice(0, Math.max(0, n));\n if (str) text(ctx, str, fx, midBase(ay, 42), { size: 42, weight: 600, color: C.ink });\n else text(ctx, s.placeholder, fx, midBase(ay, 40), { size: 40, weight: 500, color: C.ink3 });\n ctx.fillStyle = mix('#E3DED3', '#258EFF', focus); ctx.fillRect(fx, ay + 44, fw * (.3 + .7 * focus) + 0, 3);\n if (focus > .1 && Math.sin(t * 11) > -.2) { const cx = fx + (str ? measure(ctx, str, 42, 600) + 4 : 0); ctx.fillStyle = C.blue; ctx.fillRect(cx, ay - 30, 3.5, 44); }\n const ready = str.length === (s.typed || '').length && str ? S(t - s.typeTo, 14) : 0, bw = 150, bx = x + w - 40 - bw;\n ctx.beginPath(); ctx.roundRect(bx, ay - 42, bw, 84, 42); ctx.fillStyle = mix('#ECE8DF', C.blue, ready); ctx.fill();\n text(ctx, s.button || 'Publier', bx + bw / 2, midBase(ay, 34), { size: 34, weight: 700, align: 'center', color: mix(C.ink3, C.white, ready) });\n const [px, py] = track(t, [[s.from, [W + 60, y + h + 260]], [s.clickAt - .6, [fx + 200, ay + 10]], [s.typeTo + .1, [bx + bw * .6, ay + 12]]], 9);\n cursor(ctx, px, py, { alpha: S(t - s.from - .15, 10), press: Math.max(press(t, s.clickAt, .2), s.sendAt ? press(t, s.sendAt, .2) : 0) });\n }));\n },\n};\n\n// ---- X post rebuilt natively. A desktop screenshot of a long post puts the body at ~25 px on a 960 card; this\n// redraws it as a light paper sheet with a 40 px body. text: '**bold**' spans; marks: [{at, text, dur}] highlight\n// the first occurrence of `text` (across line breaks) when it is spoken. h auto-fits unless rect[3] is given.\nfunction richLayout(ctx, str, maxW, size) {\n const words = []; let cur = null, bold = false, plain = '';\n for (let i = 0; i \u003c str.length; i++) {\n if (str.startsWith('**', i)) { bold = !bold; i++; continue; }\n const ch = str[i];\n if (ch === ' ') { cur = null; plain += ' '; continue; }\n if (!cur) { cur = { runs: [], s: plain.length }; words.push(cur); }\n const r = cur.runs.at(-1); if (r && r.bold === bold) r.t += ch; else cur.runs.push({ t: ch, bold });\n plain += ch; cur.e = plain.length;\n }\n const sp = measure(ctx, ' ', size, 500);\n let x = 0, line = 0;\n for (const w of words) {\n w.runs.forEach(r => { r.w = measure(ctx, r.t, size, r.bold ? 700 : 500); });\n w.w = w.runs.reduce((a, r) => a + r.w, 0);\n if (x > 0 && x + w.w > maxW) { x = 0; line++; }\n w.x = x; w.line = line; x += w.w + sp;\n }\n return { words, plain, lines: words.length ? line + 1 : 0 };\n}\nfunction markSegs(L, str) { // [{line, x0, x1}] covering `str` in reading order\n const a = L.plain.indexOf(str); if (a \u003c 0) return [];\n const b = a + str.length, segs = [];\n for (const w of L.words) if (w.e > a && w.s \u003c b) {\n const g = segs.at(-1);\n if (g && g.line === w.line) g.x1 = w.x + w.w; else segs.push({ line: w.line, x0: w.x, x1: w.x + w.w });\n }\n return segs;\n}\nfunction xLogo(ctx, cx, cy, s, color) {\n ctx.save(); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineCap = 'butt';\n ctx.beginPath(); ctx.moveTo(cx - s * .5, cy - s * .5); ctx.lineTo(cx - s * .2, cy - s * .5); ctx.lineTo(cx + s * .5, cy + s * .5); ctx.lineTo(cx + s * .2, cy + s * .5); ctx.closePath(); ctx.fill();\n ctx.lineWidth = s * .09; ctx.beginPath(); ctx.moveTo(cx + s * .44, cy - s * .5); ctx.lineTo(cx - s * .44, cy + s * .5); ctx.stroke(); ctx.restore();\n}\nfunction verified(ctx, cx, cy, r) { // scalloped blue badge + white check\n ctx.save(); ctx.beginPath();\n for (let k = 0; k \u003c= 48; k++) { const a = k / 48 * TAU, rr = r * (1 + .09 * Math.cos(a * 8)); ctx.lineTo(cx + Math.cos(a) * rr, cy + Math.sin(a) * rr); }\n ctx.fillStyle = C.blue; ctx.fill(); ctx.restore();\n checkIcon(ctx, cx, cy, r * 1.05, C.white, 1, r * .24);\n}\n// media: the post's clip (a `media` id) played from `offset`; mediaKeys [[at, offset, id?]] jump-cut inside it (or to\n// another media id) on the spoken word, so one long launch film shows its best shots. mediaAr = h/w (default 9/16).\nfunction tweetMedia(s, t) {\n if (s.mediaKeys) { const k = s.mediaKeys.filter(q => t >= q[0]).at(-1) || s.mediaKeys[0]; return [k[2] || s.media, Math.max(0, k[1] + t - Math.max(k[0], s.from))]; }\n return s.media ? [s.media, clipTime(t, s.from, s.offset)] : null;\n}\nconst tweet = {\n needs(s, t) {\n if (t \u003c s.from - (s.pre || 0) - .3 || t > (s.to ?? 1e9) + .7) return [];\n const m = tweetMedia(s, t); return [...(s.avatar ? [[s.avatar, 0]] : []), ...(m ? [m] : [])];\n },\n prep(s, R) {\n if (s._p) return s._p;\n const { ctx } = R, [x, y, w, hh] = s.rect, size = s.size || 40, lh = Math.round(size * 1.34), pad = 44;\n const L = richLayout(ctx, s.text, w - pad * 2, size), top = y + 40 + 88 + 66;\n const mh = s.media || s.mediaKeys ? Math.round((w - pad * 2) * (s.mediaAr || 9 / 16)) : 0, M = mh ? mh + 34 : 0;\n const h = hh ?? (top - y) + (L.lines - 1) * lh + M + (s.footer ? 140 : 54);\n return (s._p = { x, y, w, h, size, lh, pad, L, top, mh, M, marks: (s.marks || []).map(m => ({ ...m, segs: markSegs(L, m.text) })) });\n },\n hand(s, R) { // the media rect at rest, for a board item that morphs out of it (screen morph: true)\n const P = tweet.prep(s, R); return P.mh ? [P.x + P.pad, P.top + (P.L.lines - 1) * P.lh + 34, P.w - P.pad * 2, P.mh] : null;\n },\n draw(R, s, t) {\n const { ctx } = R, P = tweet.prep(s, R), { x, y, w, h, size, lh, pad, L, top, mh, M } = P;\n env(ctx, s, t, pin => rise(ctx, pin, 70, () => {\n sheet(ctx, x, y, w, h, 34, { elev: 1.2 });\n if (s.label) cardLabel(ctx, x + 12, y + 12, s.label, s.dot === 'blue' ? C.blue : s.dot === 'gray' ? C.ink3 : null);\n // header\n const ax = x + pad, ay = y + 40, img = s.avatar ? R.frame(s.avatar, 0) : null;\n if (s.initial) { // YouTube-style letter avatar\n ctx.beginPath(); ctx.arc(ax + 44, ay + 44, 44, 0, TAU); ctx.fillStyle = C.ink2; ctx.fill();\n text(ctx, s.initial, ax + 44, midBase(ay + 44, 44), { size: 44, weight: 700, align: 'center', color: C.white });\n } else { ctx.save(); ctx.beginPath(); ctx.arc(ax + 44, ay + 44, 44, 0, TAU); ctx.clip(); cover(ctx, img, ax, ay, 88, 88); ctx.restore(); }\n const nx = ax + 88 + 22;\n text(ctx, s.name, nx, ay + 38, { size: 40, weight: 800, ls: -.6 });\n if (s.verified) verified(ctx, nx + measure(ctx, s.name, 40, 800, -.6) + 30, ay + 24, 17);\n text(ctx, s.handle, nx, ay + 82, { size: 34, weight: 500, color: C.ink3 });\n if (isImgLogo(s.logo)) logoImg(R, ctx, s.logo, x + w - pad - 64, ay - 2, 64, 14); else xLogo(ctx, x + w - pad - 20, ay + 26, 36, C.ink);\n // highlighter under the words, sweeping line by line\n for (const m of P.marks) {\n const tot = m.segs.reduce((a, g) => a + g.x1 - g.x0, 0); let left = tot * E.cubic(seg(t, m.at, m.at + (m.dur ?? .7)));\n if (left \u003c= .5) continue;\n ctx.save(); ctx.fillStyle = 'rgba(37,142,255,.24)';\n for (const g of m.segs) { if (left \u003c= 0) break; const gw = Math.min(left, g.x1 - g.x0); left -= g.x1 - g.x0;\n ctx.beginPath(); ctx.roundRect(x + pad + g.x0 - 6, top + g.line * lh - size * .86, gw + 12, size * 1.18, 8); ctx.fill(); }\n ctx.restore();\n }\n // read: {from, to} = read-along: each word goes from ink3 to ink on the spoken word it maps to\n const rd = s.read ? (P.toks ||= typing(L.words.map(wd => wd.runs.map(r => r.t).join('')).join(' '), R.tl.words, s.read.from, s.read.to)) : null;\n L.words.forEach((wd, i) => { let wx = x + pad + wd.x; const color = rd ? mix(C.ink3, C.ink, S(t - rd[i].start + .04, 22)) : C.ink;\n for (const r of wd.runs) { text(ctx, r.t, wx, top + wd.line * lh, { size, weight: r.bold ? 700 : 500, color }); wx += r.w; } });\n if (mh && !(s._handTo != null && t >= s._handTo)) { // the post's own video / image, playing inside the card like on X (until a morph takes it)\n const my = top + (L.lines - 1) * lh + 34, m = tweetMedia(s, t), img = m ? R.frame(m[0], m[1]) : null;\n cover(ctx, img, x + pad, my, w - pad * 2, mh, 22);\n ctx.save(); ctx.beginPath(); ctx.roundRect(x + pad, my, w - pad * 2, mh, 22); ctx.strokeStyle = 'rgba(20,22,27,.1)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n }\n if (s.footer) {\n const fy = top + (L.lines - 1) * lh + M + 26 + 30;\n ctx.fillStyle = C.line; ctx.fillRect(x + pad, fy - 30, w - pad * 2, 2);\n let fx = x + pad;\n for (const [str, strong] of s.footer.split('**').map((q, k) => [q, k % 2 === 1])) { text(ctx, str, fx, fy + 36, { size: 34, weight: strong ? 700 : 500, color: strong ? C.ink : C.ink3 }); fx += measure(ctx, str, 34, strong ? 700 : 500); }\n }\n if (s.photo) { // photo: a camera flash washing over the card as it lands (the shutter sound is in cues.js)\n const f = t - (s.from - (s.pre || 0));\n if (f > 0 && f \u003c .5) { ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 34); ctx.fillStyle = `rgba(255,255,255,${(Math.pow(1 - f / .5, 2) * .9).toFixed(3)})`; ctx.fill(); ctx.restore(); }\n }\n }));\n },\n};\n\n// ---- article: a press source rebuilt natively (crisp at any size, unlike a page screenshot). Outlet row (real logo +\n// name + date), headline, optional pulled quote with a blue rule and attribution. Height follows the text.\n// { rect: [x, y, w], center: true, logo: 'assets/logos/cnbc.png', outlet: 'CNBC', date: '29 sept. 2026', kicker: 'Tech',\n// headline, headSize: 54, hlMark: { at, dur }, // highlighter sweeping the whole headline when it is said\n// headMarks: [{ at, text, dur }], // or only these words of the headline, each when it is said\n// quote, quoteSize: 38, by: 'Arthur Mensch, CEO de Mistral', marks: [{ at, text, dur }], read: { from, to } }\nconst H_ = H;\nconst article = {\n prep(s, R) {\n if (s._p) return s._p;\n const { ctx } = R, [x, y, w] = s.rect, pad = 48, hs = s.headSize || 54, hlh = Math.round(hs * 1.16), qs = s.quoteSize || 38, qlh = Math.round(qs * 1.38);\n const H = wrap(ctx, s.headline || '', w - pad * 2, hs, 800, -1.2), htop = y + pad + 64 + 34 + hs * .82;\n const Q = s.quote ? richLayout(ctx, s.quote, w - pad * 2 - 34, qs) : null, qtop = htop + (H.length - 1) * hlh + 46 + 30 + qs * .82;\n const h = Q ? qtop - y + (Q.lines - 1) * qlh + (s.by ? 84 : 30) + pad - 10 : htop - y + (H.length - 1) * hlh + pad;\n const dy = s.center ? Math.round((H_ - h) / 2) - y : 0; // center: true centres the card on the stage height\n return (s._p = { x, y: y + dy, w, h, pad, hs, hlh, qs, qlh, H, htop: htop + dy, Q, qtop: qtop + dy, marks: Q ? (s.marks || []).map(m => ({ ...m, segs: markSegs(Q, m.text) })) : [] });\n },\n draw(R, s, t) {\n const { ctx } = R, P = article.prep(s, R), { x, y, w, h, pad, hs, hlh, qs, qlh, H, htop, Q, qtop } = P;\n env(ctx, s, t, pin => rise(ctx, pin, 70, () => {\n sheet(ctx, x, y, w, h, 30, { elev: 1.2 });\n // outlet row: logo, name, date (right), kicker chip\n let lx = x + pad;\n if (isImgLogo(s.logo)) { logoImg(R, ctx, s.logo, lx, y + pad, 64, 14); lx += 64 + 20; }\n text(ctx, s.outlet || '', lx, midBase(y + pad + 32, 36), { size: 36, weight: 800, ls: -.4 });\n if (s.kicker) { const kx = lx + measure(ctx, s.outlet || '', 36, 800, -.4) + 22, kw = measure(ctx, s.kicker, 26, 700) + 28;\n ctx.save(); ctx.beginPath(); ctx.roundRect(kx, y + pad + 15, kw, 36, 18); ctx.fillStyle = C.tint; ctx.fill(); ctx.restore();\n text(ctx, s.kicker, kx + 14, midBase(y + pad + 33, 26), { size: 26, weight: 700, color: C.blue }); }\n if (s.date) text(ctx, s.date, x + w - pad, midBase(y + pad + 32, 30), { size: 30, weight: 500, color: C.ink3, align: 'right' });\n ctx.fillStyle = C.line; ctx.fillRect(x + pad, y + pad + 64 + 16, w - pad * 2, 2);\n // headline, with an optional highlighter sweep over all its lines\n if (s.hlMark) {\n const ws = H.map(l => measure(ctx, l, hs, 800, -1.2)), tot = ws.reduce((a, b) => a + b, 0);\n let left = tot * E.cubic(seg(t, s.hlMark.at, s.hlMark.at + (s.hlMark.dur ?? .9)));\n ctx.save(); ctx.fillStyle = 'rgba(37,142,255,.22)';\n H.forEach((l, i) => { if (left \u003c= .5) return; const gw = Math.min(left, ws[i]); left -= ws[i];\n ctx.beginPath(); ctx.roundRect(x + pad - 8, htop + i * hlh - hs * .84, gw + 16, hs * 1.12, 10); ctx.fill(); });\n ctx.restore();\n }\n for (const m of s.headMarks || []) { // a phrase of the headline, swept across the wrapped lines it spans\n const all = H.join(' '), i0 = all.indexOf(m.text);\n if (i0 \u003c 0) continue;\n const segs = []; let c0 = 0;\n H.forEach((l, i) => { const a = Math.max(i0, c0), b = Math.min(i0 + m.text.length, c0 + l.length);\n if (b > a) { const x0 = measure(ctx, l.slice(0, a - c0), hs, 800, -1.2); segs.push([i, x0, measure(ctx, l.slice(0, b - c0), hs, 800, -1.2) - x0]); }\n c0 += l.length + 1; });\n let left = segs.reduce((a, g) => a + g[2], 0) * E.cubic(seg(t, m.at, m.at + (m.dur ?? .8)));\n if (left \u003c= .5) continue;\n ctx.save(); ctx.fillStyle = 'rgba(37,142,255,.24)';\n for (const [i, x0, gw0] of segs) { if (left \u003c= 0) break; const gw = Math.min(left, gw0); left -= gw0;\n ctx.beginPath(); ctx.roundRect(x + pad + x0 - 8, htop + i * hlh - hs * .84, gw + 16, hs * 1.12, 10); ctx.fill(); }\n ctx.restore();\n }\n H.forEach((l, i) => text(ctx, l, x + pad, htop + i * hlh, { size: hs, weight: 800, ls: -1.2 }));\n if (!Q) return;\n // pulled quote: blue rule, highlighter marks, optional read-along, attribution\n const qx = x + pad + 34;\n ctx.fillStyle = C.blue; ctx.beginPath(); ctx.roundRect(x + pad, qtop - qs * .9, 6, (Q.lines - 1) * qlh + qs * 1.2, 3); ctx.fill();\n for (const m of P.marks) {\n const tot = m.segs.reduce((a, g) => a + g.x1 - g.x0, 0); let left = tot * E.cubic(seg(t, m.at, m.at + (m.dur ?? .7)));\n if (left \u003c= .5) continue;\n ctx.save(); ctx.fillStyle = 'rgba(37,142,255,.24)';\n for (const g of m.segs) { if (left \u003c= 0) break; const gw = Math.min(left, g.x1 - g.x0); left -= g.x1 - g.x0;\n ctx.beginPath(); ctx.roundRect(qx + g.x0 - 6, qtop + g.line * qlh - qs * .86, gw + 12, qs * 1.18, 8); ctx.fill(); }\n ctx.restore();\n }\n const rd = s.read ? (P.toks ||= typing(Q.words.map(wd => wd.runs.map(r => r.t).join('')).join(' '), R.tl.words, s.read.from, s.read.to)) : null;\n Q.words.forEach((wd, i) => { let wx = qx + wd.x; const color = rd ? mix(C.ink3, C.ink, S(t - rd[i].start + .04, 22)) : C.ink;\n for (const r of wd.runs) { text(ctx, r.t, wx, qtop + wd.line * qlh, { size: qs, weight: r.bold ? 700 : 500, color }); wx += r.w; } });\n if (s.by) text(ctx, s.by, qx, qtop + (Q.lines - 1) * qlh + 64, { size: 30, weight: 600, color: C.ink3 });\n }));\n },\n};\n\n// ---- columns: vertical cost columns rebuilt from a bar chart (Artificial Analysis \"Cost per Intelligence Index Task\").\n// groups: [{cols: [{name, sub, value, text, at, blue}], ghost: {at, over: 0, of: 1, chip: {text, at}}}] side by side on one\n// baseline; value on top of each column, name + sub under it. ghost draws the taller column `of` as a dashed outline over\n// the cheaper column `over`, with a ratio chip above it (\"5.7x\"). {base: 1080, y0: 470 (top of the tallest), cx: 520}\nconst columns = {\n draw(R, s, t) {\n const { ctx } = R, base = s.base ?? 1080, top = s.y0 ?? 470, cw = s.colW ?? 170, gi = s.gapIn ?? 30, go = s.gapOut ?? 90;\n const max = s.max ?? Math.max(...s.groups.flatMap(g => g.cols.map(c => c.value))), H = v => (base - top) * v / max;\n const gw = g => g.cols.length * cw + (g.cols.length - 1) * gi, total = s.groups.reduce((a, g) => a + gw(g), 0) + go * (s.groups.length - 1);\n const x0 = (s.cx ?? W / 2) - total / 2;\n env(ctx, s, t, () => {\n if (s.header) rise(ctx, S(t - s.header.at, 12), 40, () => text(ctx, s.header.text, W / 2, 240, { size: 64, weight: 800, ls: -1.8, align: 'center' }));\n if (s.sub) rise(ctx, S(t - s.sub.at, 12), 30, () => text(ctx, s.sub.text, W / 2, 300, { size: 34, weight: 600, color: C.ink2, align: 'center' }));\n const g0 = E.out(seg(t, s.from + .05, s.from + .6));\n alpha(ctx, S(t - s.from - .1, 12), () => { // dotted grid, like the source chart\n ctx.save(); ctx.setLineDash([3, 9]); ctx.strokeStyle = 'rgba(40,30,10,.18)'; ctx.lineWidth = 2;\n for (let k = 1; k \u003c= 4; k++) { const yy = base - (base - top) * k / 4; ctx.beginPath(); ctx.moveTo(x0 - 30, yy); ctx.lineTo(lerp(x0 - 30, x0 + total + 30, g0), yy); ctx.stroke(); }\n ctx.restore();\n });\n ctx.save(); ctx.strokeStyle = C.ink; ctx.lineWidth = 3; ctx.lineCap = 'round';\n ctx.beginPath(); ctx.moveTo(x0 - 30, base); ctx.lineTo(lerp(x0 - 30, x0 + total + 30, g0), base); ctx.stroke(); ctx.restore();\n let gx = x0;\n for (const g of s.groups) {\n const cx = i => gx + i * (cw + gi);\n if (g.ghost) { // the expensive column's height, drawn over the cheap one\n const q = g.ghost, gh = H(g.cols[q.of ?? 1].value), p = E.out(seg(t, q.at, q.at + .55)), X = cx(q.over ?? 0);\n alpha(ctx, Math.min(1, p * 2), () => {\n ctx.beginPath(); ctx.roundRect(X, base - gh * p, cw, gh * p, [16, 16, 0, 0]); ctx.fillStyle = 'rgba(37,142,255,.08)'; ctx.fill();\n ctx.setLineDash([10, 9]); ctx.strokeStyle = 'rgba(37,142,255,.6)'; ctx.lineWidth = 2.5; ctx.stroke(); ctx.setLineDash([]);\n });\n if (q.chip) { const pc = S(t - q.chip.at, 13); if (pc > .002) rise(ctx, pc, 20, () => chip(ctx, X + cw / 2, base - gh - 56, q.chip.text, { size: 40, h: 80, padX: 26, fill: C.blue, color: C.white, border: false })); }\n }\n g.cols.forEach((c, i) => {\n const X = cx(i), p = S(t - c.at, 12), gr = S(t - c.at - .05, 7), h = Math.max(8, H(c.value) * gr), col = c.blue ? C.blue : C.ink3;\n if (p \u003c= .002) return;\n alpha(ctx, Math.min(1, p * 1.4), () => { ctx.beginPath(); ctx.roundRect(X, base - h, cw, h, [16, 16, 0, 0]); ctx.fillStyle = col; ctx.fill(); });\n rise(ctx, S(t - c.at - .15, 13), 16, () => text(ctx, c.text, X + cw / 2, base - h - 22, { size: 46, weight: 800, ls: -1, align: 'center', color: c.blue ? C.blue : C.ink, halo: 10 }));\n rise(ctx, p, 20, () => {\n text(ctx, c.name, X + cw / 2, base + 62, { size: 42, weight: 800, ls: -.8, align: 'center', color: c.blue ? C.ink : C.ink2 });\n if (c.sub) text(ctx, c.sub, X + cw / 2, base + 108, { size: 36, weight: 600, align: 'center', color: C.ink3 });\n });\n });\n gx += gw(g) + go;\n }\n });\n },\n};\n\n// ---- recap: numbered list on one sheet (\"les 9 changements\"). {rect: [x, y, w], header: {text, count, at}, rh: 98,\n// rows: [{title, sub, at}]}. Rows land in a quick cascade; the number sits in a blue disc.\nconst recap = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, w] = s.rect, rh = s.rh ?? 98, head = 118, pad = 40, h = head + s.rows.length * rh + 18;\n env(ctx, s, t, pin => rise(ctx, pin, 70, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.3 });\n if (s.header) {\n text(ctx, s.header.text, x + pad, y + 78, { size: 52, weight: 800, ls: -1.2 });\n if (s.header.count) text(ctx, s.header.count, x + w - pad, y + 76, { size: 40, weight: 700, color: C.blue, align: 'right' });\n ctx.fillStyle = C.line; ctx.fillRect(x + pad, y + head - 2, w - pad * 2, 2);\n }\n s.rows.forEach((r, i) => {\n const cy = y + head + i * rh + rh / 2, p = S(t - r.at, 13); if (p \u003c= .002) return;\n rise(ctx, p, 24, () => {\n ctx.beginPath(); ctx.arc(x + pad + 30, cy, 30, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n text(ctx, String(i + 1), x + pad + 30, midBase(cy, 34), { size: 34, weight: 800, align: 'center', color: C.white });\n text(ctx, r.title, x + pad + 84, midBase(cy, 44), { size: 44, weight: 800, ls: -.8 });\n if (r.sub) text(ctx, r.sub, x + w - pad, midBase(cy, 36), { size: 36, weight: 600, color: C.ink2, align: 'right' });\n if (i \u003c s.rows.length - 1) { ctx.fillStyle = 'rgba(40,30,10,.06)'; ctx.fillRect(x + pad + 84, cy + rh / 2 - 1, w - pad * 2 - 84, 1.5); }\n });\n });\n }));\n },\n};\n\n// ---- usage meters (rebuilt settings popup, e.g. Claude \"Plan usage limits\"): rows of label + sub + % + bar.\n// rows: [{label, sub, value, fill: {value, at, sub}, mark: {at, to}}]. fill animates the bar (and % text); mark tints the row.\nconst usage = {\n draw(R, s, t) {\n const { ctx } = R, [x, y, w] = s.rect, pad = 46, rh = 168, head = s.title ? 96 : 30, h = head + s.rows.length * rh + 24;\n env(ctx, s, t, pin => rise(ctx, pin, 70, () => {\n sheet(ctx, x, y, w, h, 34, { elev: 1.2 });\n if (s.title) {\n const lx = s.logo ? x + pad + 56 + 18 : x + pad; if (s.logo) logoImg(R, ctx, s.logo, x + pad, y + 22, 56);\n text(ctx, s.title, lx, y + 70, { size: 36, weight: 600, color: C.ink2 }); ctx.fillStyle = C.line; ctx.fillRect(x + pad, y + head - 2, w - pad * 2, 2);\n }\n s.rows.forEach((r, i) => {\n const ry = y + head + i * rh, mk = r.mark ? S(t - r.mark.at, 12) * (1 - (r.mark.to != null ? S(t - r.mark.to, 12) : 0)) : 0;\n if (mk > .002) alpha(ctx, mk, () => { ctx.beginPath(); ctx.roundRect(x + 18, ry + 12, w - 36, rh - 16, 24); ctx.fillStyle = 'rgba(37,142,255,.08)'; ctx.fill(); ctx.strokeStyle = C.blue; ctx.lineWidth = 4; ctx.stroke(); });\n const f = r.fill ? S(t - r.fill.at, 5) : 0, v = lerp(r.value, r.fill?.value ?? r.value, f) * S(t - s.from + (s.pre || 0) - .15 - i * .08, 7), full = v > .995;\n text(ctx, r.label, x + pad, ry + 66, { size: 44, weight: 700, ls: -.5 });\n text(ctx, Math.round(v * 100) + '%', x + w - pad, ry + 66, { size: 42, weight: 700, align: 'right', color: full ? C.ink : C.ink2 });\n const subB = r.fill?.sub ? S(t - r.fill.at - .5, 12) : 0;\n if (subB \u003c .998 && r.sub) text(ctx, r.sub, x + pad, ry + 114, { size: 34, weight: 500, color: C.ink3, alpha: 1 - subB });\n if (subB > .002) text(ctx, r.fill.sub, x + pad, ry + 114, { size: 34, weight: 700, color: C.ink, alpha: subB });\n const by = ry + 134, bw = w - pad * 2;\n ctx.beginPath(); ctx.roundRect(x + pad, by, bw, 16, 8); ctx.fillStyle = 'rgba(40,30,10,.07)'; ctx.fill();\n if (v > .001) { ctx.beginPath(); ctx.roundRect(x + pad, by, Math.max(16, bw * v), 16, 8); ctx.fillStyle = full ? C.ink : C.blue; ctx.fill(); }\n });\n }));\n },\n};\n\n\n// ---------------------------------------------------------------- motion-design film (full paper, seam 1920)\n// A sequence of beats (\"md*\" scenes) played while the video is paused. Each beat has a step header (blue disc with a\n// number or logo, title, sub), a faint giant numeral behind it and a 5-step progress rail on top. Beats slide in from\n// the right and leave to the left, so back-to-back beats read as one continuous camera move.\nfunction slide(ctx, s, t, fn) {\n const pin = S(t - s.from, 10), out = s.to != null ? S(t - s.to, 10) : 0;\n const a = Math.min(1, pin * 1.3) * (1 - out); if (a \u003c= .002) return;\n ctx.save(); ctx.globalAlpha *= a; ctx.translate(W * .6 * (1 - pin) - W * .6 * out, 0); fn(pin); ctx.restore();\n}\nfunction stepHead(ctx, s, t, R) {\n const st = s.step; if (!st) return;\n const y = st.y ?? 430, r = 66, p = S(t - s.from - .05, 12), img = isImgLogo(st.logo); // image logo = real app icon after the title, the disc keeps the number\n if (st.n != null && (!st.logo || img)) alpha(ctx, .05 * p, () => text(ctx, String(st.n), W - 20, 1560, { size: 900, weight: 800, ls: -40, align: 'right' }));\n if (st.of) { // progress rail: done steps blue, the current one fills over the beat\n const n = st.of, gap = 14, x0 = 120, sw = (W - 330 - gap * (n - 1)) / n; // right end leaves room for the pause badge\n for (let i = 0; i \u003c n; i++) {\n const f = i \u003c st.n - 1 ? 1 : i === st.n - 1 ? E.out(seg(t, s.from, s.to ?? s.from + 3)) : 0;\n ctx.beginPath(); ctx.roundRect(x0 + i * (sw + gap), 290, sw, 12, 6); ctx.fillStyle = C.dim; ctx.fill();\n if (f > .001) { ctx.beginPath(); ctx.roundRect(x0 + i * (sw + gap), 290, sw * f, 12, 6); ctx.fillStyle = C.blue; ctx.fill(); }\n }\n }\n ctx.save(); ctx.translate(120, y); const sc = .7 + .3 * p; ctx.scale(sc, sc); ctx.globalAlpha *= Math.min(1, p * 1.4);\n shadow(ctx, -r, -r, r * 2, r * 2, r, 18, .18, 6);\n ctx.beginPath(); ctx.arc(0, 0, r, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n if (st.logo && LOGO[st.logo]) LOGO[st.logo](ctx, 0, 0, 34, C.white);\n else text(ctx, String(st.n), 0, midBase(0, 64), { size: 64, weight: 800, align: 'center', color: C.white });\n ctx.restore();\n rise(ctx, S(t - s.from - .12, 12), 30, () => {\n text(ctx, st.title, 218, midBase(y - 24, 80), { size: 80, weight: 800, ls: -2.4 });\n if (img && R) { const ix = 218 + measure(ctx, st.title, 80, 800, -2.4) + 26; shadow(ctx, ix, y - 68, 88, 88, 20, 14, .16, 5); logoImg(R, ctx, st.logo, ix, y - 68, 88, 20); }\n });\n if (st.sub) rise(ctx, S(t - s.from - .22, 12), 30, () => text(ctx, st.sub, 220, midBase(y + 50, 44), { size: 44, weight: 600, color: C.ink2 }));\n}\n// a rubber stamp that lands (scale 1.35 -> 1, no overshoot) at a slight angle\nfunction stamp(ctx, x, y, str, p, o = {}) {\n if (p \u003c= .002) return;\n const size = o.size || 60, w = measure(ctx, str, size, 800, -1) + 70, h = size + 50;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.6); ctx.translate(x, y); ctx.rotate(o.rot ?? -.07); const k = 1 + .35 * (1 - p); ctx.scale(k, k);\n ctx.beginPath(); ctx.roundRect(-w / 2, -h / 2, w, h, 22); ctx.fillStyle = o.fill || 'rgba(37,142,255,.08)'; ctx.fill();\n ctx.lineWidth = 7; ctx.strokeStyle = C.blue; ctx.stroke();\n text(ctx, str, 0, midBase(0, size), { size, weight: 800, ls: -1, align: 'center', color: C.blue });\n ctx.restore();\n}\nconst fmtT = x => { const m = Math.floor(x / 60), s = x - m * 60; return `${String(m).padStart(2, '0')}:${s.toFixed(2).padStart(5, '0')}`; };\n\n// ---- mdGrid: a slowly drifting dot grid on the paper for the whole film. {from, to}\nconst mdGrid = {\n draw(R, s, t) {\n const { ctx } = R, a = vis(t, s.from, s.to ?? 1e9, 8, 12) * .5; if (a \u003c= .002) return;\n const g = 60, oy = -((t - s.from) * 14) % g;\n ctx.save(); ctx.globalAlpha *= a; ctx.fillStyle = 'rgba(40,30,10,.13)';\n for (let y = oy; y \u003c 1940; y += g) for (let x = 30; x \u003c W; x += g) { ctx.beginPath(); ctx.arc(x, y, 2.6, 0, TAU); ctx.fill(); }\n ctx.restore();\n },\n};\n\n// ---- mdIntro: the narrator introduces itself. Big logo tile on a slow sunburst, title, then a sub line whose words\n// land on the narrator's words. {y, title, sub: {text, at, to, emph}}\nconst mdIntro = {\n draw(R, s, t) {\n const { ctx } = R, y = s.y ?? 640, u = t - s.from;\n slide(ctx, s, t, pin => {\n // sunburst\n alpha(ctx, S(u, 6) * .9, () => {\n ctx.save(); ctx.translate(W / 2, y); ctx.rotate(u * .12);\n for (let i = 0; i \u003c 24; i++) { ctx.rotate(TAU / 24); ctx.beginPath(); ctx.moveTo(0, 0); ctx.arc(0, 0, 1300, -.055, .055); ctx.closePath(); ctx.fillStyle = i % 2 ? 'rgba(37,142,255,.05)' : 'rgba(40,30,10,.025)'; ctx.fill(); }\n ctx.restore();\n });\n // expanding rings on entry\n for (let k = 0; k \u003c 3; k++) { const e = seg(u, .05 + k * .14, .95 + k * .14); if (e > 0 && e \u003c 1) alpha(ctx, (1 - e) * .7, () => { ctx.beginPath(); ctx.arc(W / 2, y, 150 + 420 * E.out(e), 0, TAU); ctx.strokeStyle = C.blue; ctx.lineWidth = 6 * (1 - e) + 1; ctx.stroke(); }); }\n const p = S(u, 11), sz = 320 * (.7 + .3 * p);\n alpha(ctx, Math.min(1, p * 1.5), () => {\n ctx.save(); ctx.translate(W / 2, y); ctx.rotate(-.35 * (1 - p));\n shadow(ctx, -sz / 2, -sz / 2, sz, sz, sz * .27, 40, .28, 16);\n ctx.beginPath(); ctx.roundRect(-sz / 2, -sz / 2, sz, sz, sz * .27); ctx.fillStyle = C.blue; ctx.fill();\n LOGO.claude(ctx, 0, 0, sz * .32, C.white); ctx.restore();\n });\n const words = s.title.split(' '), size = 150, ls = -5, sp = measure(ctx, ' ', size, 800, ls), ws = words.map(w => measure(ctx, w, size, 800, ls));\n let x = W / 2 - (ws.reduce((a, b) => a + b, 0) + sp * (words.length - 1)) / 2;\n words.forEach((w, i) => { const q = S(u - .15 - i * .1, 13), wx = x; x += ws[i] + sp; rise(ctx, q, 60, () => text(ctx, w, wx, y + 370, { size, weight: 800, ls })); });\n if (s.sub) {\n const toks = typing(s.sub.text, R.tl.words.some(w => w.narrator) ? R.tl.words.filter(w => w.narrator) : R.tl.words, s.sub.at, s.sub.to ?? s.sub.at + 1.5), fs = 64;\n const lines = wrap(ctx, s.sub.text, 900, fs, 700); let k = 0;\n lines.forEach((ln, li) => {\n const lw = ln.split(' '), lws = lw.map(w => measure(ctx, w, fs, 700)), lsp = measure(ctx, ' ', fs, 700);\n let lx = W / 2 - (lws.reduce((a, b) => a + b, 0) + lsp * (lw.length - 1)) / 2;\n lw.forEach((w, i) => {\n const tk = toks[k++], q = tk ? S(t - tk.start + .04, 18) : 1, wx = lx; lx += lws[i] + lsp;\n const em = s.sub.emph && w.replace(/[.,]/g, '') === s.sub.emph;\n rise(ctx, q, 26, () => text(ctx, w, wx, y + 520 + li * 84, { size: fs, weight: 700, color: em ? C.blue : C.ink2 }));\n });\n });\n }\n });\n },\n};\n\n// ---- overlay: a full-frame transparent clip (a media with alpha: true, e.g. an app UI animated in HTML and captured with\n// omitBackground) drawn straight over the frame, under the captions. With layer: 'over' it sits on top of the full-screen\n// camera (seam 0), e.g. the Instagram Reels UI over the creator's face for a comment CTA. Keep it inside a full-screen\n// stretch: a window move draws the camera card over the 'over' layer. {media, from, to, offset, fade (s, 0 = cut),\n// rect: [x, y, w, h] (default the whole layer: 0, 0, W, H; a 1920x1080 clip on a youtube 'over'/'top' layer = [0, 0, 1920, 1080])}\nconst overlay = {\n needs(s, t) { return t >= s.from - .1 && t \u003c= (s.to ?? 1e9) + .1 ? [[s.media, clipTime(t, s.from, s.offset || 0)]] : []; },\n draw(R, s, t) {\n const to = s.to ?? 1e9; if (t \u003c s.from || t > to) return;\n const img = R.frame(s.media, clipTime(t, s.from, s.offset || 0)); if (!img) return;\n const f = s.fade || 0, a = f ? Math.min(1, (t - s.from) / f, (to - t) / f) : 1;\n const [x, y, w, h] = s.rect || [0, 0, W, H], m = R.media?.(s.media), c = m?.crop; // crop: only the clip's visible box is stored\n const kx = c ? w / m.w : 0, ky = c ? h / m.h : 0;\n alpha(R.ctx, a, () => c ? R.ctx.drawImage(img, x + c[0] * kx, y + c[1] * ky, c[2] * kx, c[3] * ky) : R.ctx.drawImage(img, x, y, w, h));\n },\n};\n\n// ---- mdRec: step 1, the raw take. The card plays the source, a REC pill blinks, the timecode runs, then a stamp.\n// {step, media, y, clock (seconds shown at the end), stamp: {text, at}}\nconst mdRec = {\n needs(s, t) { return t >= s.from - .3 && t \u003c= (s.to ?? 1e9) + .7 ? [[s.media, clipTime(t, s.from, s.offset || 0)]] : []; },\n draw(R, s, t) {\n const { ctx } = R, w = 960, h = 540, x = W / 2 - w / 2, y = s.y ?? 600;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n const img = R.frame(s.media, clipTime(t, s.from, s.offset || 0));\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n card(R, x, y, w, h, img);\n const blink = Math.sin((t - s.from) * 6) > -.3 ? 1 : .25;\n ctx.beginPath(); ctx.roundRect(x + 28, y + 28, 168, 64, 32); ctx.fillStyle = 'rgba(20,22,27,.62)'; ctx.fill();\n alpha(ctx, blink, () => { ctx.beginPath(); ctx.arc(x + 62, y + 60, 13, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); });\n text(ctx, 'REC', x + 88, midBase(y + 60, 36), { size: 36, weight: 800, color: C.white });\n const secs = (s.clock || 55) * E.io(seg(t, s.from + .2, s.to ?? s.from + 2.5)), tc = fmtT(secs);\n const tw = measure(ctx, tc, 36, 600, 0, 'JetBrains Mono') + 44;\n ctx.beginPath(); ctx.roundRect(x + w - 28 - tw, y + h - 28 - 64, tw, 64, 32); ctx.fillStyle = 'rgba(20,22,27,.62)'; ctx.fill();\n text(ctx, tc, x + w - 28 - tw / 2, midBase(y + h - 28 - 32, 36), { size: 36, weight: 600, align: 'center', color: C.white, fam: 'JetBrains Mono' });\n });\n if (s.stamp) stamp(ctx, W / 2, y + h + 260, s.stamp.text, S(t - s.stamp.at, 16), { size: 88 });\n });\n },\n};\n\n// ---- mdWave: step 2, transcription. A waveform, a playhead that sweeps it, and each word it passes drops into a list\n// with its timestamp. {step, y, words: [[text, seconds]], sweepFrom, sweepTo, markAt (decimals turn blue)}\nconst mdWave = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, h = 300, n = 64, bw = (w - 80) / n;\n const ph = E.io(seg(t, s.sweepFrom, s.sweepTo)), phx = x + 40 + (w - 80) * ph, mk = s.markAt != null ? S(t - s.markAt, 12) : 0;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.2 });\n for (let i = 0; i \u003c n; i++) {\n const v = .15 + .85 * Math.pow(noise1(i * .55 + 3), 1.4) * (.55 + .45 * Math.abs(Math.sin(i * .9))), bh = (h - 70) * v, bx = x + 40 + i * bw;\n const on = bx \u003c phx; ctx.beginPath(); ctx.roundRect(bx + 2, y + h / 2 - bh / 2, bw - 5, bh, (bw - 5) / 2); ctx.fillStyle = on ? C.blue : C.dim; ctx.fill();\n }\n if (t > s.sweepFrom - .2) { ctx.fillStyle = C.ink; ctx.fillRect(phx - 2.5, y + 14, 5, h - 28); ctx.beginPath(); ctx.arc(phx, y + 14, 11, 0, TAU); ctx.fill(); }\n });\n const L = s.words.length;\n s.words.forEach(([wd, sec], i) => {\n const f = (i + .5) / L, at = s.sweepFrom + (s.sweepTo - s.sweepFrom) * f * .92, p = S(t - at, 13); if (p \u003c= .002) return;\n const ry = y + h + 100 + i * 116, fx = x + 40 + (w - 80) * f;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.4); ctx.translate((fx - 200) * (1 - p) * .35, -(ry - y - h / 2) * (1 - p));\n const tc = fmtT(sec), whole = tc.slice(0, 6), dec = tc.slice(6);\n text(ctx, whole, 100, midBase(ry, 52), { size: 52, weight: 600, color: C.ink2, fam: 'JetBrains Mono' });\n const dx = 100 + measure(ctx, whole, 52, 600, 0, 'JetBrains Mono');\n if (mk > .002) { ctx.beginPath(); ctx.roundRect(dx - 6, ry - 38, measure(ctx, dec, 52, 600, 0, 'JetBrains Mono') * mk + 12, 76, 12); ctx.fillStyle = 'rgba(37,142,255,.16)'; ctx.fill(); }\n text(ctx, dec, dx, midBase(ry, 52), { size: 52, weight: 700, color: mix(C.ink2, C.blue, mk), fam: 'JetBrains Mono' });\n text(ctx, wd, 520, midBase(ry, 66), { size: 66, weight: 800, ls: -1.5 });\n ctx.restore();\n });\n });\n },\n};\n\n// ---- mdVision: step 3, person segmentation. A camera frame; a scan line sweeps down and everything above it becomes\n// transparent (checkerboard) with the speaker outlined in blue; then a flip-book counter. {step, frameAt, y,\n// scanFrom, scanTo, flipAt, frames: [a, b] (counter range), w: 940 (card width)}\nlet VIS = null;\nconst mdVision = {\n needs(s, t, U) { return t >= s.from - .3 && t \u003c= (s.to ?? 1e9) + .7 && U ? [U.cam(s.frameAt), U.cut(s.frameAt)] : []; },\n draw(R, s, t) {\n const { ctx } = R, w = s.w ?? 940, h = Math.round(w * 1080 / 1200), x = W / 2 - w / 2, y = s.y ?? 600;\n const cam = R.img(R.U.cam(s.frameAt)), cut = R.img(R.U.cut(s.frameAt));\n const sc = E.io(seg(t, s.scanFrom, s.scanTo)), ly = y + h * sc, fl = s.flipAt != null ? S(t - s.flipAt, 12) : 0;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n for (let k = 3; k >= 1; k--) alpha(ctx, fl, () => { ctx.save(); ctx.translate(x + w / 2, y + h / 2); ctx.rotate(k * .045 * fl); ctx.translate(-(x + w / 2) + k * 22 * fl, -(y + h / 2) + k * 14 * fl); sheet(ctx, x - 12, y - 12, w + 24, h + 24, 30, { elev: .6 }); ctx.restore(); });\n sheet(ctx, x - 12, y - 12, w + 24, h + 24, 30, { elev: 1.3 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, 20); ctx.clip();\n if (cam) ctx.drawImage(cam, x, y, w, h);\n if (sc > .001 && cut) {\n ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, ly - y); ctx.clip();\n const q = 40; for (let yy = y; yy \u003c ly; yy += q) for (let xx = x; xx \u003c x + w; xx += q) { ctx.fillStyle = ((xx - x) / q + (yy - y) / q) % 2 ? C.sheet : '#E9E5DB'; ctx.fillRect(xx, yy, q, q); }\n if (!VIS) VIS = new OffscreenCanvas(cut.width, cut.height);\n const v = VIS.getContext('2d'); v.globalCompositeOperation = 'source-over'; v.clearRect(0, 0, VIS.width, VIS.height); v.drawImage(cut, 0, 0);\n v.globalCompositeOperation = 'source-in'; v.fillStyle = C.blue; v.fillRect(0, 0, VIS.width, VIS.height);\n for (let k = 0; k \u003c 12; k++) { const an = k * TAU / 12; ctx.drawImage(VIS, x + Math.cos(an) * 6, y + Math.sin(an) * 6, w, h); }\n ctx.drawImage(cut, x, y, w, h);\n ctx.restore();\n }\n ctx.restore();\n if (t > s.scanFrom - .1 && sc \u003c .999) {\n ctx.save(); ctx.shadowColor = 'rgba(37,142,255,.8)'; ctx.shadowBlur = 24; ctx.fillStyle = C.blue; ctx.fillRect(x - 24, ly - 3, w + 48, 6); ctx.restore();\n const lab = Math.round(sc * 100) + ' %', lw = measure(ctx, lab, 36, 700, 0, 'JetBrains Mono') + 40;\n ctx.beginPath(); ctx.roundRect(x + w + 24 - lw, ly - 70, lw, 56, 28); ctx.fillStyle = C.blue; ctx.fill();\n text(ctx, lab, x + w + 24 - lw / 2, midBase(ly - 42, 36), { size: 36, weight: 700, align: 'center', color: C.white, fam: 'JetBrains Mono' });\n }\n });\n if (s.flipAt != null && fl > .002) {\n const [a, b] = s.frames || [1, 1471], n = Math.round(lerp(a, b, E.out(seg(t, s.flipAt, (s.to ?? s.flipAt + 1.2) - .1))));\n const str = `Image ${String(n).padStart(4, '0')}`;\n rise(ctx, fl, 30, () => chip(ctx, W / 2, y + h + 130, str, { size: 50, h: 104, padX: 40, fill: C.ink, color: C.white, border: false }));\n }\n });\n },\n};\n\n// ---- mdDiffuse: step 4, image generation. A prompt types itself, then the picture resolves from big noisy pixels\n// to sharp. {step, src, y, prompt: {text, from, to}, genFrom, sharpAt}\nlet NOISE = null;\nconst mdDiffuse = {\n needs(s, t) { return t >= s.from - .3 && t \u003c= (s.to ?? 1e9) + .7 ? ['/p/' + s.src] : []; },\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, ih = 720, iy = y + 160, img = R.img('/p/' + s.src);\n const q = E.io(seg(t, s.genFrom, s.sharpAt)), started = t >= s.genFrom;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 50, () => {\n sheet(ctx, x, y, w, 110, 55, { elev: 1.1 });\n const n = Math.round(s.prompt.text.length * clamp((t - s.prompt.from) / (s.prompt.to - s.prompt.from))), str = s.prompt.text.slice(0, Math.max(0, n));\n text(ctx, str || 'Décris une image…', x + 44, midBase(y + 55, 40), { size: 40, weight: 600, color: str ? C.ink : C.ink3 });\n if (n \u003c s.prompt.text.length && t > s.prompt.from - .2 && Math.sin(t * 11) > -.2) { ctx.fillStyle = C.blue; ctx.fillRect(x + 48 + measure(ctx, str, 40, 600), y + 30, 3.5, 50); }\n const go = S(t - s.prompt.to, 14); ctx.beginPath(); ctx.arc(x + w - 55, y + 55, 36, 0, TAU); ctx.fillStyle = mix(C.dim, C.blue, go); ctx.fill();\n ctx.save(); ctx.strokeStyle = C.white; ctx.lineWidth = 6; ctx.lineCap = 'round'; ctx.lineJoin = 'round'; ctx.beginPath(); ctx.moveTo(x + w - 55, y + 72); ctx.lineTo(x + w - 55, y + 38); ctx.moveTo(x + w - 70, y + 52); ctx.lineTo(x + w - 55, y + 38); ctx.lineTo(x + w - 40, y + 52); ctx.stroke(); ctx.restore();\n });\n rise(ctx, S(t - s.genFrom + .3, 11), 60, () => {\n sheet(ctx, x - 12, iy - 12, w + 24, ih + 24, 30, { elev: 1.3 });\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, iy, w, ih, 20); ctx.clip();\n ctx.fillStyle = '#E9E5DB'; ctx.fillRect(x, iy, w, ih);\n if (img && started) {\n const px = Math.max(1, Math.round(120 * Math.pow(1 - q, 2.2)));\n if (px \u003c= 1) cover(ctx, img, x, iy, w, ih);\n else {\n const sw = Math.ceil(w / px), shh = Math.ceil(ih / px), oc = new OffscreenCanvas(sw, shh), o = oc.getContext('2d');\n cover(o, img, 0, 0, sw, shh); ctx.imageSmoothingEnabled = false; ctx.drawImage(oc, x, iy, sw * px, shh * px); ctx.imageSmoothingEnabled = true;\n }\n if (!NOISE) { NOISE = new OffscreenCanvas(256, 256); const g = NOISE.getContext('2d'), d = g.createImageData(256, 256); for (let i = 0; i \u003c d.data.length; i += 4) { const v = Math.random() * 255; d.data[i] = d.data[i + 1] = d.data[i + 2] = v; d.data[i + 3] = 255; } g.putImageData(d, 0, 0); }\n const na = (1 - q) * .55; if (na > .01) { ctx.globalAlpha = na; ctx.globalCompositeOperation = 'overlay'; const f = Math.floor(t * 30), ox = (f * 97) % 256, oy = (f * 61) % 256; for (let yy = iy - oy; yy \u003c iy + ih; yy += 256) for (let xx = x - ox; xx \u003c x + w; xx += 256) ctx.drawImage(NOISE, xx, yy); ctx.globalAlpha = 1; ctx.globalCompositeOperation = 'source-over'; }\n const fl = t >= s.sharpAt ? Math.exp(-(t - s.sharpAt) * 5) : 0; if (fl > .01) { ctx.fillStyle = `rgba(255,255,255,${fl * .7})`; ctx.fillRect(x, iy, w, ih); }\n }\n ctx.restore();\n const by = iy + ih + 80, bw = w - 260;\n ctx.beginPath(); ctx.roundRect(x, by - 8, bw, 16, 8); ctx.fillStyle = 'rgba(40,30,10,.08)'; ctx.fill();\n if (q > .001) { ctx.beginPath(); ctx.roundRect(x, by - 8, Math.max(16, bw * q), 16, 8); ctx.fillStyle = C.blue; ctx.fill(); }\n text(ctx, Math.round(q * 100) + ' %', x + w, midBase(by, 56), { size: 56, weight: 800, align: 'right', color: q > .999 ? C.blue : C.ink, fam: 'JetBrains Mono' });\n });\n });\n },\n};\n\n// ---- mdCode: step 5, music and sound effects written as code. Lines type themselves; each finished line fires its\n// effect (notes float up, the equaliser jumps). {step, y, file, lines: [{text, at, dur}], eq: false hides the equaliser}\nconst mdCode = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, lh = 100, top = 110, h = top + s.lines.length * lh + 50, fs = 46;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.3 });\n [0, 1, 2].forEach(i => { ctx.beginPath(); ctx.arc(x + 50 + i * 36, y + 52, 11, 0, TAU); ctx.fillStyle = C.dim; ctx.fill(); });\n text(ctx, s.file || 'musique.js', x + w / 2, midBase(y + 52, 34), { size: 34, weight: 600, align: 'center', color: C.ink2, fam: 'JetBrains Mono' });\n ctx.fillStyle = C.line; ctx.fillRect(x, y + 96, w, 2);\n s.lines.forEach((ln, i) => {\n const ly = y + top + 20 + i * lh, d = ln.dur ?? ln.text.length * .03, n = Math.round(ln.text.length * clamp((t - ln.at) / d));\n const done = t >= ln.at + d, act = t >= ln.at && (i === s.lines.length - 1 || t \u003c s.lines[i + 1].at);\n if (act) { ctx.fillStyle = 'rgba(37,142,255,.07)'; ctx.fillRect(x + 4, ly - lh / 2 + 4, w - 8, lh - 8); }\n text(ctx, String(i + 1), x + 70, midBase(ly, 34), { size: 34, weight: 500, align: 'right', color: C.ink3, fam: 'JetBrains Mono' });\n if (n \u003c= 0) return;\n const str = ln.text.slice(0, n); let cx = x + 110;\n for (const m of str.matchAll(/('[^']*'?|\\d[\\d.]*|[A-Za-z_]+|[^A-Za-z_\\d']+)/g)) {\n const tk = m[0], col = /^'/.test(tk) || /^\\d/.test(tk) ? C.blue : /^(const|await)$/.test(tk) ? C.blue2 : /^[A-Za-z_]/.test(tk) ? C.ink : C.ink3;\n text(ctx, tk, cx, midBase(ly, fs), { size: fs, weight: /^[A-Za-z_]/.test(tk) ? 600 : 500, color: col, fam: 'JetBrains Mono' }); cx += measure(ctx, tk, fs, /^[A-Za-z_]/.test(tk) ? 600 : 500, 0, 'JetBrains Mono');\n }\n if (!done && Math.sin(t * 12) > -.2) { ctx.fillStyle = C.blue; ctx.fillRect(cx + 2, ly - 26, 3.5, 50); }\n if (done && s.notes !== false) for (let k = 0; k \u003c 3; k++) { // the line \"plays\": notes float up from its end\n const u = (t - ln.at - d - k * .12) / 1.1; if (u \u003c= 0 || u >= 1) continue;\n alpha(ctx, (1 - u) * Math.min(1, u * 8), () => note(ctx, cx + 40 + k * 46 + 20 * Math.sin(u * 4 + k), ly - 10 - 170 * E.out(u), .9));\n }\n });\n });\n // equaliser under the editor, driven by the lines that already \"play\"\n const live = s.lines.filter(l => t >= l.at + (l.dur ?? l.text.length * .03)).length / s.lines.length;\n const kick = s.lines.reduce((m, l) => { const e = t - l.at - (l.dur ?? l.text.length * .03); return e >= 0 ? Math.max(m, Math.exp(-e * 5)) : m; }, 0);\n const ey = y + h + 200, nb = 30, bw = w / nb;\n if (s.eq !== false) alpha(ctx, S(t - s.from - .3, 10), () => {\n for (let i = 0; i \u003c nb; i++) {\n const v = .08 + (.25 + .75 * live) * Math.abs(Math.sin(t * (5 + i * .37) + i * 1.3)) * (.5 + .5 * Math.sin(t * 2.1 + i * .5)) * (.6 + .4 * kick);\n const bh = 14 + 230 * v; ctx.beginPath(); ctx.roundRect(x + i * bw + 5, ey - bh / 2, bw - 10, bh, (bw - 10) / 2); ctx.fillStyle = i % 5 === 0 ? C.blue : mix(C.dim, C.blue, live * .6); ctx.fill();\n }\n });\n });\n },\n};\n\n// ---- mdAssemble: step 6, the edit. Clips fly in from everywhere and snap onto their tracks, a playhead runs the\n// cut, a stamp lands. {step, y, tracks: [{label, clips: [[a, b]], cam: true}], flyFrom, stagger, playFrom, playTo, stamp: {text, at}}\nconst mdAssemble = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, rh = 150, lx = x + 240, lw = w - 240 - 36, h = s.tracks.length * rh + 40;\n const ph = E.io(seg(t, s.playFrom, s.playTo)), phx = lx + lw * ph;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .05, 11), 50, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.2 });\n s.tracks.forEach((tr, i) => {\n const ry = y + 20 + i * rh;\n if (i) { ctx.fillStyle = C.line; ctx.fillRect(x + 30, ry, w - 60, 1.5); }\n text(ctx, tr.label, x + 36, midBase(ry + rh / 2, 42), { size: 42, weight: 700, ls: -.5 });\n });\n });\n let k = 0;\n s.tracks.forEach((tr, i) => tr.clips.forEach(([a, b], j) => {\n const idx = k++, at = s.flyFrom + idx * (s.stagger ?? .075), p = S(t - at, 11); if (p \u003c= .002) return;\n const r = hash(idx * 7.3 + 1), r2 = hash(idx * 3.1 + 5), cx = lx + lw * a, cw = Math.max(26, lw * (b - a) - 6), cy = y + 20 + i * rh + 18, ch = rh - 36;\n const dx = (r - .5) * 1400, dy = (r2 \u003c .5 ? -1 : 1) * (500 + 400 * r2), rot = (r - .5) * .9;\n ctx.save(); ctx.globalAlpha *= Math.min(1, p * 1.5); ctx.translate(cx + cw / 2 + dx * (1 - p), cy + ch / 2 + dy * (1 - p)); ctx.rotate(rot * (1 - p));\n const on = ph > a && ph \u003c b + .01 ? 1 : 0;\n if (tr.cam && R.cam) { ctx.save(); ctx.beginPath(); ctx.roundRect(-cw / 2, -ch / 2, cw, ch, 12); ctx.clip(); cover(ctx, R.cam, -cw / 2, -ch / 2, cw, ch, 0, [.5, .35]); ctx.restore(); ctx.beginPath(); ctx.roundRect(-cw / 2, -ch / 2, cw, ch, 12); ctx.strokeStyle = on ? C.blue : 'rgba(20,22,27,.12)'; ctx.lineWidth = on ? 5 : 2; ctx.stroke(); }\n else if (tr.text) {\n ctx.beginPath(); ctx.roundRect(-cw / 2, -ch / 2, cw, ch, 12); ctx.fillStyle = on ? C.blue : C.sheet; ctx.fill(); ctx.strokeStyle = on ? C.blue : C.line; ctx.lineWidth = 2; ctx.stroke();\n text(ctx, 'Aa', -cw / 2 + 16, midBase(0, 38), { size: 38, weight: 800, color: on ? C.white : C.ink });\n }\n else {\n ctx.beginPath(); ctx.roundRect(-cw / 2, -ch / 2, cw, ch, 12); ctx.fillStyle = on || tr.blue ? C.blue : C.tint; ctx.fill();\n ctx.fillStyle = on || tr.blue ? 'rgba(255,255,255,.85)' : 'rgba(37,142,255,.7)';\n const nb = Math.max(1, Math.floor((cw - 14) / 11)); for (let q = 0; q \u003c nb; q++) { const hh = (ch - 22) * (.2 + .8 * Math.abs(Math.sin(q * 1.3 + i * 2 + j))); ctx.fillRect(-cw / 2 + 8 + q * 11, -hh / 2, 5, hh); }\n }\n ctx.restore();\n }));\n if (t > s.playFrom - .2) alpha(ctx, vis(t, s.playFrom - .2, 1e9, 14), () => {\n ctx.fillStyle = C.ink; ctx.fillRect(phx - 2.5, y + 6, 5, h - 12);\n ctx.beginPath(); ctx.moveTo(phx - 16, y - 8); ctx.lineTo(phx + 16, y - 8); ctx.lineTo(phx, y + 14); ctx.closePath(); ctx.fill();\n });\n if (s.stamp) stamp(ctx, W / 2, y + h + 190, s.stamp.text, S(t - s.stamp.at, 16), { size: 70, rot: -.05 });\n });\n },\n};\n\n\n// a mini 9:16 short (paper top with a card in variant k, the live camera below, a caption pill at capAt of the height).\n// Used by mdStills and mdRender.\nfunction miniShort(R, ctx, x, y, w, h, k, o = {}) {\n ctx.save(); ctx.beginPath(); ctx.roundRect(x, y, w, h, o.r ?? 14); ctx.clip();\n ctx.fillStyle = C.paper; ctx.fillRect(x, y, w, h);\n const sh = h * .5; if (R.cam) cover(ctx, R.cam, x, y + sh, w, h - sh, 0, [.5, .3]);\n const cx = x + w * .1, cw = w * .8, cy = y + sh * .2, ch = sh * .64, v = ((k % 4) + 4) % 4;\n ctx.fillStyle = C.sheet; ctx.beginPath(); ctx.roundRect(cx, cy, cw, ch, w * .05); ctx.fill();\n ctx.strokeStyle = 'rgba(60,45,20,.1)'; ctx.lineWidth = 1.5; ctx.stroke();\n if (v === 0) text(ctx, '92', cx + cw / 2, midBase(cy + ch / 2, w * .3), { size: w * .3, weight: 800, align: 'center', color: C.blue, ls: -2 });\n else if (v === 1) [.85, .55, .3].forEach((f, i) => { ctx.beginPath(); ctx.roundRect(cx + cw * .12, cy + ch * (.22 + i * .24), cw * .76 * f, ch * .14, ch * .07); ctx.fillStyle = i ? C.dim : C.blue; ctx.fill(); });\n else if (v === 2) { ctx.beginPath(); ctx.arc(cx + cw * .22, cy + ch * .32, cw * .09, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); [.6, .78, .45].forEach((f, i) => { ctx.beginPath(); ctx.roundRect(cx + cw * (i ? .12 : .38), cy + ch * (.25 + i * .24), cw * f * (i ? 1 : .8), ch * .1, ch * .05); ctx.fillStyle = C.dim; ctx.fill(); }); }\n else { ctx.beginPath(); ctx.arc(cx + cw / 2, cy + ch / 2, ch * .3, 0, TAU); ctx.fillStyle = C.blue; ctx.fill(); checkIcon(ctx, cx + cw / 2, cy + ch / 2, ch * .3, C.white); }\n const pw = w * .62, ph = h * .055, py = y + h * (o.capAt ?? .6);\n ctx.beginPath(); ctx.roundRect(x + w / 2 - pw / 2, py - ph / 2, pw, ph, ph / 2); ctx.fillStyle = C.white; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + w / 2 - pw / 2 + ph * .4, py - ph * .17, pw * .42, ph * .34, ph * .17); ctx.fillStyle = C.ink; ctx.fill();\n ctx.beginPath(); ctx.roundRect(x + w / 2 - pw / 2 + ph * .4 + pw * .46, py - ph * .17, pw * .3, ph * .34, ph * .17); ctx.fillStyle = C.blue; ctx.fill();\n ctx.restore();\n}\n\n// ---- mdCut: the tightening. A waveform with its silences; they get marked, close up, the speed stamp lands and a\n// big clock runs down. {step, y, markAt, closeAt, speedAt, speed: '×1.15', factor: 1.15, clock: {start: 60, stop: 40, at, endAt}}\nconst CUTSEG = [[9, 0], [4, 1], [7, 0], [5, 1], [10, 0], [3, 1], [8, 0], [6, 1], [9, 0]]; // [bars, is silence]\nconst mdCut = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, h = 300;\n const mk = s.markAt != null ? S(t - s.markAt, 12) : 0, cl = s.closeAt != null ? E.io(seg(t, s.closeAt, s.closeAt + .55)) : 0;\n const sp = s.speedAt != null ? E.io(seg(t, s.speedAt, s.speedAt + .5)) : 0, k = 1 - (1 - 1 / (s.factor || 1.15)) * sp;\n const total = CUTSEG.reduce((a, [n]) => a + n, 0), kept = CUTSEG.reduce((a, [n, g]) => a + (g ? 0 : n), 0), bw = (w - 80) / total;\n const span = (total - (total - kept) * cl) * bw * k;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n sheet(ctx, x, y, w, h, 36, { elev: 1.2 });\n let bx = x + w / 2 - span / 2, bi = 0;\n for (const [nb, gap] of CUTSEG) {\n const ww = bw * k * (gap ? 1 - cl : 1);\n if (gap && ww > .4) {\n alpha(ctx, mk * (1 - cl), () => {\n ctx.beginPath(); ctx.roundRect(bx + 2, y + 34, nb * ww - 4, h - 68, 14); ctx.fillStyle = 'rgba(37,142,255,.12)'; ctx.fill();\n ctx.setLineDash([12, 9]); ctx.strokeStyle = C.blue; ctx.lineWidth = 3; ctx.stroke(); ctx.setLineDash([]);\n });\n alpha(ctx, 1 - cl, () => { for (let i = 0; i \u003c nb; i++) { ctx.beginPath(); ctx.roundRect(bx + i * ww + 2, y + h / 2 - 6, Math.max(1, ww - 5), 12, 6); ctx.fillStyle = C.dim; ctx.fill(); } });\n } else if (!gap) for (let i = 0; i \u003c nb; i++) {\n const v = .2 + .8 * Math.pow(noise1((bi + i) * .55 + 3), 1.3) * (.55 + .45 * Math.abs(Math.sin((bi + i) * .9))), bh = (h - 80) * v;\n ctx.beginPath(); ctx.roundRect(bx + i * ww + 2, y + h / 2 - bh / 2, ww - 5, bh, (ww - 5) / 2); ctx.fillStyle = mix(C.ink2, C.blue, cl); ctx.fill();\n }\n bx += nb * ww; bi += nb;\n }\n });\n if (s.speedAt != null) stamp(ctx, W / 2, y + h + 120, s.speed || '×1.15', S(t - s.speedAt, 16), { size: 76 });\n if (s.clock) {\n const c = s.clock, q = E.io(seg(t, c.at, c.endAt)), v = Math.round(lerp(c.start, c.stop, q)), str = `${Math.floor(v / 60)}:${String(v % 60).padStart(2, '0')}`;\n rise(ctx, S(t - c.at + .35, 12), 40, () => text(ctx, str, W / 2, midBase(y + h + 350, 210), { size: 210, weight: 800, align: 'center', ls: -6, fam: 'JetBrains Mono', color: q >= .999 ? C.blue : C.ink }));\n }\n });\n },\n};\n\n// ---- mdStills: the self-review. A contact sheet of stills drops in, a loupe scans it, one still gets flagged (its\n// caption covers the card), then the caption slides into place and the flag turns into a check.\n// {step, y, enterAt, scanFrom, flagAt, flag (index 0..7), fixAt}\nconst mdStills = {\n draw(R, s, t) {\n const { ctx } = R, cols = 4, gap = 24, x0 = 60, tw = (960 - gap * (cols - 1)) / cols, th = tw * 16 / 9, y0 = s.y ?? 560;\n const fi = s.flag ?? 5, fl = s.flagAt != null ? S(t - s.flagAt, 14) : 0, fx = s.fixAt != null ? S(t - s.fixAt, 11) : 0;\n const ctr = i => [x0 + (i % cols) * (tw + gap) + tw / 2, y0 + Math.floor(i / cols) * (th + gap) + th / 2];\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n for (let i = 0; i \u003c 8; i++) {\n const [cx, cy] = ctr(i), x = cx - tw / 2, y = cy - th / 2, p = S(t - (s.enterAt ?? s.from + .1) - i * .06, 12); if (p \u003c= .002) continue;\n rise(ctx, p, 50, () => {\n const isF = i === fi;\n sheet(ctx, x - 6, y - 6, tw + 12, th + 12, 18, { elev: .9 });\n miniShort(R, ctx, x, y, tw, th, i, { capAt: isF ? lerp(.36, .62, E.io(fx)) : .62 });\n if (!isF) alpha(ctx, fl * .5 * (1 - fx), () => { ctx.beginPath(); ctx.roundRect(x - 6, y - 6, tw + 12, th + 12, 18); ctx.fillStyle = C.paper; ctx.fill(); });\n if (isF && fl > .002) {\n ctx.beginPath(); ctx.roundRect(x - 14, y - 14, tw + 28, th + 28, 24); ctx.strokeStyle = C.blue; ctx.lineWidth = 7 * fl; ctx.stroke();\n ctx.save(); ctx.translate(x + tw - 2, y + 2); const kk = .6 + .4 * fl; ctx.scale(kk, kk);\n ctx.beginPath(); ctx.arc(0, 0, 40, 0, TAU); ctx.fillStyle = C.blue; ctx.fill();\n alpha(ctx, 1 - fx, () => text(ctx, '!', 0, midBase(0, 54), { size: 54, weight: 800, align: 'center', color: C.white }));\n if (fx > .002) checkIcon(ctx, 0, 0, 40, C.white, fx);\n ctx.restore();\n }\n });\n }\n // the loupe: snakes over the sheet and stops on the flagged still, magnifying what is under it\n if (s.scanFrom != null) {\n const order = [0, 1, 2, 3, 7, 6, 5, 4], path = order.slice(0, order.indexOf(fi) + 1).map(ctr);\n path[path.length - 1] = [path.at(-1)[0], path.at(-1)[1] - th * .14]; // parks on the flagged caption, over the card it covers\n const end = s.flagAt ?? s.scanFrom + 1.5, q = E.io(seg(t, s.scanFrom, end)) * (path.length - 1), j = Math.min(path.length - 2, Math.floor(q)), f = q - j;\n const [lx, ly] = path.length > 1 ? [lerp(path[j][0], path[j + 1][0], f), lerp(path[j][1], path[j + 1][1], f)] : path[0];\n const a = S(t - s.scanFrom + .25, 12) * (1 - S(t - (s.fixAt ?? end + 1) - .5, 10));\n alpha(ctx, a, () => {\n const r = 96, z = 1.7, sr = r / z, m = ctx.getTransform();\n ctx.save(); ctx.beginPath(); ctx.arc(lx, ly, r, 0, TAU); ctx.clip();\n ctx.drawImage(ctx.canvas, m.a * (lx - sr) + m.e, m.d * (ly - sr) + m.f, 2 * sr * m.a, 2 * sr * m.d, lx - r, ly - r, 2 * r, 2 * r);\n ctx.restore();\n ctx.lineCap = 'round'; ctx.strokeStyle = C.ink;\n ctx.lineWidth = 24; ctx.beginPath(); ctx.moveTo(lx + r * .78, ly + r * .78); ctx.lineTo(lx + r * 1.45, ly + r * 1.45); ctx.stroke();\n ctx.lineWidth = 11; ctx.beginPath(); ctx.arc(lx, ly, r, 0, TAU); ctx.stroke();\n ctx.lineWidth = 6; ctx.strokeStyle = 'rgba(255,255,255,.7)'; ctx.beginPath(); ctx.arc(lx, ly, r - 22, -2.6, -1.9); ctx.stroke();\n });\n }\n });\n },\n};\n\n// ---- mdRender: the render. A browser window renders the short frame by frame (preview, counter, progress), then\n// two bars compare the video length with the render time. {step, y, total, renderFrom, renderTo, url,\n// bars: [{label, value, frac, at, blue}]}\nconst mdRender = {\n draw(R, s, t) {\n const { ctx } = R, x = 60, w = 960, y = s.y ?? 600, h = 520, tot = s.total || 2760;\n const q = E.io(seg(t, s.renderFrom, s.renderTo)), n = Math.round(tot * q), done = q >= .999;\n slide(ctx, s, t, () => {\n stepHead(ctx, s, t, R);\n rise(ctx, S(t - s.from - .1, 11), 60, () => {\n sheet(ctx, x, y, w, h, 30, { elev: 1.3 });\n [0, 1, 2].forEach(i => { ctx.beginPath(); ctx.arc(x + 46 + i * 34, y + 44, 10, 0, TAU); ctx.fillStyle = C.dim; ctx.fill(); });\n ctx.beginPath(); ctx.roundRect(x + 170, y + 20, w - 210, 48, 24); ctx.fillStyle = 'rgba(40,30,10,.06)'; ctx.fill();\n text(ctx, s.url || 'localhost:3000/render', x + 200, midBase(y + 44, 28), { size: 28, weight: 500, color: C.ink2, fam: 'JetBrains Mono' });\n ctx.fillStyle = C.line; ctx.fillRect(x, y + 88, w, 2);\n const pw = 232, ph = pw * 16 / 9, px = x + 46, py = y + 88 + (h - 88 - ph) / 2;\n miniShort(R, ctx, px, py, pw, ph, Math.floor(n / 45), { r: 12 });\n const rx = px + pw + 60, bwid = x + w - 50 - rx;\n text(ctx, 'Image', rx, midBase(y + 170, 42), { size: 42, weight: 600, color: C.ink2 });\n text(ctx, String(n).padStart(4, '0'), rx, midBase(y + 275, 124), { size: 124, weight: 800, ls: -4, fam: 'JetBrains Mono', color: done ? C.blue : C.ink });\n text(ctx, '/ ' + tot, rx + 6, midBase(y + 368, 44), { size: 44, weight: 600, color: C.ink3, fam: 'JetBrains Mono' });\n ctx.beginPath(); ctx.roundRect(rx, y + 432, bwid, 18, 9); ctx.fillStyle = 'rgba(40,30,10,.08)'; ctx.fill();\n if (q > .001) { ctx.beginPath(); ctx.roundRect(rx, y + 432, Math.max(18, bwid * q), 18, 9); ctx.fillStyle = C.blue; ctx.fill(); }\n });\n (s.bars || []).forEach((b, i) => {\n const p = S(t - b.at, 11), by = y + h + 105 + i * 118, bx = x + 210, full = w - 210 - 190; if (p \u003c= .002) return;\n rise(ctx, Math.min(1, p * 1.5), 24, () => {\n text(ctx, b.label, x, midBase(by, 50), { size: 50, weight: 800, ls: -1 });\n const bl = Math.max(56, full * b.frac * E.out(p));\n ctx.beginPath(); ctx.roundRect(bx, by - 30, bl, 60, 30); ctx.fillStyle = b.blue ? C.blue : C.ink; ctx.fill();\n text(ctx, b.value, bx + bl + 24, midBase(by, 56), { size: 56, weight: 800, fam: 'JetBrains Mono', color: b.blue ? C.blue : C.ink });\n });\n });\n });\n },\n};\n\n\n// ---- race: two models, same prompt, side by side. Each panel = a paper sheet with a header bar (logo, name, live\n// cost counter + elapsed time) over its generation playing. Counters run from 0 to the real cost between countFrom\n// and each panel's own finish (scaled to its real duration, so the faster model stops first and gets a check).\n// winnerAt: the cheaper panel gets the blue ring, its price turns blue, and `chip` lands centred below.\n// {header?: {text, at}, footer?: {text, at, logo}, countFrom, countTo, winnerAt, chip?: {text, at}, stack?: bool,\n// panels: [{name, logo: 'claude'|'codex'|'openai', media, offset, cost, costSuffix, time (s), ar}]}\nLOGO.openai = (ctx, cx, cy, r, col) => { // six interlocking petals, drawn as rotated rounded strokes\n ctx.save(); ctx.strokeStyle = col; ctx.lineWidth = r * .17; ctx.lineJoin = 'round';\n for (let i = 0; i \u003c 6; i++) { ctx.save(); ctx.translate(cx, cy); ctx.rotate(i * TAU / 6); ctx.beginPath(); ctx.roundRect(-r * .2, -r * .92, r * .4, r * .9, r * .2); ctx.stroke(); ctx.restore(); }\n ctx.restore();\n};\nexport const money = (v, suffix = '') => '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

+ (v >= 100 ? v.toFixed(0) : v.toFixed(2)) + suffix;\nexport const clock = sec => { sec = Math.round(sec); const h = Math.floor(sec / 3600), m = Math.floor(sec % 3600 / 60), s_ = sec % 60; return h ? `${h}h ${String(m).padStart(2, '0')}m` : m ? `${m}m ${String(s_).padStart(2, '0')}s` : `${s_}s`; };\nexport function raceTiming(s) { // per-panel finish time: the slowest one ends at countTo\n const maxT = Math.max(...s.panels.map(p => p.time || 1));\n return s.panels.map(p => s.countFrom + (s.countTo - s.countFrom) * Math.max(.35, (p.time || maxT) / maxT));\n}\nexport function raceLayout(s, H_) {\n const n = s.panels.length, top = s.header ? 150 : 44, bottom = (s.footer ? 70 : 30) + (s.chip ? 110 : 0), gap = 30, m = 44;\n const stack = s.stack ?? H_ > W * 1.2;\n if (stack) { const ph = (H_ - top - bottom - gap * (n - 1)) / n; return s.panels.map((_, i) => [m, top + i * (ph + gap), W - 2 * m, ph]); }\n const pw = (W - 2 * m - gap * (n - 1)) / n, hb = Math.round(clamp(pw * .1, 72, 104)), ph = Math.min(H_ - top - bottom, hb + pw * (s.panels[0].ar || .625));\n const y0 = s.chip ? top + 6 : top + (H_ - top - bottom - ph) / 2; // keep room under the panels for the verdict chip\n return s.panels.map((_, i) => [m + i * (pw + gap), y0, pw, ph]);\n}\n// Grid mode (`cols: n`): n models side by side, one row per reasoning level (3 models x 2 efforts on a square).\n// `columns: [{name, logo}]` heads each column once (without it each panel shows its logo + name in its bar, time on the clip); a panel = clip on top, elapsed time + live cost in a bar under it,\n// `effort` pinned on the clip, `note` ('3 tries') pinned top right, `failed: {title, sub}` replaces the clip.\n// The cheapest working panel wins. {cols, columns, ar, header?, footer?, countFrom, countTo, winnerAt, chip?, panels}\nexport function raceGridLayout(s, H_) {\n const n = s.panels.length, cols = s.cols, rows = Math.ceil(n / cols), m = 30, gap = 18, bar = 62;\n const top = (s.header ? 132 : 30) + (s.columns ? 66 : 0), bottom = (s.footer ? 66 : 24) + (s.chip ? 104 : 0);\n const pw = (W - 2 * m - gap * (cols - 1)) / cols, ph = 10 + (pw - 20) * (s.ar || .75) + bar;\n const off = Math.max(0, (H_ - top - bottom - rows * ph - (rows - 1) * gap) / 2), y0 = top + off; // header, heads and grid move as one block\n return { bar, off, boxes: s.panels.map((_, i) => [m + (i % cols) * (pw + gap), y0 + Math.floor(i / cols) * (ph + gap), pw, ph]) };\n}\nfunction raceGrid(R, s, t) {\n const { ctx } = R, H_ = H, ends = raceTiming(s), { bar, off, boxes } = raceGridLayout(s, H_);\n const ok = s.panels.map((p, i) => p.failed ? Infinity : p.cost), win = s.winner ?? ok.indexOf(Math.min(...ok));\n env(ctx, s, t, () => {\n if (s.header) { const p = S(t - s.header.at + (s.pre || 0), 13); rise(ctx, p, 24, () => text(ctx, s.header.text, W / 2, 88 + off * .6, { size: 52, weight: 800, ls: -1.3, align: 'center', depth: s.header.depth })); }\n (s.columns || []).forEach((c, j) => {\n const [x, y, w] = boxes[j], p = S(t - s.from + (s.pre || 0) - j * .08, 12), cy = y - 40, ns = 34, lg = 40;\n rise(ctx, p, 30, () => {\n const tw = measure(ctx, c.name, ns, 800, -.8), x0 = x + w / 2 - (tw + lg + 12) / 2;\n if (isImgLogo(c.logo)) logoImg(R, ctx, c.logo, x0, cy - lg / 2, lg, 8); else if (LOGO[c.logo]) LOGO[c.logo](ctx, x0 + lg / 2, cy, lg / 2, C.ink);\n text(ctx, c.name, x0 + lg + 12, midBase(cy, ns), { size: ns, weight: 800, ls: -.8 });\n });\n });\n s.panels.forEach((pn, i) => {\n const [x, y, w, h] = boxes[i], p = S(t - s.from + (s.pre || 0) - .15 - i * .06, 12), mh = h - bar - 10;\n rise(ctx, p, 50, () => {\n const wp = s.winnerAt != null && i === win ? S(t - s.winnerAt, 12) : 0;\n sheet(ctx, x, y, w, h, 22, { elev: 1.1 });\n if (pn.failed) {\n ctx.save(); ctx.beginPath(); ctx.roundRect(x + 10, y + 10, w - 20, mh, 14); ctx.fillStyle = 'rgba(40,30,10,.05)'; ctx.fill(); ctx.clip();\n ctx.strokeStyle = 'rgba(40,30,10,.07)'; ctx.lineWidth = 2;\n for (let k = -mh; k \u003c w; k += 22) { ctx.beginPath(); ctx.moveTo(x + 10 + k, y + 10 + mh); ctx.lineTo(x + 10 + k + mh, y + 10); ctx.stroke(); }\n ctx.restore();\n const cx = x + w / 2, cy = y + 10 + mh / 2;\n text(ctx, pn.failed.title, cx, midBase(cy - (pn.failed.sub ? 18 : 0), 34), { size: 34, weight: 800, ls: -.8, align: 'center', color: C.ink2 });\n if (pn.failed.sub) text(ctx, pn.failed.sub, cx, midBase(cy + 26, 24), { size: 24, weight: 600, align: 'center', color: C.ink3 });\n } else {\n cover(ctx, R.frame(pn.media, clipTime(t, s.from, pn.offset || 0)), x + 10, y + 10, w - 20, mh, 14, pn.focus);\n ctx.save(); ctx.beginPath(); ctx.roundRect(x + 10, y + 10, w - 20, mh, 14); ctx.strokeStyle = 'rgba(20,22,27,.08)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n }\n if (pn.effort) chip(ctx, x + 22, y + 40, pn.effort, { size: 22, h: 40, padX: 14, weight: 800, align: 'left', fill: C.ink, color: C.white, border: false, elev: .6 });\n if (pn.note) chip(ctx, x + w - 22, y + mh - 20, pn.note, { size: 22, h: 40, padX: 14, weight: 800, align: 'right', fill: C.white, color: C.ink, border: false, elev: .6 });\n // bar: elapsed time left (check when done), live cost right. No column heads: logo + name left, time on the clip\n const k = E.cubic(seg(t, s.countFrom, ends[i])), done = t >= ends[i], cy = y + h - bar / 2 - 4, cs = 40, ts = 28;\n text(ctx, money(pn.cost * k, done ? (pn.costSuffix || '') : ''), x + w - 22, midBase(cy, cs), { size: cs, weight: 800, ls: -1, align: 'right', color: mix(C.ink, C.blue, wp) });\n if (!s.columns) {\n const lg = 36;\n if (isImgLogo(pn.logo)) logoImg(R, ctx, pn.logo, x + 22, cy - lg / 2, lg, 8); else if (LOGO[pn.logo]) LOGO[pn.logo](ctx, x + 22 + lg / 2, cy, lg / 2, C.ink);\n text(ctx, pn.name, x + 22 + lg + 12, midBase(cy, 32), { size: 32, weight: 800, ls: -.8 });\n if (pn.time) chip(ctx, x + w - 22, y + 40, clock(pn.time * k), { size: 22, h: 40, padX: 14, weight: 800, align: 'right', fill: C.white, color: C.ink, border: false, elev: .6 });\n } else if (pn.time) {\n const dp = E.out(seg(t, ends[i], ends[i] + .35)), cw = done && !pn.failed ? ts * 1.05 : 0;\n if (cw) checkIcon(ctx, x + 22 + ts * .45, cy, ts * .9, C.ink2, dp);\n text(ctx, clock(pn.time * k), x + 22 + cw, midBase(cy, ts), { size: ts, weight: 600, color: C.ink3 });\n }\n if (wp > .002) alpha(ctx, wp, () => { ctx.beginPath(); ctx.roundRect(x - 7 - 5 * (1 - wp), y - 7 - 5 * (1 - wp), w + 14 + 10 * (1 - wp), h + 14 + 10 * (1 - wp), 28); ctx.strokeStyle = C.blue; ctx.lineWidth = 5; ctx.stroke(); });\n });\n });\n if (s.chip) {\n const p = S(t - s.chip.at, 13), yb = Math.max(...boxes.map(q => q[1] + q[3]));\n if (p > .002) rise(ctx, p, 20, () => chip(ctx, W / 2, s.chip.y ?? Math.min(H_ - 120, yb + 62), s.chip.text, { size: 38, h: 80, fill: C.blue, color: C.white, border: false }));\n }\n if (s.footer) {\n const p = S(t - s.footer.at + (s.pre || 0), 13);\n rise(ctx, p, 16, () => text(ctx, s.footer.text, W / 2, midBase(H_ - 40, 28), { size: 28, weight: 700, ls: -.4, align: 'center', color: C.ink2 }));\n }\n });\n}\nconst race = {\n needs(s, t) { return t >= s.from - .3 && t \u003c= (s.to ?? 1e9) + .7 ? s.panels.filter(p => p.media).map(p => [p.media, clipTime(t, s.from, p.offset || 0)]) : []; },\n draw(R, s, t) {\n if (s.cols) return raceGrid(R, s, t);\n const { ctx } = R, H_ = H, ends = raceTiming(s), boxes = raceLayout(s, H_);\n const costs = s.panels.map(p => p.cost), win = costs.indexOf(Math.min(...costs));\n env(ctx, s, t, pin => {\n if (s.header) { const p = S(t - s.header.at + (s.pre || 0), 13); rise(ctx, p, 24, () => text(ctx, s.header.text, W / 2, 104, { size: 58, weight: 800, ls: -1.4, align: 'center' })); }\n s.panels.forEach((pn, i) => {\n const [x, y, w, h] = boxes[i], hb = Math.round(clamp(w * .1, 72, 104)), p = S(t - s.from + (s.pre || 0) - i * .08, 12);\n rise(ctx, p, 60, () => {\n const wp = s.winnerAt != null && i === win ? S(t - s.winnerAt, 12) : 0;\n sheet(ctx, x, y, w, h, 26, { elev: 1.1 });\n cover(ctx, R.frame(pn.media, clipTime(t, s.from, pn.offset || 0)), x + 10, y + hb, w - 20, h - hb - 10, 18);\n ctx.save(); ctx.beginPath(); ctx.roundRect(x + 10, y + hb, w - 20, h - hb - 10, 18); ctx.strokeStyle = 'rgba(20,22,27,.08)'; ctx.lineWidth = 1.5; ctx.stroke(); ctx.restore();\n // header bar: logo + name left, elapsed time + live cost right\n const cy = y + hb / 2 + 2, ns = Math.round(clamp(w * .05, 34, 50)), cs = Math.round(ns * 1.22), lr = ns * .52;\n if (LOGO[pn.logo]) LOGO[pn.logo](ctx, x + 26 + lr, cy, lr, C.ink);\n text(ctx, pn.name, x + 26 + lr * 2 + 16, midBase(cy, ns), { size: ns, weight: 800, ls: -1 });\n const k = E.cubic(seg(t, s.countFrom, ends[i])), done = t >= ends[i], dp = S(t - ends[i], 14);\n const cost = money(pn.cost * k, done ? (pn.costSuffix || '') : ''), cw = measure(ctx, money(pn.cost, pn.costSuffix || ''), cs, 800, -1);\n text(ctx, cost, x + w - 26, midBase(cy, cs), { size: cs, weight: 800, ls: -1, align: 'right', color: mix(C.ink, C.blue, wp) });\n if (pn.time) {\n const ts = Math.round(ns * .76), tx = x + w - 26 - cw - 22;\n text(ctx, clock(pn.time * k), tx, midBase(cy, ts), { size: ts, weight: 600, align: 'right', color: C.ink3 });\n if (dp > .002) checkIcon(ctx, tx - measure(ctx, clock(pn.time), ts, 600) - ts * .7, cy, ts * .9, C.ink2, E.out(seg(t, ends[i], ends[i] + .35)));\n }\n // reasoning effort, pinned on the media so it is never missed\n if (pn.effort) { const es = Math.round(clamp(w * .036, 26, 34)); chip(ctx, x + 26, y + hb + 18 + es, pn.effort, { size: es, weight: 800, align: 'left', fill: C.ink, color: C.white, border: false, elev: .6 }); }\n if (wp > .002) alpha(ctx, wp, () => { ctx.beginPath(); ctx.roundRect(x - 8 - 6 * (1 - wp), y - 8 - 6 * (1 - wp), w + 16 + 12 * (1 - wp), h + 16 + 12 * (1 - wp), 32); ctx.strokeStyle = C.blue; ctx.lineWidth = 5; ctx.stroke(); });\n });\n });\n if (s.chip) {\n const p = S(t - s.chip.at, 13), b = boxes.at(-1), yb = Math.max(...boxes.map(q => q[1] + q[3]));\n const cy = s.chip.y ?? (s.stack ?? H_ > W * 1.2 ? b[1] + b[3] / 2 : Math.min(H_ - 70, yb + 66));\n if (p > .002) rise(ctx, p, 20, () => chip(ctx, W / 2, cy, s.chip.text, { size: 44, h: 88, fill: C.blue, color: C.white, border: false }));\n }\n if (s.footer) {\n const p = S(t - s.footer.at + (s.pre || 0), 13);\n rise(ctx, p, 16, () => {\n const fs_ = 34, tw = measure(ctx, s.footer.text, fs_, 700, -.5), lg = s.footer.logo && LOGO[s.footer.logo] ? 58 : 0, x0 = W / 2 - (tw + lg) / 2;\n if (lg) LOGO[s.footer.logo](ctx, x0 + 22, H_ - 46, 20, C.ink2);\n text(ctx, s.footer.text, x0 + lg, midBase(H_ - 46, fs_), { size: fs_, weight: 700, ls: -.5, color: C.ink2 });\n });\n }\n });\n },\n};\n\n// ---- bill: a compact ranked bill for up to ~7 runs (logo, model, effort + time, cost bar). The `blue` row is the pick,\n// a `failed` row shows a dashed empty bar and its `note`. {title?: {text, at}, y?, rowH?, footer?: {text, at}, chip?: {text, at, y},\n// rows: [{logo, name, sub, value (0..1), label, at, blue?, failed?, note?}]}\n// lift: each row on a lifted paper card, embossed title, bars with a soft shadow (the default paper style: never flat).\nconst bill = {\n draw(R, s, t) {\n const { ctx } = R, rh = s.rowH || 112, y0 = s.y ?? 210, lx = 44, bx = 506, bw = W - bx - (s.lift ? 210 : 170), bh = 44;\n env(ctx, s, t, () => {\n if (s.title) rise(ctx, S(t - s.title.at, 13), 24, () => text(ctx, s.title.text, W / 2, 112, { size: 54, weight: 800, ls: -1.4, align: 'center', depth: s.lift ? 1 : undefined }));\n s.rows.forEach((r, i) => {\n const p = S(t - r.at, 12), cy = y0 + i * rh + rh / 2;\n rise(ctx, p, 30, () => {\n if (s.lift) sheet(ctx, lx - 20, cy - rh / 2 + 10, W - 2 * lx + 40, rh - 20, 26, { elev: .8 });\n if (r.blue) { ctx.beginPath(); ctx.roundRect(lx - 16, cy - rh / 2 + 8, W - 2 * lx + 32, rh - 16, 24); ctx.fillStyle = 'rgba(37,142,255,.08)'; ctx.fill(); }\n if (isImgLogo(r.logo)) logoImg(R, ctx, r.logo, lx, cy - 24, 48, 10); else if (LOGO[r.logo]) LOGO[r.logo](ctx, lx + 24, cy, 24, C.ink);\n text(ctx, r.name, lx + 66, midBase(cy - 16, 32), { size: 32, weight: 800, ls: -.8, color: r.failed ? C.ink3 : C.ink });\n text(ctx, r.sub, lx + 66, midBase(cy + 22, 24), { size: 24, weight: 600, color: C.ink3 });\n ctx.beginPath(); ctx.roundRect(bx, cy - bh / 2, bw, bh, bh / 2); ctx.fillStyle = 'rgba(40,30,10,.05)'; ctx.fill();\n const g = S(t - r.at - .08, 6.5), L = Math.max(bh, bw * r.value * g);\n if (r.failed) { ctx.save(); ctx.setLineDash([8, 8]); ctx.strokeStyle = C.ink3; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.roundRect(bx, cy - bh / 2, L, bh, bh / 2); ctx.stroke(); ctx.restore(); }\n else if (g > .002) {\n if (s.lift) shadow(ctx, bx, cy - bh / 2, L, bh, bh / 2, 12, .22, 5);\n ctx.beginPath(); ctx.roundRect(bx, cy - bh / 2, L, bh, bh / 2); ctx.fillStyle = r.blue ? C.blue : C.ink; ctx.fill();\n }\n text(ctx, r.label, bx + L + 18, midBase(cy, 34), { size: 34, weight: 800, fam: 'JetBrains Mono', color: r.blue ? C.blue : r.failed ? C.ink3 : C.ink });\n if (r.note) chip(ctx, bx + 14, cy, r.note, { size: 20, h: 34, padX: 12, weight: 800, align: 'left', fill: C.white, color: C.ink2, border: false, elev: .4 });\n });\n });\n if (s.chip) { const p = S(t - s.chip.at, 13); if (p > .002) rise(ctx, p, 20, () => chip(ctx, W / 2, s.chip.y ?? H - 120, s.chip.text, { size: 38, h: 80, fill: C.blue, color: C.white, border: false })); }\n if (s.footer) rise(ctx, S(t - s.footer.at, 13), 16, () => text(ctx, s.footer.text, W / 2, midBase(H - 40, 28), { size: 28, weight: 700, ls: -.4, align: 'center', color: C.ink2 }));\n });\n },\n};\n\nexport const SCENES = { race, bill, stat, bars, columns, recap, tiles, title, chat, convo, scatter, chip: chipScene, cta, switch: switchScene, tree, snap, xylo, sfxtrack, popover, pause: pauseScene, pipeline, comment, tweet, article, usage, mdGrid, mdIntro, mdRec, mdWave, mdVision, overlay, mdDiffuse, mdCode, mdAssemble, mdCut, mdStills, mdRender, ...STORY, ...LIFE, ...META, ...FACE, ...MASTER, ...CRASH, ...CLEAN };\n// A scene with a push (or a board's own `zooms`) draws into a zoom layer, so its slow push-in moves as one image (kit zoomLayer)\nconst zoomWrapped = new WeakSet();\nfor (const sc of Object.values(SCENES)) if (sc?.draw && !zoomWrapped.has(sc)) {\n const draw = sc.draw; zoomWrapped.add(sc);\n sc.draw = (R, s, t) => (sc.zooms ? sc.zooms(R, s) : s.push) ? zoomLayer(R, R2 => draw(R2, s, t)) : draw(R, s, t);\n}\n\n// Choreography between back-to-back scenes of one layer, resolved once at load (times are short seconds):\n// gate: a scene starting within .35 s of the previous scene's exit enters .16 s after that exit (the old beat is ~60%\n// gone), so two beats never crossfade in place. A board item with morph: true (a screen) instead grows out of the\n// previous scene's media rect (its `hand`, e.g. a tweet's image) from that exit on: a shared-element move.\nexport function linkScenes(list) {\n for (const s of list) {\n const start = s.from - (s.pre || 0);\n const prev = list.filter(o => o !== s && o.layer === s.layer && o.to != null && o.to \u003c= start + .05 && o.to >= start - .35).sort((a, b) => b.to - a.to)[0];\n if (!prev) continue;\n if (s.gate !== false) s._gate = prev.to + (prev.hard ? 0 : .16); // a hard cut leaves in one frame: nothing to wait for\n for (const it of s.items || []) if (it.morph && SCENES[prev.type]?.hand) {\n it._morph = { at: prev.to, rect: R => SCENES[prev.type].hand(prev, R) || [it.x - (it.w || 900) / 2, it.y - (it.h || 620) / 2, it.w || 900, it.h || 620] };\n prev._handTo = prev.to;\n }\n }\n return list;\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":136706,"content_sha256":"bd7672ffd44108569af50ff40784d30d6f629ad52c921c51476bd18c3ecc9b06"},{"filename":"engine/lib/style.js","content":"// Creator style: the look and pace every short of one creator shares (colors, font, paper grain, captions, pace,\n// music, mix). Written by the studio-setup skill to a LOCAL \u003cproject>/.short-editing/styles/\u003cname>/style.json; `short.mjs init` snapshots it\n// into \u003cproject>/source/style.json, so changing the profile later never restyles a finished short.\n// Layer order: engine defaults \u003c style \u003c spec. A spec value always wins. A project without source/style.json\n// renders exactly as before (legacy shorts are never touched).\n// Pure module: used by short.mjs (node), audio.mjs and the browser runtime.\n\nconst clamp = (x, a, b) => Math.min(b, Math.max(a, x));\nconst rgb = h => { h = String(h).replace('#', ''); if (h.length === 3) h = h.replace(/./g, '{head-tags}{head-tags}'); return [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); };\nconst hexOf = a => '#' + a.map(v => Math.round(clamp(v, 0, 255)).toString(16).padStart(2, '0')).join('').toUpperCase();\nconst lum = h => { const [r, g, b] = rgb(h).map(v => v / 255); return .2126 * r + .7152 * g + .0722 * b; };\nexport const mixHex = (a, b, p) => { const x = rgb(a), y = rgb(b); return hexOf(x.map((v, i) => v + (y[i] - v) * p)); };\nexport const isHex = h => /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.test(String(h || ''));\n// relative luminance (WCAG), to check that ink reads on the paper and white reads on the accent\nexport const luminance = h => { const [r, g, b] = rgb(h).map(v => { v /= 255; return v \u003c= .03928 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; }); return .2126 * r + .7152 * g + .0722 * b; };\nexport const contrast = (a, b) => { const x = luminance(a), y = luminance(b); return (Math.max(x, y) + .05) / (Math.min(x, y) + .05); };\n\n// style.colors = {paper, ink, accent} (any other kit.js C key can be pinned too: sheet, ink2, ink3, line, dim, accent2,\n// tint). The rest is derived so the whole kit (sheets, hairlines, grays, the accent's dark and tint) stays coherent.\n// Derived from the defaults it gives back the original palette within a few levels.\nexport function palette(c = {}) {\n const paper = c.paper || '#F1EEE6', ink = c.ink || '#14161B', accent = c.accent || '#258EFF';\n const sheet = c.sheet || mixHex(paper, '#FFFFFF', .78);\n return {\n paper, sheet, ink, blue: accent,\n ink2: c.ink2 || mixHex(ink, paper, .3),\n ink3: c.ink3 || mixHex(ink, paper, .62),\n line: c.line || mixHex(paper, ink, .065),\n dim: c.dim || mixHex(paper, ink, .11),\n blue2: c.accent2 || mixHex(accent, '#000000', .16),\n tint: c.tint || mixHex(sheet, accent, .11),\n // text on an accent fill: ink on a light accent (yellow), white on a dark one (blue)\n onBlue: c.onAccent || (lum(accent) > .55 ? ink : '#FFFFFF'),\n // second fill color for labels (k: 'label', bg: 'pop'), e.g. the podcast violet; defaults to ink\n pop: c.pop || ink,\n };\n}\n\n// The caption looks (same order as the capStyles scene, which cycles the first 20). 'sticker' is the default per-word paper\n// sticker; 'clean' is the white sentence-case pill of the clean look (sized by captions.size directly, 40-44).\n// 'rise' and 'hard' follow the spoken word (runtime.js): the word being said sits on an accent block.\n// rise: white words with a soft shadow (ink on the paper), the block blurs in, sharp in ~.15 s, then fades out in place (DM Sans 900)\n// hard: ink words with a paper outline, the block is a flat rect with a hard ink shadow (Bricolage Grotesque 800)\n// 'stack' (nico-slowdown, runtime.js stackCaption) switches with the layout. Full camera or paper alone: the chunk builds\n// word by word into a centered stack, small bold words flowing on a line, the key word (captions.emph, else the longest\n// content word) alone on its own line, huge, uppercase, condensed (Archivo 900). Split: one word at a time, regular\n// weight, accent text on a tight white label straddling the camera's top edge.\n// 'band' (justyn-ai, runtime.js bandCaption): the whole line on one flat block (captions.bg, default ink), words in\n// captions.font, white (captions.color), emphasised words in captions.hi (default the tint). No motion: hard in, hard out.\n// captions.bg 'none' (aiwithremy): no block, the words sit on the video with a soft shadow (captions.shadow false = none).\n// 'dim' (speedy-devv, runtime.js dimCaption, appended last so numeric indexes stay put): the phrase builds word by word,\n// spoken words in captions.color (grey, captions.dimWeight 500), the word being said in captions.hi (white) at\n// captions.weight, an emphasised word keeps a dark pill (captions.pill, default #2B2B2B) once said; each word blurs in.\nexport const CAPTION_STYLES = ['sticker', 'pill', 'box', 'caps', 'karaoke', 'underline', 'serif', 'chip', 'highlight', 'outline',\n 'punch', 'light', 'split', 'tiles', 'tilted', 'typing', 'accent', 'stacked', 'brackets', 'bar', 'clean', 'rise', 'hard', 'stack', 'band', 'dim'];\n// default family and weight per word-block look (captions.font / captions.weight override them)\nexport const CAPTION_FONTS = { rise: ['DM Sans', 900], hard: ['Bricolage Grotesque', 800] };\nexport const captionStyleIndex = s => {\n if (s == null || s === '') return 0;\n if (typeof s === 'number') return clamp(Math.round(s) - 1, 0, CAPTION_STYLES.length - 1); // 1-based, like the \"Style 07 / 20\" counter\n const i = CAPTION_STYLES.indexOf(String(s).toLowerCase()); return i \u003c 0 ? 0 : i;\n};\n\n// subscribe button words when the profile only gives a name (any other language: write cta.button / done in the profile)\nconst CTA = { fr: { sub: 'Plus de vidéos', button: \"S'abonner\", done: 'Abonné' }, en: { sub: 'More videos', button: 'Subscribe', done: 'Subscribed' },\n es: { sub: 'Más vídeos', button: 'Suscribirse', done: 'Suscrito' }, pt: { sub: 'Mais vídeos', button: 'Inscrever-se', done: 'Inscrito' },\n de: { sub: 'Mehr Videos', button: 'Abonnieren', done: 'Abonniert' }, it: { sub: 'Altri video', button: 'Iscriviti', done: 'Iscritto' } };\nexport const ctaDefaults = lang => CTA[String(lang || 'fr').slice(0, 2).toLowerCase()] || CTA.en;\n\n// Long-form sound (format youtube): the shorts profile's sfx never applies. Only paper slides, tweet shutters and the\n// quiet marker, at most one hit every 20 s (about 3 per minute), SFX bus at -36 LUFS. A spec.sfx / spec.mix key still\n// wins. YouTube comments on the Mistral video (2026-10-08): \"j'en peux plus des tings\", \"les clochettes c'est fini\",\n// \"ding dong, flush, slide\": it had 17 hits per minute with bells and blips.\nexport const LONG_SFX = { appear: 'slide', accents: 0, marker: .4, swish: 0, only: ['slide', 'shutter', 'marker'], minGap: 20 };\n\n// style -> spec defaults. Only keys the spec leaves out are filled; objects merge one level deep (spec keys win).\nexport function withStyle(spec, st) {\n if (!spec || !st) return spec;\n const o = { ...spec }, under = (k, v) => { if (v && typeof v === 'object') o[k] = { ...v, ...(spec[k] || {}) }; };\n if (spec.speed == null && st.pace?.speed != null) o.speed = st.pace.speed;\n under('tighten', st.pace?.tighten);\n under('captions', st.captions);\n under('music', st.music);\n under('mix', st.mix);\n under('zoom', st.zoom); // zoom.drift: the slow push per shot (runtime.js)\n if (spec.format === 'youtube') { o.sfx = { ...LONG_SFX, ...(spec.sfx || {}) }; o.mix = { ...(st.mix || {}), sfx: -36, ...(spec.mix || {}) }; }\n else under('sfx', st.sfx);\n under('layout', st.layout); // layout.card: the fixed camera card of the clean look (runtime.js)\n under('header', st.header); // header: brand top left + tagline top right, drawn on every frame of a card layout\n under('display', st.display); // display: the big-text look (kit.js D): counters and big numbers family, flat or embossed\n if (spec.grade == null && st.grade != null) o.grade = st.grade;\n if (spec.cuts == null && st.cuts != null) o.cuts = st.cuts; // cuts: 'hard' = boards, cutouts and B-roll in and out in one frame (runtime.js) // camera colour grade, baked at prepare (short.mjs)\n // creator: {name, lang, cta: {sub, button, done}} fills the CTA card text a cta scene leaves out\n const cr = st.creator, cta = cr && Object.fromEntries(Object.entries({ name: cr.name, ...ctaDefaults(cr.lang), ...(cr.cta || {}) }).filter(([, v]) => v != null && v !== ''));\n if (cta && Array.isArray(spec.scenes)) o.scenes = spec.scenes.map(s => s?.type === 'cta' ? { ...cta, ...s } : s);\n return o;\n}\n\n// style -> kit.setTheme() input (browser)\nexport const themeOf = st => ({\n colors: st?.colors ? palette(st.colors) : null,\n font: st?.font?.family || null,\n grain: st?.paper?.grain ?? null,\n tooth: st?.paper?.tooth ?? null,\n toothCell: st?.paper?.toothCell ?? null,\n waves: st?.paper?.waves ?? null,\n mottle: st?.paper?.mottle ?? null,\n depth: st?.paper?.depth ?? null,\n logos: st?.paper?.logos ?? null,\n bg: st?.paper?.bg ?? null,\n dots: st?.paper?.dots ?? null,\n shadow: st?.paper?.shadow ?? null,\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8854,"content_sha256":"985cfe1f942706ffe5aae309a6fabe6150c95f160279cd0a43bb2ff4f6508bee"},{"filename":"engine/lib/timeline.js","content":"// Pure timeline helpers shared by the browser runtime and the Node CLI (audio cues, QC).\n// Two clocks exist: SOURCE time (seconds in assets/source.mp4, what spec.js is written in)\n// and SHORT time (seconds in the tightened cut, what gets rendered). mapper() converts.\n\nexport const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));\n\nexport function mapper(tl) {\n const K = tl.keeps, sp = tl.speed || 1;\n return t => {\n // 'p+X' = X seconds into the inserted pause (spec.pause), e.g. a narrator word time\n if (typeof t === 'string' && /^p\\+/.test(t)) return (tl.pause?.t0 ?? 0) + parseFloat(t.slice(2));\n // 'iK+X' = X seconds into insert K (spec.inserts, index in that list)\n if (typeof t === 'string' && /^i\\d+\\+/.test(t)) { const [k, x] = t.slice(1).split('+'); return (tl.inserts?.[+k]?.t0 ?? 0) + parseFloat(x); }\n // 'o+X' = X seconds into the outro (spec.outro, the end card after the last word); 'o-X' = X seconds before it\n if (typeof t === 'string' && /^o[+-]/.test(t)) return (tl.outro?.t0 ?? tl.duration) + parseFloat(t.slice(1));\n if (typeof t !== 'number') return t;\n for (const [a, b, o] of K) { if (t \u003c a) return o; if (t \u003c b) return o + (t - a) / sp; }\n return tl.duration;\n };\n}\n\nconst norm = s => String(s).toLowerCase().replace(/[.,!?;:…]+$/, '');\n\n// Word chunks for captions. words are in SHORT time: [{text, start, end}].\n// cfg: {replace:[['cent pour cent','100%']], emph:['Opus 5.5'], maxWords, maxChars, gap, hide:[[a,b]] (short time),\n// bleep:[[a,b]] (short time, spec.bleep: those words never show)}\nexport function captionChunks(words, cfg = {}) {\n const rep = (cfg.replace || []).map(([k, v]) => [k.toLowerCase().split(/\\s+/), v]);\n const merged = [];\n for (let i = 0; i \u003c words.length; i++) {\n let hit = null;\n for (const [ks, v] of rep) if (ks.every((k, j) => words[i + j] && norm(words[i + j].text) === norm(k))) { hit = [ks.length, v]; break; }\n if (hit) {\n const last = words[i + hit[0] - 1], p = last.text.match(/[.,!?;:]$/);\n merged.push({ text: hit[1] + (p ? p[0] : ''), start: words[i].start, end: last.end, rep: true });\n i += hit[0] - 1;\n } else merged.push({ text: words[i].text, start: words[i].start, end: words[i].end });\n }\n const hidden = t => [...(cfg.hide || []), ...(cfg.bleep || [])].some(([a, b]) => t >= a - .01 && t \u003c b);\n const emph = new Set((cfg.emph || []).map(norm));\n const maxW = cfg.maxWords ?? 3, maxC = cfg.maxChars ?? 16, gap = cfg.gap ?? .35;\n // cfg.scaleKeys (captions drawn k times bigger from t on): such a line holds k times fewer characters\n const scaleAt = t => (cfg.scaleKeys || []).reduce((v, k) => t >= k[0] ? k[1] : v, 1);\n const chunks = []; let cur = null, prev = null;\n for (const w of merged) {\n if (hidden(w.start)) { cur = null; prev = null; continue; }\n // cfg.lower: every spoken word in lower case (benkimball.ai); captions.replace values keep their own case\n const text = (cfg.lower && !w.rep ? w.text.toLowerCase() : w.text).replace(/[.,;:]+$/, '').replace(/^[\"«“]|[\"»”]$/g, '');\n if (!text) { prev = w; continue; } // a lone ':' or '.' token: no word, but it still breaks the line\n const word = { text, start: w.start, end: w.end, emph: emph.has(norm(text)) };\n const brk = !cur || !cfg.phrase && (cur.words.length >= maxW || cur.chars + 1 + text.length > maxC / scaleAt(cur.words[0]?.start ?? w.start))\n || /[.,!?;:]$/.test(prev?.text || '') || w.start - prev.end > gap;\n if (brk) { cur = { words: [], chars: 0 }; chunks.push(cur); }\n cur.chars += (cur.words.length ? 1 : 0) + text.length;\n cur.words.push(word); prev = w;\n }\n if (cfg.phrase) chunks.splice(0, chunks.length, ...chunks.flatMap(c => phraseSplit(c.words, maxW, maxC / scaleAt(c.words[0].start))));\n chunks.forEach((c, i) => { c.start = c.words[0].start; c.end = c.words.at(-1).end; c.next = chunks[i + 1]?.start ?? Infinity; });\n // a line never runs into a hide span after it (a word ending in a cut can map past a spec.inserts card)\n for (const c of chunks) c.stop = Math.min(Infinity, ...(cfg.hide || []).map(([a]) => a).filter(a => a > c.start));\n return chunks;\n}\n\n// cfg.phrase: a sentence (between punctuation and pauses) is cut where it reads, not where the line fills up. Every\n// split of it into lines of 1 to maxW words is scored and the cheapest wins: a line over maxC characters, a line that\n// ends on a word that leans on the next one (de, les, que, ton, the, of...), a lone word and a line of two all cost,\n// a longer pause between two words makes a good cut, so lines run 3 to 5 words when the sentence allows it.\nconst LEAN = new Set(('le la les l un une des du de d à au aux et ou mais que qu qui ton ta tes son sa ses mon ma mes ce cet cette ' +\n 'en sur pour avec par dans sans tu il elle on je me te se ne nous vous leur très plus tout vrais gros bonnes est sont va the a an of to and or in on for with my your is').split(' '));\nfunction phraseSplit(words, maxW, maxC) {\n const n = words.length, best = [0], from = [0], len = w => w.text.length;\n for (let j = 1; j \u003c= n; j++) {\n best[j] = Infinity;\n for (let i = Math.max(0, j - maxW); i \u003c j; i++) {\n const ws = words.slice(i, j), k = ws.length, chars = ws.reduce((a, w) => a + len(w), k - 1);\n const last = norm(ws[k - 1].text).replace(/^.*'/, '').replace(/'$/, '');\n let c = best[i] + 3 + Math.max(0, chars - maxC) * 4 + (k === 1 && n > 1 ? 7 : k === 2 && n > 2 ? 2 : 0);\n if (j \u003c n) c += (LEAN.has(last) || /'$/.test(ws[k - 1].text) ? 9 : 0) - Math.min(3, (words[j].start - words[j - 1].end) * 12);\n if (c \u003c best[j]) { best[j] = c; from[j] = i; }\n }\n }\n const out = []; for (let j = n; j > 0; j = from[j]) out.unshift({ words: words.slice(from[j], j), chars: 0 });\n return out;\n}\n\n// Word-synced typing: display tokens mapped onto the spoken words between t0 and t1 (SHORT time).\nexport function typing(display, words, t0, t1) {\n const toks = display.split(' ');\n const sp = words.filter(w => w.start >= t0 - .02 && w.start \u003c t1 + .02);\n if (!sp.length) return toks.map((tok, i) => ({ tok, start: t0 + i * .1, end: t0 + i * .1 + .1 }));\n return toks.map((tok, i) => {\n const j = Math.round(i * (sp.length - 1) / Math.max(1, toks.length - 1)), w = sp[j];\n return { tok, start: w.start, end: Math.max(w.end, w.start + .12) };\n });\n}\n\n// spec.js is authored in SOURCE time (read times straight from files/words.md). This deep-copies it into\n// SHORT time. A number is a time when its key is at/from/to/until or ends with \"At\"/\"From\"/\"To\"\n// (countFrom, workTo...); inside any array whose key ends in \"keys\" (keys, labelKeys...) the first element\n// of each entry is a time; \"hide\", \"bleep\", \"thump\" and any key ending in \"spans\" hold [a, b, ...] time pairs. A string 'p+X' in a time\n// slot means X seconds into the inserted pause (spec.pause), 'iK+X' X seconds into insert K (spec.inserts), 'o+X' X seconds into the outro (spec.outro). Everything else\n// (durations, sizes, offsets into media clips) is left alone. \"cut\" and \"tighten\" stay in source time.\nconst TIME_KEY = /^(at|from|to|until)$|(At|From|To)$/;\nexport function mapSpec(spec, m) {\n const walk = (v, k) => {\n if (k === 'cut' || k === 'tighten' || k === 'media' || k === 'cam') return v;\n if (typeof v === 'number' || (typeof v === 'string' && /^(p\\+|o[+-]|i\\d+\\+)[\\d.]+$/.test(v))) return TIME_KEY.test(k) ? m(v) : v;\n if (Array.isArray(v)) {\n if (/keys$/i.test(k)) return v.map(e => [m(e[0]), ...e.slice(1).map(x => walk(x, ''))]);\n if (k === 'hide' || k === 'bleep' || k === 'thump' || /spans$/i.test(k)) return v.map(([a, b, ...r]) => [m(a), m(b), ...r]);\n return v.map(x => walk(x, ''));\n }\n if (v && typeof v === 'object') { const o = {}; for (const kk in v) o[kk] = walk(v[kk], kk); return o; }\n return v;\n };\n return walk(spec, '');\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7851,"content_sha256":"bf952f721b34b8b9e80edeefc172856c9202957781a287d6789d082516e47711"},{"filename":"engine/library.mjs","content":"#!/usr/bin/env node\n// Illustration library of a style: every generated illustration lives ONCE in \u003cprofile>/styles/\u003cstyle>/illustrations/library/,\n// so any short of that style can reuse it. The engine reads a spec's assets/images/\u003cname>.png there when the project has\n// no such file; on macOS/Linux the project also gets a relative symlink (browsable, read by other tools).\n// node library.mjs import \u003cproject>... move the project's assets/images files into its style's library\n// (dedup by content; a name taken by another image -> \u003cname>-\u003cslug>),\n// link them back, archive the sub-folders (raw renders) in library/raw/\u003cslug>/\n// node library.mjs link \u003cproject> \u003cname>[=\u003cas>]... use library images in a short as assets/images/\u003cas>.\u003cext>\n// node library.mjs list [query] [--style \u003cname>] name, uses, prompt (default style of the profile without --style)\n// library/index.json (name -> file, prompt, ar, refs, used by) and library/index.html (gallery) are rewritten each time.\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport crypto from 'node:crypto';\n\nconst argv = process.argv.slice(2);\nconst opt = k => { const i = argv.indexOf(`--${k}`); if (i \u003c 0) return null; const v = argv[i + 1]; argv.splice(i, 2); return v; };\nconst STYLE_FLAG = opt('style');\nconst [cmd, ...args] = argv;\nconst IMG = /\\.(png|jpe?g|webp)$/i;\nconst expand = p => p.replace(/^~/, os.homedir());\nconst readJSON = f => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };\nconst sha = f => crypto.createHash('sha1').update(fs.readFileSync(f)).digest('hex').slice(0, 16);\nconst slugOf = proj => path.basename(proj).replace(/^short-/, '');\n\n// profile (LOCAL only): $SHORT_EDITING_HOME, else the nearest .short-editing/ in the start folder or a parent, home excluded\nconst HOME = start => expand(process.env.SHORT_EDITING_HOME || (s => { for (let d = s; d !== path.dirname(d); d = path.dirname(d)) if (d !== os.homedir() && fs.existsSync(path.join(d, '.short-editing'))) return path.join(d, '.short-editing'); return path.join(s, '.short-editing'); })(path.resolve(start)));\nfunction styleDir(home, name) {\n const dir = path.join(home, 'styles');\n if (!name) { const d = readJSON(path.join(home, 'style.json'))?.default; if (typeof d === 'string') return path.join(dir, d); throw new Error(`no default style in ${home}/style.json, pass --style \u003cname>`); }\n const low = String(name).toLowerCase();\n const d = fs.existsSync(dir) && fs.readdirSync(dir).map(n => path.join(dir, n)).find(d => path.basename(d).toLowerCase() === low || String(readJSON(path.join(d, 'style.json'))?.name || '').toLowerCase() === low);\n if (!d) throw new Error(`no style \"${name}\" in ${dir}`);\n return d;\n}\nconst libOf = sdir => { const d = path.join(sdir, 'illustrations/library'); fs.mkdirSync(path.join(d, 'raw'), { recursive: true }); return fs.realpathSync(d); };\nconst projLib = proj => libOf(styleDir(HOME(proj), STYLE_FLAG || readJSON(path.join(proj, 'source/style.json'))?.name));\n\nconst load = lib => readJSON(path.join(lib, 'index.json'))?.items || {};\nfunction save(lib, items) {\n const sorted = Object.fromEntries(Object.entries(items).sort(([a], [b]) => a.localeCompare(b)));\n fs.writeFileSync(path.join(lib, 'index.json'), JSON.stringify({ about: 'Illustrations of this style. name -> file, sha, prompt, ar, refs, used: [\u003cshort>/\u003cname in the short>]. Written by short-editing/engine/library.mjs.', items: sorted }, null, 1) + '\\n');\n const esc = s => String(s).replace(/[&\u003c>\"]/g, c => `&#${c.charCodeAt(0)};`);\n const card = ([n, it]) => `\u003cfigure title=\"${esc(it.prompt || '')}\">\u003cimg loading=\"lazy\" src=\"${esc(it.file)}\">\u003cfigcaption>\u003cb>${esc(n)}\u003c/b>\u003cbr>${esc((it.used || []).map(u => u.split('/')[0]).join(', '))}\u003c/figcaption>\u003c/figure>`;\n fs.writeFileSync(path.join(lib, 'index.html'), `\u003c!doctype html>\u003cmeta charset=\"utf-8\">\u003ctitle>Illustrations (${Object.keys(sorted).length})\u003c/title>\u003cstyle>body{font:13px system-ui;background:#F7F7F5;margin:20px}main{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:14px}figure{margin:0;background:#fff;border:1px solid #ddd;padding:8px}img{width:100%;height:170px;object-fit:contain}figcaption{color:#555}b{color:#111}input{font-size:16px;padding:6px;width:300px;margin-bottom:14px}\u003c/style>\u003cinput placeholder=\"filter\" oninput=\"for(const f of document.querySelectorAll('figure'))f.hidden=!(f.textContent+f.title).toLowerCase().includes(this.value.toLowerCase())\">\u003cmain>${Object.entries(sorted).map(card).join('')}\u003c/main>\\n`);\n}\n\n// a relative symlink; false where the system refuses it (Windows without developer mode): the engine reads the library then\nfunction linkTo(link, target) {\n fs.rmSync(link, { force: true });\n try { fs.symlinkSync(path.relative(path.dirname(link), target), link); return true; } catch { return false; }\n}\n// without a symlink, a renamed image is renamed in the spec too\nfunction renameInSpec(proj, from, to) {\n const f = path.join(proj, 'source/spec.js');\n if (from === to || !fs.existsSync(f)) return;\n const s = fs.readFileSync(f, 'utf8'), next = s.split(`assets/images/${from}`).join(`assets/images/${to}`);\n if (next !== s) fs.writeFileSync(f, next);\n}\n\n// prompts of a project: files/*prompts*.json (later versions win), then the manifests images.mjs wrote in the image folders\nfunction prompts(proj) {\n const out = {}, put = o => { if (!o?.name) return; for (const k of ['prompt', 'ar', 'refs']) if (o[k]) (out[o.name] ??= {})[k] = o[k]; };\n const files = path.join(proj, 'files'), img = path.join(proj, 'assets/images');\n if (fs.existsSync(files)) for (const f of fs.readdirSync(files).filter(f => /prompts.*\\.json$/.test(f)).sort()) [].concat(readJSON(path.join(files, f)) || []).forEach(put);\n const manifests = d => [path.join(d, 'manifest.json'), ...(fs.existsSync(d) ? fs.readdirSync(d, { withFileTypes: true }).filter(e => e.isDirectory()).map(e => path.join(d, e.name, 'manifest.json')) : [])];\n for (const m of manifests(img).filter(fs.existsSync).sort()) (readJSON(m)?.images || []).forEach(put);\n return out;\n}\n\n// move src into dst, merging folders (a file already there is replaced)\nfunction moveInto(src, dst) {\n if (fs.lstatSync(src).isDirectory()) { fs.mkdirSync(dst, { recursive: true }); for (const e of fs.readdirSync(src)) moveInto(path.join(src, e), path.join(dst, e)); fs.rmdirSync(src); }\n else { fs.rmSync(dst, { force: true }); fs.renameSync(src, dst); }\n}\n\nfunction importProject(proj) {\n proj = path.resolve(expand(proj));\n const lib = projLib(proj), items = load(lib), slug = slugOf(proj), img = path.join(proj, 'assets/images'), P = prompts(proj), bySha = {};\n if (!fs.existsSync(img)) { console.log(`${slug}: no assets/images`); return; }\n for (const [n, it] of Object.entries(items)) bySha[it.sha] = n;\n let added = 0, known = 0;\n for (const f of fs.readdirSync(img)) {\n const src = path.join(img, f), st = fs.lstatSync(src);\n if (st.isDirectory()) { moveInto(src, path.join(lib, 'raw', slug, f)); continue; }\n if (st.isSymbolicLink() || !IMG.test(f)) continue;\n const h = sha(src), as = f.replace(IMG, ''), ext = path.extname(f);\n let name = bySha[h];\n if (name) { known++; fs.rmSync(src); }\n else {\n name = as;\n for (let i = 2; items[name] || fs.existsSync(path.join(lib, name + ext)); i++) name = i === 2 ? `${as}-${slug}` : `${as}-${slug}-${i}`;\n fs.renameSync(src, path.join(lib, name + ext));\n items[name] = { file: name + ext, sha: h, from: slug, created: st.mtime.toISOString().slice(0, 10), ...P[as], used: [] };\n bySha[h] = name; added++;\n }\n const file = items[name].file;\n if (!linkTo(src, path.join(lib, file))) renameInSpec(proj, f, file);\n items[name].used = [...new Set([...(items[name].used || []), `${slug}/${as}`])];\n }\n save(lib, items);\n console.log(`${slug}: ${added} added, ${known} already there -> ${lib} (${Object.keys(items).length} illustrations)`);\n}\n\nfunction link(proj, specs) {\n proj = path.resolve(expand(proj));\n const lib = projLib(proj), items = load(lib), slug = slugOf(proj), img = path.join(proj, 'assets/images');\n fs.mkdirSync(img, { recursive: true });\n for (const s of specs) {\n const [name, as = name] = s.split('='), it = items[name];\n if (!it) { console.error(`not in the library: ${name}`); process.exitCode = 1; continue; }\n const ext = path.extname(it.file), ok = linkTo(path.join(img, as + ext), path.join(lib, it.file));\n if (!ok && as !== name) { console.error(`${as}: no symlink on this system, use assets/images/${it.file} in the spec`); process.exitCode = 1; continue; }\n it.used = [...new Set([...(it.used || []), `${slug}/${as}`])];\n console.log(`${slug}: assets/images/${as}${ext}${ok ? ` -> ${it.file}` : ' (read from the library by the engine)'}`);\n }\n save(lib, items);\n}\n\nif (cmd === 'import' && args.length) args.forEach(importProject);\nelse if (cmd === 'link' && args.length > 1) link(args[0], args.slice(1));\nelse if (cmd === 'list') {\n const lib = libOf(styleDir(HOME(process.cwd()), STYLE_FLAG)), q = (args[0] || '').toLowerCase(), items = load(lib);\n for (const [n, it] of Object.entries(items)) if (!q || `${n} ${it.prompt || ''}`.toLowerCase().includes(q)) console.log(`${n.padEnd(28)} ${String((it.used || []).length).padStart(2)}x ${(it.prompt || '').slice(0, 110)}`);\n console.log(`${Object.keys(items).length} illustrations, gallery: ${path.join(lib, 'index.html')}`);\n}\nelse { console.error('usage: library.mjs import \u003cproject>... | link \u003cproject> \u003cname>[=\u003cas>]... | list [query] [--style \u003cname>]'); process.exit(1); }\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":9688,"content_sha256":"d76b7e086997e12bdc1578479d3fcaa5821c050993fab9d9ad84ec8df071728c"},{"filename":"engine/matte.swift","content":"// Person matte for the camera frames, with Apple Vision (no model download, runs on the Neural Engine).\n// swiftc -O matte.swift -o matte (short.mjs compiles and caches it)\n// matte \u003ccamDir> \u003coutDir> \u003cjobFile> job lines: \"\u003cframe> \u003crawMaskPath> \u003cwrite 0|1>\", consecutive frames\n// Two stages per frame:\n// 1. raw mask: Vision person segmentation (~34 ms, the bottleneck), resized to the cam frame on the CPU (vImage;\n// Core Image cost more than Vision itself). Cached as a gray PNG at rawMaskPath, keyed by the cam JPG content,\n// so a new `cut` only runs Vision on frames it has never seen.\n// 2. output (~5 ms): temporal EMA (40% previous, so hair and shoulder edges do not flicker), 5x5 gaussian (sigma\n// ~1.2, a soft clean edge), written as \u003coutDir>/%05d.png gray+alpha (alpha = person). The runtime composites it\n// over the cam JPG. Lines with write 0 only prime the EMA, so parallel slices join without a seam.\n// MATTE_QUALITY=balanced is ~2x faster on Vision, coarser edges.\nimport Foundation\nimport Vision\nimport Accelerate\nimport ImageIO\nimport UniformTypeIdentifiers\n\nlet a = CommandLine.arguments\nguard a.count == 4, let job = try? String(contentsOfFile: a[3], encoding: .utf8) else {\n print(\"usage: matte \u003ccamDir> \u003coutDir> \u003cjobFile>\"); exit(1)\n}\nlet camDir = URL(fileURLWithPath: a[1]), outDir = URL(fileURLWithPath: a[2])\ntry? FileManager.default.createDirectory(at: outDir, withIntermediateDirectories: true)\nlet req = VNGeneratePersonSegmentationRequest()\nreq.qualityLevel = ProcessInfo.processInfo.environment[\"MATTE_QUALITY\"] == \"balanced\" ? .balanced : .accurate\nreq.outputPixelFormat = kCVPixelFormatType_OneComponent8\nlet gray = CGColorSpaceCreateDeviceGray()\nlet g: [Float] = [0.0398, 0.2420, 0.4364, 0.2420, 0.0398]\nlet kernel: [Int16] = g.flatMap { y in g.map { x in Int16((x * y * 4096).rounded()) } }\nlet kDiv = Int32(kernel.reduce(0) { $0 + Int32($1) })\n\nfunc writePNG(_ bytes: [UInt8], _ w: Int, _ h: Int, alpha: Bool, to url: URL) {\n let bpp = alpha ? 2 : 1\n let img = CGImage(width: w, height: h, bitsPerComponent: 8, bitsPerPixel: 8 * bpp, bytesPerRow: w * bpp, space: gray,\n bitmapInfo: CGBitmapInfo(rawValue: (alpha ? CGImageAlphaInfo.last : .none).rawValue),\n provider: CGDataProvider(data: CFDataCreate(nil, bytes, bytes.count))!, decode: nil, shouldInterpolate: false, intent: .defaultIntent)!\n let tmp = url.appendingPathExtension(\"tmp\")\n guard let dest = CGImageDestinationCreateWithURL(tmp as CFURL, UTType.png.identifier as CFString, 1, nil) else { print(\"cannot write \\(url.path)\"); exit(1) }\n CGImageDestinationAddImage(dest, img, nil); CGImageDestinationFinalize(dest)\n _ = try? FileManager.default.removeItem(at: url); try! FileManager.default.moveItem(at: tmp, to: url)\n}\n\n// raw mask (W*H bytes) for one cam frame: from the cache, or Vision + resize, then cached\nfunc rawMask(_ cam: URL, _ cache: URL, _ W: inout Int, _ H: inout Int) -> [UInt8]? {\n if let s = CGImageSourceCreateWithURL(cache as CFURL, nil), let im = CGImageSourceCreateImageAtIndex(s, 0, nil) {\n W = im.width; H = im.height\n var out = [UInt8](repeating: 0, count: W * H)\n let c = CGContext(data: &out, width: W, height: H, bitsPerComponent: 8, bytesPerRow: W, space: gray, bitmapInfo: CGImageAlphaInfo.none.rawValue)!\n c.draw(im, in: CGRect(x: 0, y: 0, width: W, height: H)); return out\n }\n guard let s = CGImageSourceCreateWithURL(cam as CFURL, nil), let img = CGImageSourceCreateImageAtIndex(s, 0, nil) else { return nil }\n W = img.width; H = img.height\n guard (try? VNImageRequestHandler(cgImage: img, options: [:]).perform([req])) != nil, let buf = req.results?.first?.pixelBuffer else { return nil }\n var out = [UInt8](repeating: 0, count: W * H)\n CVPixelBufferLockBaseAddress(buf, .readOnly)\n var src = vImage_Buffer(data: CVPixelBufferGetBaseAddress(buf), height: vImagePixelCount(CVPixelBufferGetHeight(buf)),\n width: vImagePixelCount(CVPixelBufferGetWidth(buf)), rowBytes: CVPixelBufferGetBytesPerRow(buf))\n out.withUnsafeMutableBytes { p in\n var dst = vImage_Buffer(data: p.baseAddress, height: vImagePixelCount(H), width: vImagePixelCount(W), rowBytes: W)\n _ = vImageScale_Planar8(&src, &dst, nil, vImage_Flags(kvImageHighQualityResampling))\n }\n CVPixelBufferUnlockBaseAddress(buf, .readOnly)\n writePNG(out, W, H, alpha: false, to: cache)\n return out\n}\n\nvar W = 0, H = 0, prevFrame = -2, written = 0\nvar ema: [Float] = [], cur: [Float] = [], u8: [UInt8] = [], soft: [UInt8] = [], ga: [UInt8] = []\nfor line in job.split(separator: \"\\n\") {\n let f = line.split(separator: \" \", maxSplits: 2)\n guard f.count == 3, let i = Int(f[0]) else { continue }\n let name = String(format: \"%05d\", i)\n guard let raw = rawMask(camDir.appendingPathComponent(name + \".jpg\"), URL(fileURLWithPath: String(f[1])), &W, &H) else { print(\"no mask \\(name)\"); exit(1) }\n if ema.count != W * H { ema = [Float](repeating: 0, count: W * H); cur = ema; u8 = [UInt8](repeating: 0, count: W * H); soft = u8; ga = [UInt8](repeating: 0, count: 2 * W * H) }\n vDSP_vfltu8(raw, 1, &cur, 1, vDSP_Length(W * H))\n if i != prevFrame + 1 { ema = cur } else { var k: Float = 0.6; vDSP_vintb(ema, 1, cur, 1, &k, &ema, 1, vDSP_Length(W * H)) }\n prevFrame = i\n if f[2] != \"1\" { continue }\n vDSP_vfixu8(ema, 1, &u8, 1, vDSP_Length(W * H))\n u8.withUnsafeMutableBytes { ps in soft.withUnsafeMutableBytes { pd in\n var s = vImage_Buffer(data: ps.baseAddress, height: vImagePixelCount(H), width: vImagePixelCount(W), rowBytes: W)\n var d = vImage_Buffer(data: pd.baseAddress, height: vImagePixelCount(H), width: vImagePixelCount(W), rowBytes: W)\n _ = vImageConvolve_Planar8(&s, &d, nil, 0, 0, kernel, 5, 5, kDiv, 0, vImage_Flags(kvImageEdgeExtend))\n } }\n for j in 0..\u003c(W * H) { ga[2 * j + 1] = soft[j] } // gray 0, alpha = mask\n writePNG(ga, W, H, alpha: true, to: outDir.appendingPathComponent(name + \".png\"))\n written += 1\n if written % 10 == 0 { print(\"matte \\(written)\"); fflush(stdout) }\n}\nprint(\"matte done\")\n","content_type":"text/plain; charset=utf-8","language":"swift","size":6050,"content_sha256":"fc4378430d749b067d07a781cf5b190b52b4ef3cdb1eaabb01191eb97161462d"},{"filename":"engine/music.mjs","content":"#!/usr/bin/env node\n// Render procedural beds (lib/chill.js) to mp3: node music.mjs [style ... | --all] [--out \u003cdir>] [--seed n]\n// No style = the shorts library kept by ear (LIBRARY, picking guide in references/music.md), into ../assets/music by default.\n// Prints length, loudness and the energy split per band, and keeps \u003cout>/tracks.json (id, name, mood, bpm, desc, dur) in sync.\nimport fs from 'node:fs'; import path from 'node:path'; import { spawn } from 'node:child_process';\nimport { render, STYLES } from './lib/chill.js';\nimport { SR, SVF } from './lib/dsp.js';\nimport { FFMPEG } from './platform.mjs';\n\nexport const LIBRARY = ['enquete', 'arpege', 'filature', 'compte-a-rebours', 'sonar', 'code-noir', 'thriller', 'neon'];\n\nconst args = process.argv.slice(2), opt = k => { const i = args.indexOf(k); return i \u003c 0 ? null : args.splice(i, 2)[1]; };\nconst all = args.includes('--all'); if (all) args.splice(args.indexOf('--all'), 1);\nconst out = path.resolve((opt('--out') || path.join(import.meta.dirname, '../assets/music')).replace(/^~/, process.env.HOME || process.env.USERPROFILE)), seed = +(opt('--seed') ?? 11);\nconst ids = all ? Object.keys(STYLES) : args.length ? args : LIBRARY;\nfor (const id of ids) if (!STYLES[id]) throw new Error(`unknown style ${id}: ${Object.keys(STYLES).join(', ')}`);\nfs.mkdirSync(out, { recursive: true });\n\nconst encode = (file, L, R) => new Promise((ok, ko) => {\n const p = spawn(FFMPEG, ['-v', 'error', '-y', '-f', 'f32le', '-ar', String(SR), '-ac', '2', '-i', 'pipe:0', '-c:a', 'libmp3lame', '-b:a', '192k', file], { stdio: ['pipe', 'inherit', 'inherit'] });\n p.on('close', c => c ? ko(new Error(`ffmpeg exited ${c}`)) : ok());\n const I = new Float32Array(L.length * 2); for (let i = 0; i \u003c L.length; i++) { I[2 * i] = L[i]; I[2 * i + 1] = R[i]; }\n p.stdin.end(Buffer.from(I.buffer));\n});\n\nconst bands = (L, R) => { // % of energy below 150, 150-500, 500-2k, 2k-5k, above 5k Hz\n const cuts = [150, 500, 2000, 5000], F = cuts.map(() => new SVF()), E = new Float64Array(5);\n for (let i = 0; i \u003c L.length; i++) {\n let x = (L[i] + R[i]) * .5;\n for (let j = 0; j \u003c 4; j++) { F[j].run(x, cuts[j]); E[j] += F[j].lp * F[j].lp; x = F[j].hp; }\n E[4] += x * x;\n }\n const tot = E.reduce((a, b) => a + b, 0) || 1; return [...E].map(e => Math.round(e / tot * 100));\n};\n\nconst meta = fs.existsSync(path.join(out, 'tracks.json')) ? Object.fromEntries(JSON.parse(fs.readFileSync(path.join(out, 'tracks.json'), 'utf8')).map(t => [t.id, t])) : {};\nfor (const id of ids) {\n const t0 = Date.now(), r = render(id, { seed }), ms = Date.now() - t0;\n let pk = 0; for (const X of [r.L, r.R]) for (let i = 0; i \u003c X.length; i++) pk = Math.max(pk, Math.abs(X[i]));\n await encode(path.join(out, `${id}.mp3`), r.L, r.R);\n const st = STYLES[id]; meta[id] = { id, name: st.name, mood: st.mood, bpm: st.bpm, desc: st.desc, dur: +r.T.toFixed(1) };\n console.log(`[music] ${id.padEnd(13)} ${r.T.toFixed(1)}s ${r.bars} bars ${r.lufs.toFixed(1)} LUFS peak ${(20 * Math.log10(pk)).toFixed(1)} dB bands ${bands(r.L, r.R).join('/')}% ${(ms / 1000).toFixed(1)}s`);\n}\nfs.writeFileSync(path.join(out, 'tracks.json'), JSON.stringify(Object.keys(STYLES).filter(k => meta[k] && fs.existsSync(path.join(out, `${k}.mp3`))).map(k => meta[k]), null, 2) + '\\n');\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3285,"content_sha256":"860d48d11f0b40490a17477472608edcf383feb25f6b5b209fa447caa3f270e4"},{"filename":"engine/package.json","content":"{\n \"name\": \"short-editing-engine\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@mediapipe/tasks-vision\": \"^1.0.1\",\n \"playwright-core\": \"1.63.0\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":174,"content_sha256":"e962e1d7d492300cc2bc861e606e7ed36aac80e3319d80e21081c17d350a55b2"},{"filename":"engine/platform.mjs","content":"// Where the tools live on this machine, macOS or Windows: ffmpeg, ffprobe, Chrome, the face and person-matte helpers.\n// The one-command installer puts portable copies in \u003cproject>/.runtime (bin/ffmpeg, browsers/, models/, runtime.json).\n// Order for each tool: its env var (FFMPEG_PATH, FFPROBE_PATH, CHROME_PATH), then the portable copy, then the PATH.\n// Vision: Apple Vision binaries on a Mac (prebuilt universal in bin/darwin/, else compiled once with swiftc), MediaPipe\n// in headless Chrome everywhere else (vision-web.mjs). SHORT_EDITING_VISION=web forces MediaPipe, e.g. to test it on a Mac.\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport crypto from 'node:crypto';\nimport { execFileSync, spawnSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\n\nexport const ENGINE = path.dirname(fileURLToPath(import.meta.url));\nexport const IS_MAC = process.platform === 'darwin', IS_WIN = process.platform === 'win32';\nconst EXE = IS_WIN ? '.exe' : '';\nconst exists = f => { try { return fs.existsSync(f); } catch { return false; } };\n\n// \u003cdir>/.runtime with a runtime.json, in the skill's folder or a parent (project install), else the cwd or a parent\nfunction findRuntime() {\n if (process.env.SHORT_EDITING_RUNTIME) return path.resolve(process.env.SHORT_EDITING_RUNTIME);\n for (const start of [ENGINE, process.cwd()])\n for (let d = path.resolve(start); ; d = path.dirname(d)) {\n if (exists(path.join(d, '.runtime', 'runtime.json'))) return path.join(d, '.runtime');\n if (d === path.dirname(d)) break;\n }\n const def = path.join(os.homedir(), 'video-editing', '.runtime');\n return exists(path.join(def, 'runtime.json')) ? def : null;\n}\nexport const RUNTIME = findRuntime();\n\nexport function bin(name) {\n const env = process.env[`${name.toUpperCase()}_PATH`];\n if (env) return env;\n const local = RUNTIME && path.join(RUNTIME, 'bin', name + EXE);\n return local && exists(local) ? local : name;\n}\nexport const FFMPEG = bin('ffmpeg'), FFPROBE = bin('ffprobe');\n\n// A Chromium with WebCodecs: Chrome, else Edge (always there on Windows), Brave, Chromium, else the portable headless shell\nexport function findChrome() {\n if (process.env.CHROME_PATH) return process.env.CHROME_PATH;\n const H = os.homedir(), L = process.env.LOCALAPPDATA || path.join(H, 'AppData', 'Local');\n const PF = process.env.PROGRAMFILES || 'C:\\\\Program Files', PF86 = process.env['PROGRAMFILES(X86)'] || 'C:\\\\Program Files (x86)';\n const mac = app => [`/Applications/${app}.app/Contents/MacOS/${app}`, path.join(H, `Applications/${app}.app/Contents/MacOS/${app}`)];\n const list = IS_MAC ? ['Google Chrome', 'Microsoft Edge', 'Brave Browser', 'Chromium'].flatMap(mac)\n : IS_WIN ? [path.join(PF, 'Google\\\\Chrome\\\\Application\\\\chrome.exe'), path.join(PF86, 'Google\\\\Chrome\\\\Application\\\\chrome.exe'),\n path.join(L, 'Google\\\\Chrome\\\\Application\\\\chrome.exe'), path.join(PF86, 'Microsoft\\\\Edge\\\\Application\\\\msedge.exe'),\n path.join(PF, 'Microsoft\\\\Edge\\\\Application\\\\msedge.exe'), path.join(PF, 'BraveSoftware\\\\Brave-Browser\\\\Application\\\\brave.exe')]\n : ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge'];\n return list.find(exists) || headlessShell() || list[0];\n}\n// chrome-headless-shell installed by the installer (playwright layout) in \u003cruntime>/browsers when no browser was found\nfunction headlessShell() {\n const root = RUNTIME && path.join(RUNTIME, 'browsers');\n if (!root || !exists(root)) return null;\n for (const d of fs.readdirSync(root).filter(d => d.startsWith('chromium_headless_shell-')).sort().reverse())\n for (const sub of fs.readdirSync(path.join(root, d))) {\n const f = path.join(root, d, sub, 'chrome-headless-shell' + EXE);\n if (exists(f)) return f;\n }\n return null;\n}\nexport const CHROME = findChrome();\n\n// Low priority for one child: `nice` on macOS and Linux, nothing to wrap on Windows (the process priority is inherited)\nexport const niced = (cmd, args) => IS_WIN ? [cmd, args] : ['nice', ['-n', '10', cmd, ...args]];\n// --eco: background QoS on macOS (efficiency cores), lowest priority elsewhere\nexport function eco() {\n if (IS_MAC) try { execFileSync('taskpolicy', ['-b', '-p', String(process.pid)]); return; } catch {}\n try { os.setPriority(0, os.constants.priority.PRIORITY_LOW); } catch {}\n}\n\nlet encoders;\nexport const hasEncoder = name => new RegExp(`\\\\b${name}\\\\b`).test(encoders ??= (() => {\n try { return execFileSync(FFMPEG, ['-hide_banner', '-encoders'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString(); } catch { return ''; }\n})());\n// H.264 output args: the Apple hardware encoder at the given bitrate when ffmpeg has it, else x264 at a matching quality\nexport function h264(bitrate, maxrate) {\n if (hasEncoder('h264_videotoolbox')) return ['-c:v', 'h264_videotoolbox', '-b:v', bitrate, ...(maxrate ? ['-maxrate', maxrate] : [])];\n return ['-c:v', 'libx264', '-preset', 'fast', '-crf', parseFloat(bitrate) \u003c= 6 ? '23' : '18'];\n}\n\nexport const CACHE = IS_MAC ? path.join(os.homedir(), 'Library/Caches/short-editing')\n : IS_WIN ? path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'short-editing') : path.join(os.homedir(), '.cache/short-editing');\nconst hash = f => crypto.createHash('md5').update(fs.readFileSync(f)).digest('hex').slice(0, 12);\n// xcode-select -p first: a bare xcrun on a Mac without the Command Line Tools opens an install dialog\nconst hasSwiftc = () => IS_MAC && spawnSync('xcode-select', ['-p'], { stdio: 'ignore' }).status === 0 && spawnSync('xcrun', ['--find', 'swiftc'], { stdio: 'ignore' }).status === 0;\n\n// A Swift helper as a native binary: the prebuilt universal one shipped next to it (bin/darwin/\u003cname>, with\n// \u003cname>.hash = the source it was built from), else compiled once into the cache, else the prebuilt one even if older.\nexport function swiftTool(src) {\n if (!IS_MAC) return null;\n const name = path.basename(src, '.swift'), h = hash(src), pre = path.join(path.dirname(src), 'bin', 'darwin', name);\n const preHash = exists(pre + '.hash') ? fs.readFileSync(pre + '.hash', 'utf8').trim() : null;\n if (exists(pre) && preHash === h) return { cmd: pre, id: h };\n const out = path.join(CACHE, `${name}-${h}`);\n if (exists(out)) return { cmd: out, id: h };\n if (hasSwiftc()) {\n fs.mkdirSync(CACHE, { recursive: true }); console.error(`compiling ${name}.swift (once)`);\n execFileSync('xcrun', ['swiftc', '-O', src, '-o', out + '.tmp'], { stdio: ['ignore', 'ignore', 'inherit'] }); fs.renameSync(out + '.tmp', out);\n return { cmd: out, id: h };\n }\n return exists(pre) ? { cmd: pre, id: preHash || h } : null;\n}\n\n// faces: `\u003ccmd> \u003ccamDir> \u003cout.json>`, matte: `\u003ccmd> \u003ccamDir> \u003coutDir> \u003cjobFile>` (same contract on both paths)\nexport function visionTool(name) {\n const force = process.env.SHORT_EDITING_VISION === 'web';\n const apple = !force && swiftTool(path.join(ENGINE, `${name}.swift`));\n if (apple) return { cmd: apple.cmd, args: [], id: apple.id, web: false };\n return { cmd: process.execPath, args: [path.join(ENGINE, 'vision-web.mjs'), name], id: 'web1', web: true };\n}\n\n// The codex CLI as a spawnable command: on Windows npm installs a .cmd shim that spawn() cannot run without a shell,\n// so run its JS entry with this node instead (no shell, no quoting of the prompt)\nexport function codexCmd() {\n if (!IS_WIN) return ['codex', []];\n const roots = [process.env.APPDATA && path.join(process.env.APPDATA, 'npm'), path.dirname(process.execPath)].filter(Boolean);\n try { roots.push(...execFileSync('where', ['codex'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().split(/\\r?\\n/).filter(Boolean).map(f => path.dirname(f))); } catch {}\n for (const r of roots) {\n const js = path.join(r, 'node_modules', '@openai', 'codex', 'bin', 'codex.js');\n if (exists(js)) return [process.execPath, [js]];\n }\n return ['codex', []];\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7927,"content_sha256":"905f49c3df19c36efd21cebe396eb7a69d36cb55cad47f7c5bd2c7912ac37e19"},{"filename":"engine/setup.mjs","content":"#!/usr/bin/env node\n// One-time install of the render engine, macOS, Windows and Linux. Safe to run again: it only does what is missing.\n// node \u003cskill>/engine/setup.mjs install what is missing\n// node \u003cskill>/engine/setup.mjs --check report only, change nothing (exit 1 when something is missing)\n// 1. Node 20+ and ffmpeg (never installed here: the command to run is printed)\n// 2. the engine packages (npm install in engine/, about 15 MB)\n// 3. a Chromium for the render: Chrome, Edge, Brave or Chromium already installed, else a headless shell in \u003cskill>/.runtime\n// 4. whisper.cpp (Homebrew on macOS, the official release on Windows) and its speech model (about 0.5 GB, once)\n// 5. the 8 music beds (generated by code, assets/music/*.mp3) and the Noto icons (assets/icons/*.png)\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { spawnSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\n\nconst ENGINE = path.dirname(fileURLToPath(import.meta.url)), SKILL = path.dirname(ENGINE), RUNTIME = path.join(SKILL, '.runtime');\nconst CHECK = process.argv.includes('--check');\nconst IS_WIN = process.platform === 'win32', IS_MAC = process.platform === 'darwin';\nconst exists = f => { try { return fs.existsSync(f); } catch { return false; } };\nconst run = (cmd, args, opts = {}) => spawnSync(cmd, args, { stdio: 'inherit', shell: IS_WIN, ...opts });\nconst ok = (s) => console.log(`ok ${s}`), todo = (s) => console.log(`todo ${s}`);\nlet missing = 0;\nconst t0 = Date.now();\n\n// 1. Node and ffmpeg\nconst major = +process.versions.node.split('.')[0];\nif (major \u003c 20) { console.log(`Node ${process.versions.node} is too old: install Node 20 or newer (https://nodejs.org)`); process.exit(1); }\nok(`node ${process.versions.node}`);\nconst ffmpegOk = () => spawnSync(process.env.FFMPEG_PATH || 'ffmpeg', ['-version'], { stdio: 'ignore' }).status === 0;\nif (!ffmpegOk()) {\n console.log(`ffmpeg is missing. Install it, open a new terminal, then run this again:\\n ${IS_MAC ? 'brew install ffmpeg' : IS_WIN ? 'winget install Gyan.FFmpeg' : 'sudo apt install ffmpeg'}`);\n process.exit(1);\n}\nok('ffmpeg');\n\n// 2. engine packages\nif (!exists(path.join(ENGINE, 'node_modules', 'playwright-core'))) {\n missing++;\n if (CHECK) todo('engine packages (npm install in engine/)');\n else {\n console.log('installing the engine packages (about 15 MB)...');\n if (run('npm', ['install', '--no-audit', '--no-fund', '--loglevel', 'error'], { cwd: ENGINE }).status) process.exit(1);\n missing--; ok('engine packages');\n }\n} else ok('engine packages');\n\n// 3. a Chromium (platform.mjs finds the installed browser, else the headless shell under .runtime/browsers)\nconst ensureRuntime = () => { fs.mkdirSync(RUNTIME, { recursive: true }); if (!exists(path.join(RUNTIME, 'runtime.json'))) fs.writeFileSync(path.join(RUNTIME, 'runtime.json'), '{}\\n'); };\nlet { CHROME } = await import('./platform.mjs');\nif (!exists(CHROME)) {\n missing++;\n if (CHECK) todo('a Chromium for the render (Chrome, Edge, or the headless shell)');\n else {\n console.log('no Chrome found: installing a headless Chromium for the render (about 100 MB)...');\n ensureRuntime();\n const r = run('npx', ['playwright-core', 'install', 'chromium-headless-shell'], { cwd: ENGINE, env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: path.join(RUNTIME, 'browsers') } });\n if (r.status) process.exit(1);\n ({ CHROME } = await import(`./platform.mjs?again=${Date.now()}`));\n if (!exists(CHROME)) { console.log('the headless Chromium did not install: install Google Chrome, then run this again'); process.exit(1); }\n missing--; ok(`chromium: ${CHROME}`);\n }\n} else ok(`chromium: ${CHROME}`);\n\n// 4. whisper.cpp and its model\nconst W = await import('./whisper.mjs');\nif (!W.findWhisper()) {\n missing++;\n if (CHECK) todo('whisper.cpp (local transcription)');\n else if (IS_MAC) {\n if (spawnSync('brew', ['--version'], { stdio: 'ignore' }).status) { console.log('whisper.cpp needs Homebrew (https://brew.sh), then: brew install whisper-cpp'); process.exit(1); }\n console.log('installing whisper.cpp with Homebrew...');\n if (run('brew', ['install', 'whisper-cpp']).status) process.exit(1);\n missing--;\n } else if (IS_WIN) {\n console.log('downloading whisper.cpp for Windows (official release)...');\n ensureRuntime();\n const dir = path.join(RUNTIME, 'whisper'), zip = path.join(RUNTIME, 'whisper-bin-x64.zip');\n fs.mkdirSync(dir, { recursive: true });\n const r = await fetch('https://github.com/ggml-org/whisper.cpp/releases/latest/download/whisper-bin-x64.zip');\n if (!r.ok) { console.log(`download failed (HTTP ${r.status}): get whisper-bin-x64.zip from https://github.com/ggml-org/whisper.cpp/releases and unzip it into ${dir}`); process.exit(1); }\n fs.writeFileSync(zip, Buffer.from(await r.arrayBuffer()));\n if (spawnSync('tar', ['-xf', zip, '-C', dir], { stdio: 'inherit' }).status) process.exit(1);\n fs.unlinkSync(zip);\n missing--;\n } else { console.log('whisper.cpp: build it (https://github.com/ggml-org/whisper.cpp) and put whisper-cli on the PATH, or set WHISPER_CLI'); process.exit(1); }\n if (!CHECK) { if (!W.findWhisper()) { console.log('whisper-cli still not found: open a new terminal and run this again'); process.exit(1); } ok(`whisper.cpp: ${W.findWhisper()}`); }\n} else ok(`whisper.cpp: ${W.findWhisper()}`);\nif (!exists(W.modelPath())) {\n missing++;\n if (CHECK) todo(`speech model ${path.basename(W.modelPath())} (about 0.5 GB)`);\n else { await W.ensureModel(); missing--; ok(`speech model ${path.basename(W.modelPath())}`); }\n} else ok(`speech model ${path.basename(W.modelPath())}`);\n\n// 5. music beds and icons, both generated on this computer\nconst env = { ...process.env, CHROME_PATH: process.env.CHROME_PATH || CHROME };\nconst beds = JSON.parse(fs.readFileSync(path.join(SKILL, 'assets/music/tracks.json'), 'utf8'));\nconst ids = (Array.isArray(beds) ? beds : beds.tracks || []).map(t => t.id).filter(Boolean);\nconst noBed = ids.filter(id => !exists(path.join(SKILL, 'assets/music', `${id}.mp3`)));\nif (noBed.length) {\n missing++;\n if (CHECK) todo(`music beds (${noBed.length} to generate)`);\n else {\n console.log(`generating ${noBed.length} music beds (code, no download, about a minute)...`);\n if (run(process.execPath, [path.join(ENGINE, 'music.mjs')], { cwd: ENGINE, shell: false }).status) process.exit(1);\n missing--; ok('music beds');\n }\n} else ok(`music beds (${ids.length})`);\nif (!exists(path.join(SKILL, 'assets/icons/check.png'))) {\n missing++;\n if (CHECK) todo('icons (Google Noto, drawn from assets/icons/svg)');\n else {\n console.log('drawing the icons (Google Noto emoji, about 30 s)...');\n if (run(process.execPath, [path.join(SKILL, 'assets/icons/build-icons.mjs')], { cwd: ENGINE, env, shell: false }).status) process.exit(1);\n missing--; ok('icons');\n }\n} else ok('icons');\n\nconsole.log(missing ? `${missing} thing(s) to install: run node ${path.relative(process.cwd(), fileURLToPath(import.meta.url))}` : `engine ready (${((Date.now() - t0) / 1000).toFixed(0)} s)`);\nprocess.exit(missing ? 1 : 0);\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7126,"content_sha256":"4765c42211d36cfd4a4050f588efccd90a706d9524e273fab7a0921395795590"},{"filename":"engine/short.html","content":"\u003c!doctype html>\n\u003chtml>\n\u003chead>\n\u003cmeta charset=\"utf-8\">\n\u003clink rel=\"icon\" href=\"data:,\">\n\u003cstyle>\n@font-face { font-family: 'Inter'; src: url('fonts/Inter.ttf') format('truetype'); font-weight: 100 900; }\n@font-face { font-family: 'JetBrains Mono'; src: url('fonts/JetBrainsMono.ttf') format('truetype'); font-weight: 100 800; }\n/* caption and display families (style captions.font, display.font / display.text); variable, opsz follows the size */\n@font-face { font-family: 'DM Sans'; src: url('fonts/DMSans.ttf') format('truetype'); font-weight: 100 1000; }\n@font-face { font-family: 'Bricolage Grotesque'; src: url('fonts/BricolageGrotesque.ttf') format('truetype'); font-weight: 200 800; font-stretch: 75% 100%; }\n@font-face { font-family: 'Poppins'; src: url('fonts/Poppins-SemiBold.ttf') format('truetype'); font-weight: 600; }\n@font-face { font-family: 'Archivo'; src: url('fonts/Archivo.ttf') format('truetype'); font-weight: 100 900; font-stretch: 62% 125%; }\n@font-face { font-family: 'Archivo Black'; src: url('fonts/ArchivoBlack.ttf') format('truetype'); font-weight: 400; }\n@font-face { font-family: 'Bowlby One SC'; src: url('fonts/BowlbyOneSC.ttf') format('truetype'); font-weight: 400; }\n@font-face { font-family: 'Geist Pixel'; src: url('fonts/GeistPixel-Square.woff2') format('woff2'); font-weight: 400 900; }\n@font-face { font-family: 'Instrument Serif'; src: url('fonts/InstrumentSerif-Regular.ttf') format('truetype'); font-weight: 400; }\n@font-face { font-family: 'Instrument Serif'; src: url('fonts/InstrumentSerif-Italic.ttf') format('truetype'); font-weight: 400; font-style: italic; }\n/* Apple system fonts for every UI drawn in a captured component (macOS only; elsewhere the stack falls back) */\n@font-face { font-family: 'SF Pro'; src: url('file:///System/Library/Fonts/SFNS.ttf'); font-weight: 1 1000; }\n@font-face { font-family: 'SF Pro'; src: url('file:///System/Library/Fonts/SFNSItalic.ttf'); font-weight: 1 1000; font-style: italic; }\n@font-face { font-family: 'SF Mono'; src: url('file:///System/Library/Fonts/SFNSMono.ttf'); font-weight: 1 1000; }\nhtml, body { margin: 0; background: #F1EEE6; }\ncanvas { width: 540px; height: 960px; display: block; }\n\u003c/style>\n\u003cscript src=\"/p/source/spec.js\">\u003c/script>\n\u003c/head>\n\u003cbody>\n\u003cscript type=\"module\">\n// format 'youtube' (16:9 long-form: paper left, camera right) has its own runtime; every other format shares runtime.js\nconst { boot } = await import(window.SPEC?.format === 'youtube' ? './lib/runtime-yt.js' : './lib/runtime.js');\nawait boot().catch(e => { console.error(e); window.READY_ERROR = String(e && e.stack || e); });\n// ?play=1: live preview in a normal tab (short.mjs preview)\nif (window.READY && new URLSearchParams(location.search).has('play')) (await import('./lib/player.js')).play();\n\u003c/script>\n\u003c/body>\n\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":2798,"content_sha256":"fe8dc092373761ee1df4bb5cf6da58998e1ca3586d6c25bc1737de06bbcb0ba8"},{"filename":"engine/short.mjs","content":"#!/usr/bin/env node\n// short-editing engine CLI. Every heavy step runs niced (and optionally on efficiency cores with --eco).\n// node short.mjs init \u003cproject> [--source take.mp4|latest] new project: folders, starter spec framed on the take, style snapshot\n// node short.mjs words \u003cproject> [lang] word timestamps: whisper.cpp on this computer (ElevenLabs Scribe when ELEVEN_KEY is set) -> assets/words-scribe.json (lang: profile creator.lang)\n// node short.mjs prepare \u003cproject> tighten silences, splice voice, extract cam + media frames\n// node short.mjs rough \u003cproject> silences cut, full frame, spliced voice -> output/\u003cslug>-rough.mp4 (studio first pass)\n// node short.mjs stills \u003cproject> [t...] render PNG stills (SHORT time; suffix \"s\" = source time) + contact sheet\n// node short.mjs capture \u003cproject> \u003cpage.html> --dur \u003cs> [--size 1080x1920] [--alpha] [--out assets/x.mov] [--at t,t] HTML motion component (window.setT) -> clip + sheet\n// node short.mjs render \u003cproject> [--scale .5] [--fps 30] [--from a --to b] [--workers 2] [--jpeg] [--eco] [--out file.mp4]\n// node short.mjs audio \u003cproject> music bed + SFX + ducked voice -> assets/mix.wav\n// node short.mjs final \u003cproject> mux, poster, QC -> output/\u003cslug>.mp4\n// node short.mjs all \u003cproject> render (audio mixed beside it) + final\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport http from 'node:http';\nimport vm from 'node:vm';\nimport crypto from 'node:crypto';\nimport { spawn, spawnSync, execFileSync } from 'node:child_process';\nimport { fileURLToPath, pathToFileURL } from 'node:url';\nimport { mapper, mapSpec } from './lib/timeline.js';\nimport { withStyle } from './lib/style.js';\nimport { openChrome } from './chrome.mjs';\nimport { CHROME, FFMPEG, FFPROBE, eco, h264, hasEncoder, visionTool } from './platform.mjs';\n\nconst ENGINE = path.dirname(fileURLToPath(import.meta.url));\nconst FPS = 30, SR = 48000;\nconst BT709 = ['-color_range', 'tv', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709'];\n\nconst argv = process.argv.slice(2);\nconst cmd = argv[0], PROJ = argv[1] && path.resolve(argv[1].replace(/^~/, os.homedir()));\nconst flag = (k, d) => { const i = argv.indexOf('--' + k); return i \u003c 0 ? d : (argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : true); };\nconst pos = argv.slice(2).filter((a, i, A) => !a.startsWith('--') && !(A[i - 1] || '').startsWith('--'));\nif (!cmd || !PROJ) { console.log(fs.readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\\n').slice(1, 10).join('\\n')); process.exit(1); }\n\n// Be a good citizen: low priority for us and every child (ffmpeg, Chrome inherit it).\ntry { os.setPriority(0, 15); } catch {}\nif (flag('eco')) eco();\n\nconst P = (...a) => path.join(PROJ, ...a);\nconst readJSON = f => JSON.parse(fs.readFileSync(f, 'utf8'));\n// source/style.json (creator style snapshot, lib/style.js) fills what the spec leaves out; absent = legacy project\nconst loadStyle = () => fs.existsSync(P('source/style.json')) ? readJSON(P('source/style.json')) : null;\n// the style's illustration library (\u003cprofile>/styles/\u003cstyle>/illustrations/library, engine/library.mjs): an\n// assets/images/\u003cname> missing from the project is read there, so a spec can use any illustration of its style\nlet libMemo;\nconst styleLib = () => libMemo !== undefined ? libMemo : (libMemo = (() => {\n const name = String(loadStyle()?.name || '').toLowerCase(), dir = path.join(HOME(), 'styles');\n if (!name || !fs.existsSync(dir)) return null;\n const nameOf = d => { try { return String(readJSON(path.join(d, 'style.json')).name || '').toLowerCase(); } catch { return ''; } };\n const d = fs.readdirSync(dir).map(n => path.join(dir, n)).find(d => nameOf(d) === name || path.basename(d).toLowerCase() === name);\n return d && fs.existsSync(path.join(d, 'illustrations/library')) ? path.join(d, 'illustrations/library') : null;\n})());\nconst projFile = r => {\n const f = path.resolve(PROJ, r), img = path.join(PROJ, 'assets/images') + path.sep;\n if (fs.existsSync(f) || !f.startsWith(img) || !styleLib()) return f;\n const g = path.join(styleLib(), f.slice(img.length));\n return fs.existsSync(g) ? g : f;\n};\nconst loadSpec = () => { const ctx = { window: {} }; vm.runInNewContext(fs.readFileSync(P('source/spec.js'), 'utf8'), ctx); return withStyle(ctx.window.SPEC, loadStyle()); };\n// spec.format: 'square' renders 1080x1080 (X feed), 'wide' 1920x1080 (16:9), 'youtube' 1920x1080 long-form\n// (lib/runtime-yt.js: a 1080x1080 paper stage left of the camera), default is the 1080x1920 short.\nconst frameWH = () => ({ square: [1080, 1080], wide: [1920, 1080], youtube: [1920, 1080] })[loadSpec().format] || [1080, 1920];\n// the scenes' stage: format 'youtube' draws them on a 1080x1080 square, composited into the 1920x1080 frame\nconst stageWH = () => loadSpec().format === 'youtube' ? [1080, 1080] : frameWH();\nconst frameH = () => frameWH()[1];\nconst expand = p => p.replace(/^~/, os.homedir());\nconst ff = (args, o = {}) => execFileSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-y', ...args], { stdio: ['ignore', 'inherit', 'inherit'], ...o });\nconst probe = (f, e = 'format=duration') => execFileSync(FFPROBE, ['-v', 'error', '-show_entries', e, '-of', 'default=nw=1:nk=1', f]).toString().trim();\nconst snap = x => Math.round(x * FPS) / FPS;\nconst log = (...a) => console.log('[short]', ...a);\nconst md5 = b => crypto.createHash('md5').update(b).digest('hex');\nconst pad5 = i => String(i).padStart(5, '0');\n\n// ---------------------------------------------------------------- prepare\n// Loudness of the source voice in 10 ms frames (dB), used to find the real silences between words.\nfunction voiceFrames() {\n const raw = execFileSync(FFMPEG, ['-v', 'error', '-i', P('assets/source.wav'), '-f', 'f32le', '-ac', '1', '-ar', '16000', '-'], { maxBuffer: 1 \u003c\u003c 30 });\n const x = new Float32Array(raw.buffer, raw.byteOffset, raw.length / 4), hop = 160, db = new Float32Array(Math.floor(x.length / hop));\n for (let f = 0; f \u003c db.length; f++) { let e = 0; for (let i = f * hop; i \u003c (f + 1) * hop; i++) e += x[i] * x[i]; db[f] = 10 * Math.log10(e / hop + 1e-12); }\n const ref = Float32Array.from(db).sort()[Math.floor(db.length * .95)];\n return { db, quiet: ref - 21, dt: .01 };\n}\n\nfunction tighten(spec) {\n // Micro-silence cutter. At every word boundary, find the longest truly quiet run in the audio (21 dB under the\n // voice) and cut it down to pOut + pIn: a tiny breath, never a clipped consonant. g = the breath budget for that\n // boundary (sentence / comma / word). speed > 1 plays the whole short faster.\n // Long-form: T.gap = only quiet runs longer than this are cut (natural breathing stays, a few cuts per minute instead\n // of a jump every sentence), T.minKeep = a keep shorter than this folds into its neighbour across an automatic gap\n // (never across spec.cut), T.paper = { sentence, comma, word, gap } overrides where the layout shows full paper\n // (face hidden, a tighter cut is invisible).\n const T = { sentence: .10, comma: .06, word: .03, lead: .03, tail: .9, gap: 0, minKeep: 0, ...(spec.tighten || {}) };\n const LK = spec.layout?.keys || [];\n const layAt = t => { let x = 0; for (const [kt, kx] of LK) { if (kt \u003c= t) x = kx; else break; } return x; };\n const sp = spec.speed || 1;\n const words = readJSON(P('assets/words-scribe.json')).words.filter(w => w.type === 'word');\n const dur = +probe(P('assets/source.mp4'));\n const V = voiceFrames();\n const quietRun = (t0, t1) => {\n let best = null, st = -1;\n const f0 = Math.max(0, Math.floor(t0 / V.dt)), f1 = Math.min(V.db.length, Math.ceil(t1 / V.dt));\n for (let f = f0; f \u003c= f1; f++) {\n const q = f \u003c f1 && V.db[f] \u003c V.quiet;\n if (q && st \u003c 0) st = f;\n if (!q && st >= 0) { if (!best || f - st > best[1] - best[0]) best = [st, f]; st = -1; }\n }\n return best && [best[0] * V.dt, best[1] * V.dt];\n };\n const quietShare = (t0, t1) => {\n const f0 = Math.max(0, Math.floor(t0 / V.dt)), f1 = Math.min(V.db.length, Math.ceil(t1 / V.dt));\n if (f1 \u003c= f0) return 1;\n let n = 0; for (let f = f0; f \u003c f1; f++) n += V.db[f] \u003c V.quiet; return n / (f1 - f0);\n };\n let keeps = [], a = Math.max(0, words[0].start - T.lead);\n for (let i = 0; i \u003c words.length - 1; i++) {\n const c = words[i], n = words[i + 1];\n const TT = T.paper && layAt(c.end) >= 1920 ? { ...T, ...T.paper } : T;\n const g = /[.!?…]$/.test(c.text) ? TT.sentence : /[,;:]$/.test(c.text) ? TT.comma : TT.word;\n const pOut = Math.max(.035, g * .6), pIn = Math.max(.025, g * .4);\n // cut = the wider of the quiet run in the audio and the gap between the word timings\n // the word-timing gap only counts when the audio there is really quiet: Scribe often ends a word early or starts the\n // next one late (\"10 M\" = \"dix millions\" with \"mil-\" outside both words), and cutting that gap clipped the word\n const qa = quietRun(c.end - .06, n.start + .06), qw = quietShare(c.end, n.start) >= .5 ? [c.end, n.start] : null;\n const q = qa && (!qw || qa[1] - qa[0] > qw[1] - qw[0]) ? qa : qw;\n if (q && q[1] - q[0] > Math.max(pOut + pIn + .02, TT.gap)) { keeps.push([a, q[0] + pOut]); a = q[1] - pIn; }\n }\n keeps.push([a, Math.min(dur, words.at(-1).end + T.tail)]);\n // spec.hold: [[a, b]] SOURCE ranges kept verbatim, silences included (a sung rhythm, a beat left for an effect)\n for (const [ha, hb] of spec.hold || []) keeps.push([ha, hb]);\n keeps.sort((x, y) => x[0] - y[0]);\n for (const [ca, cb] of spec.cut || []) keeps = keeps.flatMap(([a, b]) => cb \u003c= a || ca >= b ? [[a, b]] : [[a, ca], [cb, b]].filter(([x, y]) => y - x > .02));\n keeps = keeps.map(([a, b]) => [snap(a), snap(b)]).filter(([a, b]) => b > a);\n const merged = [];\n for (const k of keeps) { const l = merged.at(-1); if (l && k[0] \u003c= l[1]) l[1] = Math.max(l[1], k[1]); else merged.push(k); }\n if (T.minKeep) { // fold a too-short keep into the neighbour with the smaller automatic gap (the silence comes back)\n const userGap = (a, b) => (spec.cut || []).some(([ca, cb]) => ca \u003c b && cb > a);\n for (let i = 0; i \u003c merged.length;) {\n const [a, b] = merged[i], p = merged[i - 1], n = merged[i + 1];\n const gp = p && !userGap(p[1], a) ? a - p[1] : Infinity, gn = n && !userGap(b, n[0]) ? n[0] - b : Infinity;\n if (b - a >= T.minKeep || (gp === Infinity && gn === Infinity)) { i++; continue; }\n if (gp \u003c= gn) { p[1] = b; merged.splice(i, 1); i--; } else { merged[i][1] = n[1]; merged.splice(i + 1, 1); }\n }\n }\n // spec.pause: the whole short stops at SOURCE time `at` for the length of an inserted voice (a narrator),\n // then resumes. The keep that contains `at` is split so the pause lands exactly there.\n const PZ = spec.pause && { ...spec.pause, at: snap(spec.pause.at) };\n if (PZ) {\n PZ.words = PZ.words ? readJSON(P(PZ.words)) : [];\n PZ.dur = snap(+probe(P(PZ.voice)) + (PZ.tail ?? .12));\n const i = merged.findIndex(([a, b]) => PZ.at > a && PZ.at \u003c b);\n if (i >= 0) merged.splice(i, 1, [merged[i][0], PZ.at], [PZ.at, merged[i][1]]);\n }\n // spec.inserts: [{at, voice, tail?, gain?}] short voice clips (a chapter line, \"Piège numéro deux\") dropped between\n // two sentences at SOURCE time `at`: the short makes room for each one, the camera holds its frame, the music runs\n // on (no grey drain, no tape stop). Time slots take 'iK+X' = X seconds into insert K.\n const INS = (spec.inserts || []).map((v, k) => ({ k, at: snap(v.at), dur: snap(+probe(P(v.voice)) + (v.tail ?? .12)) }));\n for (const v of INS) {\n const i = merged.findIndex(([a, b]) => v.at > a && v.at \u003c b);\n if (i >= 0) merged.splice(i, 1, [merged[i][0], v.at], [v.at, merged[i][1]]);\n }\n // o = SHORT-time start of each keep (already divided by speed)\n let off = 0, pause = null;\n const ins = [], todo = [...INS].sort((x, y) => x.at - y.at);\n const K = merged.map(([a, b]) => {\n if (PZ && !pause && a >= PZ.at) { pause = { t0: snap(off), dur: PZ.dur, at: PZ.at }; off = pause.t0 + PZ.dur; }\n while (todo.length && a >= todo[0].at) { const v = todo.shift(); ins[v.k] = { t0: snap(off), dur: v.dur, at: v.at }; off = ins[v.k].t0 + v.dur; }\n const r = [a, b, +off.toFixed(6)]; off += (b - a) / sp; return r;\n });\n for (const v of todo) { ins[v.k] = { t0: snap(off), dur: v.dur, at: v.at }; off += v.dur; }\n // spec.outro: seconds added after the last word (an end card): silent voice, the camera holds its last frame\n const OUT = Math.max(0, +spec.outro || 0);\n const tl = { fps: FPS, speed: sp, duration: +(off + OUT).toFixed(6), source: +dur.toFixed(3), keeps: K, words: [] };\n if (pause) tl.pause = pause;\n if (INS.length) tl.inserts = ins;\n if (OUT) tl.outro = { t0: +off.toFixed(6), dur: OUT };\n const m = mapper(tl);\n // words inside a spec.cut never reach the captions. A word the micro-silence cutter swallowed (Scribe gave a fast\n // \"je\", \"me\", \"que\" a sliver in a quiet run, the real voice sits next to its neighbour) folds into that neighbour.\n const mid = w => (w.start + w.end) / 2;\n const kept = w => K.some(([a, b]) => mid(w) >= a && mid(w) \u003c= b);\n const userCut = w => (spec.cut || []).some(([a, b]) => mid(w) >= a && mid(w) \u003c= b);\n const ws = [];\n let carry = '';\n for (const w of words) {\n if (userCut(w)) continue;\n if (!kept(w)) {\n const l = ws.at(-1);\n if (l && !/[.!?…]$/.test(l.text) && w.start - l.e0 \u003c .6) l.text += ' ' + w.text;\n else carry += w.text + ' ';\n continue;\n }\n ws.push({ text: carry + w.text, start: +m(w.start).toFixed(3), end: +m(w.end).toFixed(3), s0: w.start, e0: w.end });\n carry = '';\n }\n tl.words = ws;\n if (pause) { // narrator words, in SHORT time, with s0 = null (words.md prints them as p+X)\n tl.words.push(...PZ.words.map(w => ({ text: w.text, start: +(pause.t0 + w.start).toFixed(3), end: +(pause.t0 + w.end).toFixed(3), s0: null, e0: null, narrator: true })));\n tl.words.sort((x, y) => x.start - y.start);\n }\n return tl;\n}\n\nfunction spliceVoice(tl) {\n const raw = execFileSync(FFMPEG, ['-v', 'error', '-i', P('assets/source.wav'), '-f', 'f32le', '-ac', '1', '-ar', String(SR), '-'], { maxBuffer: 1 \u003c\u003c 30 });\n const src = new Float32Array(raw.buffer, raw.byteOffset, raw.length / 4), sp = tl.speed || 1;\n const N = Math.round(tl.duration * sp * SR), out = new Float32Array(N), F = Math.round(.006 * SR);\n for (const [a, b, o] of tl.keeps) {\n const s0 = Math.round(a * SR), n = Math.round((b - a) * SR), d0 = Math.round(o * sp * SR);\n for (let i = 0; i \u003c n && d0 + i \u003c N; i++) {\n const g = Math.min(1, (i + 1) / F, (n - i) / F);\n out[d0 + i] = (src[s0 + i] || 0) * (a === 0 && i \u003c F ? 1 : g);\n }\n }\n const tmp = P('assets/voice.f32');\n fs.writeFileSync(tmp, Buffer.from(out.buffer));\n ff(['-f', 'f32le', '-ar', String(SR), '-ac', '1', '-i', tmp, ...(sp !== 1 ? ['-af', `atempo=${sp}`] : []), '-c:a', 'pcm_s16le', P('assets/voice.wav')]);\n fs.unlinkSync(tmp);\n}\n\n// the source voice track (words makes it too); words from elsewhere skip that step\nfunction sourceWav() {\n const wav = P('assets/source.wav');\n if (!fs.existsSync(wav) || fs.statSync(wav).mtimeMs \u003c fs.statSync(P('assets/source.mp4')).mtimeMs)\n ff(['-i', P('assets/source.mp4'), '-vn', '-ac', '1', '-ar', '48000', '-c:a', 'pcm_s16le', wav]);\n}\n\n// ffmpeg select expression keeping the timeline's SOURCE ranges\nfunction keepSelect(tl) {\n // balanced (a+b)+(c+d) tree: a flat a+b+c+... sum of ~100+ keeps overflows ffmpeg's expression parser\n const tree = a => a.length === 1 ? a[0] : `(${tree(a.slice(0, a.length >> 1))}+${tree(a.slice(a.length >> 1))})`;\n // a - .001: a keep starting on a frame whose time rounds up at 4 decimals would lose that frame (1 per keep, ~0.7 s drift)\n return tree(tl.keeps.map(([a, b]) => `between(t,${(a - .001).toFixed(4)},${(b - .001).toFixed(4)})`));\n}\n\n// grade: true = the default look, fitted to the take's exposure: it measures the face (a box under cam.eyes, median of 5\n// frames). DIM (face luma \u003c= 80, a dim warm room): brighter face and mids, solid blacks, 0.84 saturation so the wood and\n// lamps stop glowing orange, a slight cool shift. NATURAL (face luma >= 100, every normal take): no colour shift at all,\n// 0.96 saturation, the black point stays, and a gentle mids lift that fades out as the take gets brighter (full at face\n// luma 108 or less, none at 128). 80 to 100 blends the two. Both add a touch of clarity. A string = your own ffmpeg filter\n// chain, false/absent = the raw take. Baked into frames/cam (and so into the cutout matte); prepare writes stills/grade.jpg\n// (raw | graded, same frame) to check it. Rejected: a contrasty cool grade (\"trop saturé, pas assez lumineux\"), the DIM lift\n// on a well lit take (\"les couleurs de la caméra sont pas bonnes\": washed out face, pale greenish skin), then the old\n// DIM/LIT blend on a 108 take (red cut in the mids, lifted blacks: grey washed out skin, \"les couleurs de la caméra sont\n// pas top\", 2026-10-10).\nconst GRADE_X = [0, .12, .3, .5, .75];\nconst DIM = { rs: -.025, bs: .015, rm: -.035, bm: .01, curve: [.012, .18, .46, .69, .89], sat: .84 };\nconst NAT = { rs: 0, bs: 0, rm: 0, bm: 0, lift: [0, .005, .025, .04, .03], sat: .96 };\nfunction faceLuma(spec) { // mean luma (0-255) of a box on the face, median over 5 frames; null when it cannot measure\n const [cx, cy, cw, ch] = spec.cam?.crop || [400, 0, 1200, 1080], [ex, ey] = spec.cam?.eyes || [cw / 2, ch * .4];\n const s = Math.round(ch * .22), x = Math.max(0, Math.round(cx + ex - s / 2)), y = Math.max(0, Math.round(cy + ey - s / 4));\n try {\n const d = +execFileSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', P('assets/source.mp4')]).toString().trim();\n const ys = [.2, .35, .5, .65, .8].map(p => {\n const out = execFileSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-ss', (d * p).toFixed(2), '-i', P('assets/source.mp4'), '-frames:v', '1',\n '-vf', `crop=${s}:${s}:${x}:${y},signalstats,metadata=print:file=-`, '-f', 'null', '-']).toString();\n return +(out.match(/YAVG=([\\d.]+)/)?.[1] ?? NaN);\n }).filter(Number.isFinite).sort((a, b) => a - b);\n return ys.length ? ys[ys.length >> 1] : null;\n } catch { return null; }\n}\n// white point: a webcam balanced for the warm lamp turns a white cap or shirt lit by the window mint/cyan (\"les couleurs\n// sont pas top\"). Mean of the near-white pixels on him (a box under cam.eyes, skips the wood and the lamp-lit wall),\n// 5 frames; each channel's highlights are bent so that white reads neutral (at most .07), the shadows and mids barely move,\n// so the skin and the wood keep their colour. cam.wb: false turns it off. Returns a curves filter or ''.\nfunction whitePoint(spec) {\n if (spec.cam?.wb === false) return '';\n const [cx, cy, cw, ch] = spec.cam?.crop || [400, 0, 1200, 1080], [ex, ey] = spec.cam?.eyes || [cw / 2, ch * .4];\n const bw = Math.round(cw * .5), bx = Math.round(Math.min(Math.max(0, ex - bw / 2), cw - bw)), by = Math.round(Math.max(0, ey - ch * .35)), bh = ch - by;\n try {\n const d = +execFileSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', P('assets/source.mp4')]).toString().trim();\n const sum = [0, 0, 0]; let n = 0, all = 0;\n for (const p of [.2, .35, .5, .65, .8]) {\n const px = execFileSync(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-ss', (d * p).toFixed(2), '-i', P('assets/source.mp4'), '-frames:v', '1',\n '-vf', `crop=${bw}:${bh}:${cx + bx}:${cy + by},scale=96:-2:in_color_matrix=${srcMatrix()}:in_range=tv,format=rgb24`, '-f', 'rawvideo', '-'], { maxBuffer: 1 \u003c\u003c 24 });\n for (let i = 0; i + 2 \u003c px.length; i += 3) {\n const r = px[i], g = px[i + 1], b = px[i + 2], lo = Math.min(r, g, b), hi = Math.max(r, g, b); all++;\n if (lo > 140 && hi \u003c 252 && hi - lo \u003c 55) { sum[0] += r; sum[1] += g; sum[2] += b; n++; }\n }\n }\n if (n \u003c all * .02) return '';\n const w = sum.map(v => v / n / 255), t = (w[0] + w[1] + w[2]) / 3, f = v => +v.toFixed(3);\n if (Math.max(...w.map(v => Math.abs(t - v))) \u003c .012) return '';\n const ch3 = ['r', 'g', 'b'].map((c, i) => {\n const dv = Math.max(-.07, Math.min(.07, t - w[i]));\n return `${c}='0/0 .5/${f(.5 + dv * .3)} ${f(w[i])}/${f(w[i] + dv)} 1/${f(dv \u003c 0 ? 1 + dv * .25 : 1)}'`;\n });\n log(`grade: white point ${w.map(v => Math.round(v * 255)).join(',')} -> neutral`);\n return `curves=${ch3.join(':')}`;\n } catch { return ''; }\n}\nlet gradeMemo = null;\nfunction gradeOf(spec) {\n if (spec.grade !== true) return typeof spec.grade === 'string' && spec.grade.trim() ? spec.grade.trim() : '';\n if (gradeMemo != null) return gradeMemo;\n const y = faceLuma(spec), cl = v => Math.min(1, Math.max(0, v));\n const k = y == null ? 0 : cl((100 - y) / 20), a = y == null ? 1 : cl((128 - y) / 20); // k: 1 = DIM, 0 = NATURAL; a: NATURAL's lift\n const mix = (d, n) => +(n + (d - n) * k).toFixed(3);\n const curve = GRADE_X.map((x, i) => `${x}/${mix(DIM.curve[i], x + NAT.lift[i] * a)}`).join(' '), wp = whitePoint(spec);\n const cb = ['rs', 'bs', 'rm', 'bm'].map(c => [c, mix(DIM[c], NAT[c])]).filter(([, v]) => v);\n log(`grade: face luma ${y == null ? '?' : y.toFixed(0)} -> ${k >= .99 ? 'dim room' : k > .01 ? `blend ${k.toFixed(2)} dim` : `natural, lift ${a.toFixed(2)}`}`);\n return gradeMemo = (wp ? wp + ',' : '') + (cb.length ? `colorbalance=${cb.map(([c, v]) => `${c}=${v}`).join(':')},` : '')\n + `curves=master='${curve} 1/1',eq=saturation=${mix(DIM.sat, NAT.sat)},unsharp=5:5:.3`;\n}\n// stills/grade.jpg: the same frame of the take raw (left) and graded (right), to check the skin at every prepare\nfunction gradeSheet(spec, g) {\n const [cx, cy, cw, ch] = spec.cam?.crop || [400, 0, 1200, 1080], m = srcMatrix();\n try {\n const d = +execFileSync(FFPROBE, ['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', P('assets/source.mp4')]).toString().trim();\n fs.mkdirSync(P('stills'), { recursive: true });\n ff(['-ss', (d * .35).toFixed(2), '-i', P('assets/source.mp4'), '-frames:v', '1', '-filter_complex',\n `[0]crop=${cw}:${ch}:${cx}:${cy},split[a][b];[a]scale=in_color_matrix=${m}:in_range=tv,format=gbrp,scale=out_color_matrix=bt601:out_range=pc,format=yuvj420p,scale=720:-2[r];`\n + `[b]${gradeChain(g)},scale=720:-2[g];[r][g]hstack`, '-q:v', '3', P('stills/grade.jpg')]);\n log('grade: stills/grade.jpg (raw | graded)');\n } catch { }\n}\n// a graded take is decoded to RGB with its real matrix (untagged HD = BT.709, as players show it), then written as JPEG (BT.601 full range)\nfunction srcMatrix() {\n const [cs, h] = execFileSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=color_space,height', '-of', 'default=nw=1:nk=1', P('assets/source.mp4')]).toString().trim().split('\\n');\n return ({ bt709: 'bt709', smpte170m: 'bt601', bt470bg: 'bt601', bt2020nc: 'bt2020' })[cs] || (+h >= 720 ? 'bt709' : 'bt601');\n}\nfunction gradeChain(g) {\n return `scale=in_color_matrix=${srcMatrix()}:in_range=tv,format=gbrp,${g},scale=out_color_matrix=bt601:out_range=pc,format=yuvj420p`;\n}\n\nfunction extractCam(spec, tl) {\n const [cx, cy, cw, ch] = spec.cam?.crop || [400, 0, 1200, 1080], g = gradeOf(spec);\n const dir = P('frames/cam'); fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true });\n const sel = keepSelect(tl);\n // software decode on all cores: 6 s for a 143 s HEVC take, VideoToolbox (copy-back) and -threads 4 are ~8 s\n // fps= BEFORE select: a VFR phone/Tella take is first put on the exact 30 fps grid, so every keep yields exactly\n // (b - a) * 30 frames and the picture never drifts from the voice (select on raw VFR lost ~7 frames over 12 min).\n // spec.cam.q = JPEG quality (2 = near lossless for long-form, 3 = default)\n ff(['-threads', '0', '-i', P('assets/source.mp4'), '-vf', `fps=${FPS},select='${sel}',setpts=N/${FPS * (tl.speed || 1)}/TB,fps=${FPS},crop=${cw}:${ch}:${cx}:${cy}${g ? ',' + gradeChain(g) : ''}`,\n '-fps_mode', 'passthrough', '-an', '-q:v', String(spec.cam?.q ?? 3), '-start_number', '0', path.join(dir, '%05d.jpg')]);\n let n = fs.readdirSync(dir).filter(f => f.endsWith('.jpg')).length;\n // freeze (spec.pause, spec.inserts), earliest first: shift every frame after the gap, then fill it with the last frame before it\n for (const g of [tl.pause, ...(tl.inserts || [])].filter(Boolean).sort((x, y) => x.t0 - y.t0)) {\n const i0 = Math.round(g.t0 * FPS), k = Math.round(g.dur * FPS), f = i => path.join(dir, `${String(i).padStart(5, '0')}.jpg`);\n for (let i = n - 1; i >= i0; i--) fs.renameSync(f(i), f(i + k));\n for (let i = i0; i \u003c i0 + k; i++) fs.linkSync(f(Math.max(0, i0 - 1)), f(i));\n n += k;\n }\n return n;\n}\n\n// media.sync: true = a second track of the take (a screen recording on the same clock as source.mp4): it is cut like\n// the camera, frames indexed in SHORT time, only over media.spans [[a, b]] (SOURCE s, default the whole take).\nfunction extractSynced(m, dir, tl, fill, w) {\n const sp = tl.speed || 1, spans = m.spans || [[0, 1e9]];\n for (const [a, b, o] of tl.keeps) for (const [sa, sb] of spans) {\n const a1 = Math.max(a, sa), b1 = Math.min(b, sb); if (b1 - a1 \u003c 1 / FPS) continue;\n const n0 = Math.round((o + (a1 - a) / sp) * FPS), n = Math.round((o + (b1 - a) / sp) * FPS) - n0; if (n \u003c 1) continue;\n ff(['-threads', '4', '-ss', String(a1), '-i', path.resolve(PROJ, expand(m.src)), '-vf', `setpts=(PTS-STARTPTS)/${sp},fps=${FPS},${fill}scale=${w}:-2:flags=lanczos`,\n '-frames:v', String(n), '-an', '-q:v', String(m.q ?? 2), '-start_number', String(n0), path.join(dir, '%05d.jpg')]);\n }\n}\n// spec.vanish.plate: the empty room (one frame of the take at its own size, the speaker removed) goes through the same\n// crop and grade as the camera, so it lines up with frames/cam pixel for pixel -> frames/plate.jpg\nfunction plateFrame(spec) {\n const src = spec.vanish?.plate; if (!src) return;\n const [cx, cy, cw, ch] = spec.cam?.crop || [400, 0, 1200, 1080], g = gradeOf(spec);\n const [sw, sh] = execFileSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=width,height', '-of', 'csv=p=0', P('assets/source.mp4')]).toString().trim().split(',');\n ff(['-i', P(src), '-frames:v', '1', '-vf', `scale=${sw}:${sh},crop=${cw}:${ch}:${cx}:${cy}${g ? ',' + gradeChain(g) : ''}`, '-q:v', '2', P('frames/plate.jpg')]);\n log('vanish: plate -> frames/plate.jpg');\n}\n// true when every use of media `id` in the spec is an `overlay` scene's media (the only draw that maps a cropped frame\n// back to its place); any other mention of the id (an actor, a card, a tweet avatar, a list) keeps the whole frame\nfunction overlayOnly(spec, id) {\n let ok = false;\n const walk = (o, k, parent) => {\n if (o === id) { if (k === 'media' && parent?.type === 'overlay' && !Array.isArray(parent)) ok ||= true; else throw 0; return; }\n if (o && typeof o === 'object') for (const [kk, v] of Object.entries(o)) walk(v, kk, o);\n };\n try { for (const [k, v] of Object.entries(spec)) if (k !== 'media') walk(v, k, spec); } catch { return false; }\n return ok;\n}\n// alpha media drawn only by overlay scenes: the box that is ever visible (alpha > 0 on any frame, + 2 px so edge\n// filtering stays the same), measured on the exact frames the extraction makes (same input, same -vf).\n// -> {box: [x, y, w, h], w, h (the whole frame)}, or null when the box would save under 10 % of the pixels\nfunction alphaBox(input, vf) {\n const r = spawnSync(FFMPEG, ['-hide_banner', ...input, '-vf', vf + ',alphaextract,bbox=min_val=1', '-an', '-f', 'null', '-'], { encoding: 'utf8', maxBuffer: 1 \u003c\u003c 28 });\n const size = r.stderr.match(/Output #0[\\s\\S]*?Stream #0:0[^\\n]*?, (\\d+)x(\\d+)/);\n if (r.status || !size) return null;\n const W0 = +size[1], H0 = +size[2];\n let x1 = 1e9, y1 = 1e9, x2 = -1, y2 = -1;\n for (const m of r.stderr.matchAll(/x1:(\\d+) x2:(\\d+) y1:(\\d+) y2:(\\d+)/g)) {\n const [a, b, c, d] = m.slice(1).map(Number); if (b \u003c a || d \u003c c) continue;\n x1 = Math.min(x1, a); x2 = Math.max(x2, b); y1 = Math.min(y1, c); y2 = Math.max(y2, d);\n }\n if (x2 \u003c 0) return null;\n x1 = Math.max(0, x1 - 2); y1 = Math.max(0, y1 - 2); x2 = Math.min(W0 - 1, x2 + 2); y2 = Math.min(H0 - 1, y2 + 2);\n const box = [x1, y1, x2 - x1 + 1, y2 - y1 + 1];\n return box[2] * box[3] > .9 * W0 * H0 ? null : { box, w: W0, h: H0 };\n}\n// frames identical to an earlier one (a UI holding still, a frozen B-roll) -> {frame: first identical frame}: the page\n// fetches and decodes each picture once (mediaURL in the runtimes)\nfunction repeats(dir) {\n const alias = {}, seen = new Map();\n for (const f of fs.readdirSync(dir).sort()) {\n const i = parseInt(f, 10); if (!(i >= 0)) continue;\n const h = md5(fs.readFileSync(path.join(dir, f))); if (seen.has(h)) alias[i] = seen.get(h); else seen.set(h, i);\n }\n return alias;\n}\nfunction extractMedia(spec, prev, tl) {\n const out = {};\n for (const [id, m] of Object.entries(spec.media || {})) {\n // the key holds the file's size and mtime too: a clip re-encoded under the same name (a component captured again) is re-extracted\n const fp = path.resolve(PROJ, expand(m.src)), fst = fs.existsSync(fp) ? fs.statSync(fp) : null, stamp = fst && [fst.size, Math.round(fst.mtimeMs)];\n const crops = !!m.alpha && !m.sync && overlayOnly(spec, id);\n const key = JSON.stringify(m.sync ? [m, tl.keeps, tl.speed, stamp] : [m, stamp, ...(crops ? ['crop1'] : [])]), dir = P('frames/media', id);\n if (prev?.[id]?.key === key && fs.existsSync(dir)) { out[id] = { ...prev[id], alias: prev[id].alias || repeats(dir) }; continue; }\n fs.rmSync(dir, { recursive: true, force: true }); fs.mkdirSync(dir, { recursive: true });\n // fill: true crops the clip to the frame's aspect first (full-screen B-roll, spec.broll), around focus [x, y] (0..1)\n const [FW, FH] = frameWH(), [fx, fy] = m.focus || [.5, .5], w = m.width || (m.fill ? FW : 960);\n const fill = m.fill ? `crop='min(iw,ih*${FW}/${FH})':'min(ih,iw*${FH}/${FW})':'(iw-ow)*${fx}':'(ih-oh)*${fy}',` : '';\n // alpha: true keeps the clip's transparency (a UI overlay rendered on a transparent background, e.g. a png-in-mov or a\n // ProRes 4444) as RGBA PNG frames, for the `overlay` scene; every other clip is JPG\n const ext = m.alpha ? 'png' : 'jpg', enc = m.alpha ? ['-pix_fmt', 'rgba'] : ['-q:v', '1', '-qmin', '1'];\n let crop = null;\n if (m.sync) extractSynced(m, dir, tl, fill, w);\n else {\n const input = ['-threads', '4', ...(m.from ? ['-ss', String(m.from)] : []), ...(m.dur ? ['-t', String(m.dur)] : []), '-i', path.resolve(PROJ, expand(m.src))];\n let vf = `fps=${FPS},${fill}scale=${w}:-2:flags=lanczos${m.alpha ? ',format=rgba' : ''}`;\n // an overlay is mostly transparent: only its visible box is stored (about half the pixels to decode and draw per frame)\n if (crops && (crop = alphaBox(input, vf))) vf += `,crop=${crop.box[2]}:${crop.box[3]}:${crop.box[0]}:${crop.box[1]}`;\n ff([...input, '-vf', vf, '-an', ...enc, '-start_number', '0', path.join(dir, `%05d.${ext}`)]);\n }\n const jpgs = fs.readdirSync(dir).filter(f => f.endsWith('.' + ext)).sort(), count = m.sync ? parseInt(jpgs.at(-1) || '-1', 10) + 1 : jpgs.length;\n const [iw, ih] = crop ? [crop.w, crop.h] : probe(path.join(dir, jpgs[0]), 'stream=width,height').split('\\n').map(Number);\n const alias = repeats(dir), reps = Object.keys(alias).length;\n out[id] = { count, w: iw, h: ih, key, ...(m.sync ? { sync: true } : {}), ...(m.alpha ? { ext } : {}), ...(crop ? { crop: crop.box } : {}), alias };\n log(`media ${id}: ${count} frames ${iw}x${ih}${crop ? `, visible box ${crop.box[2]}x${crop.box[3]}` : ''}${reps ? `, ${reps} repeats` : ''}`);\n }\n return out;\n}\n\n// Speaker matte (spec.cutout, matteSpans): person masks -> frames/matte/*.png (gray+alpha, the runtime composites them\n// over the cam JPGs). On a Mac, Apple Vision (matte.swift, prebuilt or compiled once, platform.mjs) as 3 parallel workers\n// (the Neural Engine saturates near 30 fps); elsewhere MediaPipe in headless Chrome (vision-web.mjs), one worker. Raw masks\n// are cached by cam JPG content in frames/.matte-cache, so after a `cut` change only never-seen frames hit the model;\n// the cheap EMA + blur pass reruns.\nasync function cutoutFrames(spec, tl, camKey, prevKey, camN) {\n const mm = mapSpec({ cutout: spec.cutout || [], matteSpans: spec.matteSpans || [], vanish: spec.vanish?.plate ? spec.vanish : null }, mapper(tl));\n const v = mm.vanish; // vanish.plate: only the person turns to dust, so the whole vanish needs his matte\n const cs = [].concat(mm.cutout, mm.matteSpans.map(([from, to]) => ({ from, to })), v ? [{ from: v.from, to: (v.backAt ?? v.to) + (v.back ?? .45) }] : []);\n if (!cs?.length) return null;\n const n0 = Math.max(0, Math.min(...cs.map(c => Math.floor(c.from * FPS) - 2)));\n const n1 = Math.min(camN - 1, Math.max(...cs.map(c => Math.ceil((c.to + (c.mode === 'cam' ? 1.3 : 1.1)) * FPS))));\n const tool = visionTool('matte'), binH = tool.id, q = process.env.MATTE_QUALITY || 'accurate';\n const key = JSON.stringify([camKey, n0, n1, binH, q]), dir = P('frames/matte');\n if (key === prevKey && fs.existsSync(dir)) return key;\n const t0 = Date.now(), cache = P('frames/.matte-cache'), jobs = P('frames/.matte-jobs');\n for (const d of [dir, jobs, P('frames/cutout')]) fs.rmSync(d, { recursive: true, force: true }); // frames/cutout: old RGBA format\n for (const d of [dir, jobs, cache]) fs.mkdirSync(d, { recursive: true });\n const m0 = Math.max(0, n0 - 8), raw = {}, cost = {};\n for (let i = m0; i \u003c= n1; i++) {\n raw[i] = path.join(cache, md5([tool.web ? 'w1' : 'r1', q, md5(fs.readFileSync(P('frames/cam', `${pad5(i)}.jpg`)))].join()) + '.png');\n cost[i] = fs.existsSync(raw[i]) ? 1 : 7; // ~5 ms cached vs ~35 ms through Vision\n }\n // W contiguous slices of equal cost; each primes its EMA on the 8 frames before it (cheap once cached)\n const W = Math.max(1, Math.min(+flag('matte-workers', tool.web ? 1 : 3), Math.ceil((n1 - n0 + 1) / 60))), total = Object.values(cost).reduce((x, y) => x + y, 0);\n const fresh = Object.values(cost).filter(c => c > 1).length, slices = [];\n for (let i = n0, acc = 0, a = n0; i \u003c= n1; i++) { acc += cost[i]; if (i === n1 || acc >= total * (slices.length + 1) / W) { slices.push([a, i]); a = i + 1; } }\n log(`matte: frames ${n0}-${n1}, ${fresh} through ${tool.web ? 'MediaPipe' : 'Vision'} (${n1 - m0 + 1 - fresh} cached), ${slices.length} workers`);\n const done = new Map(), N = n1 - n0 + 1; let shown = -1;\n const tick = () => { const d = [...done.values()].reduce((x, y) => x + y, 0), pc = Math.floor(100 * d / N);\n if (pc >= shown + 10) { shown = pc - pc % 10; const fps = d / ((Date.now() - t0) / 1e3); log(`matte ${pc}% (${d}/${N}) ${fps.toFixed(1)} fps, eta ${Math.round((N - d) / Math.max(fps, .1))}s`); } };\n await Promise.all(slices.map(([a, b], k) => new Promise((res, rej) => {\n const lines = []; for (let i = Math.max(m0, a - 8); i \u003c= b; i++) lines.push(`${i} ${raw[i]} ${i >= a ? 1 : 0}`);\n const jf = path.join(jobs, `${k}.txt`); fs.writeFileSync(jf, lines.join('\\n'));\n const p = spawn(tool.cmd, [...tool.args, P('frames/cam'), dir, jf], { stdio: ['ignore', 'pipe', 'inherit'] });\n p.stdout.on('data', d => { const m = [...String(d).matchAll(/matte (\\d+)/g)].at(-1); if (m) { done.set(k, +m[1]); tick(); } });\n p.on('close', c => c ? rej(new Error(`matte exited ${c}`)) : (done.set(k, b - a + 1), tick(), res()));\n })));\n fs.rmSync(jobs, { recursive: true, force: true });\n const used = new Set(Object.values(raw).map(f => path.basename(f)));\n for (const f of fs.readdirSync(cache)) if (!used.has(f)) fs.unlinkSync(path.join(cache, f)); // keep the cache to the current cut\n log(`matte: ${N} frames ready in ${((Date.now() - t0) / 1e3).toFixed(1)}s`);\n return key;\n}\n\nfunction wordsDoc(tl) {\n const lines = ['# Words', '', 'Source time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.', '',\n `Short duration: ${tl.duration.toFixed(2)}s (source ${tl.source}s, ${tl.keeps.length} keeps)`, '', '| src | short | word |', '|---|---|---|'];\n for (const w of tl.words) lines.push(`| ${w.s0 == null ? 'p+' + (w.start - tl.pause.t0).toFixed(2) : w.s0.toFixed(2)} | ${w.start.toFixed(2)} | ${w.text.replace(/\\|/g, '/')} |`);\n fs.writeFileSync(P('files/words.md'), lines.join('\\n') + '\\n');\n}\n\n// spec.silent: a paper-only motion piece with no Tella take (no voice, no camera, no captions).\n// spec.duration is the length; times in spec.js are plain seconds (SOURCE = SHORT).\nfunction prepareSilent(spec) {\n const d = snap(spec.duration);\n const tl = { fps: FPS, speed: 1, duration: d, source: d, keeps: [[0, d, 0]], words: [] };\n fs.writeFileSync(P('assets/timeline.json'), JSON.stringify(tl));\n const prevF = fs.existsSync(P('assets/frames.json')) ? readJSON(P('assets/frames.json')) : {};\n const media = extractMedia(spec, prevF.media, tl);\n fs.writeFileSync(P('assets/frames.json'), JSON.stringify({ cam: 0, media }));\n log(`silent timeline: ${d}s, no voice, no camera`);\n}\n\n// iPhone takes are HDR (HLG, BT.2020): read as SDR they come out grey, washed and purple. Tone-map them once to SDR\n// BT.709 with Apple's own mapping (avconvert H.264 preset = the iPhone's SDR export look); the HDR original stays as\n// assets/source-hlg.mp4. Same frames and duration, the rotation is applied (ffmpeg already showed it rotated).\nfunction sdrSource() {\n const s = P('assets/source.mp4');\n if (!fs.existsSync(s)) return;\n const trc = execFileSync(FFPROBE, ['-v', 'error', '-select_streams', 'v:0', '-show_entries', 'stream=color_transfer', '-of', 'default=nw=1:nk=1', s]).toString().trim();\n if (trc !== 'arib-std-b67' && trc !== 'smpte2084') return;\n if (process.platform !== 'darwin') { log(`WARNING: HDR take (${trc}), colors will look washed: convert it to SDR BT.709 first`); return; }\n const hdr = P('assets/source-hlg.mp4'), tmp = P('assets/source-sdr.mov');\n if (!fs.existsSync(hdr)) fs.copyFileSync(s, hdr, fs.constants.COPYFILE_FICLONE);\n execFileSync('avconvert', ['-s', hdr, '-o', tmp, '-p', 'Preset1920x1080', '--replace'], { stdio: ['ignore', 'ignore', 'inherit'] });\n ff(['-i', tmp, '-map', '0:v:0', '-map', '0:a:0?', '-c', 'copy', '-movflags', '+faststart', s]);\n fs.rmSync(tmp);\n log(`source: HDR (${trc}) tone-mapped to SDR BT.709, original kept as assets/source-hlg.mp4`);\n}\n\nasync function prepare() {\n const spec = loadSpec();\n fs.mkdirSync(P('files'), { recursive: true }); fs.mkdirSync(P('assets'), { recursive: true });\n if (spec.silent) return prepareSilent(spec);\n sdrSource();\n sourceWav();\n const tl = tighten(spec);\n fs.writeFileSync(P('assets/timeline.json'), JSON.stringify(tl));\n log(`timeline: ${tl.source}s -> ${tl.duration.toFixed(2)}s, ${tl.keeps.length} keeps${tl.pause ? `, pause ${tl.pause.dur}s at ${tl.pause.t0}s` : ''}${tl.inserts ? `, ${tl.inserts.length} inserts` : ''}`);\n spliceVoice(tl); log('voice.wav spliced');\n wordsDoc(tl);\n const prevF = fs.existsSync(P('assets/frames.json')) ? readJSON(P('assets/frames.json')) : {};\n const srcStat = fs.statSync(P('assets/source.mp4'));\n const camKey = JSON.stringify([srcStat.size, Math.round(srcStat.mtimeMs), tl.keeps, tl.speed, spec.cam?.crop, tl.pause, tl.inserts, ...(gradeOf(spec) ? [gradeOf(spec)] : []), ...(spec.cam?.q != null ? [spec.cam.q] : [])]);\n let cam = prevF.cam;\n if (!(prevF.camKey === camKey && fs.existsSync(P('frames/cam')))) {\n cam = extractCam(spec, tl); log(`cam: ${cam} frames (expected ${Math.round(tl.duration * FPS)})`);\n if (gradeOf(spec)) gradeSheet(spec, gradeOf(spec));\n }\n plateFrame(spec);\n const media = extractMedia(spec, prevF.media, tl);\n const cutKey = await cutoutFrames(spec, tl, camKey, prevF.cutKey, cam);\n fs.writeFileSync(P('assets/frames.json'), JSON.stringify({ cam, camKey, media, cutKey }));\n log('prepare done');\n}\n\n// The review studio's first pass: the take with every micro silence cut (and spec.cut), full frame, the spliced\n// voice, no motion design. Same timeline as prepare, so the studio's SHORT times map back to spec.js exactly.\nfunction rough() {\n const spec = loadSpec();\n if (spec.silent) throw new Error('rough needs a take (this project is paper only)');\n fs.mkdirSync(P('files'), { recursive: true }); fs.mkdirSync(P('output'), { recursive: true });\n sourceWav();\n const tl = tighten(spec);\n fs.writeFileSync(P('assets/timeline.json'), JSON.stringify(tl));\n spliceVoice(tl); wordsDoc(tl);\n if (tl.pause || tl.inserts) log('rough: spec.pause / spec.inserts are not frozen in the rough cut, the voice runs ahead after them');\n const out = P('output', `${path.basename(PROJ)}-rough.mp4`);\n ff(['-threads', '0', '-i', P('assets/source.mp4'), '-i', P('assets/voice.wav'), '-map', '0:v:0', '-map', '1:a',\n '-vf', `fps=${FPS},select='${keepSelect(tl)}',setpts=N/${FPS * (tl.speed || 1)}/TB,scale=-2:'min(1080,ih)'`, '-r', String(FPS),\n ...(hasEncoder('h264_videotoolbox') ? ['-c:v', 'h264_videotoolbox', '-b:v', '8M'] : ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '22']), '-pix_fmt', 'yuv420p',\n ...AAC(), '-t', tl.duration.toFixed(3), '-movflags', '+faststart', out]);\n log(`rough: ${tl.source}s -> ${tl.duration.toFixed(2)}s, ${tl.keeps.length} keeps -> ${out}`);\n}\n\n// ---------------------------------------------------------------- browser\nconst MIME = { '.wav': 'audio/wav', '.otf': 'font/otf', '.woff2': 'font/woff2', '.js': 'text/javascript', '.mjs': 'text/javascript', '.html': 'text/html', '.json': 'application/json', '.jpg': 'image/jpeg', '.png': 'image/png', '.ttf': 'font/ttf', '.css': 'text/css' };\nfunction serve(sinks = {}, port = 0, home = null) {\n const srv = http.createServer((req, res) => {\n const u = decodeURIComponent(req.url.split('?')[0]);\n if (home && u === '/') { res.writeHead(302, { location: home }); return res.end(); }\n // POST /sink/\u003ck>?n=\u003cframes done>[&end=1]: encoded video bytes of one work unit from a render worker, in order\n if (req.method === 'POST' && u.startsWith('/sink/')) {\n const k = u.slice(6), sk = sinks[k];\n if (!sk) { res.writeHead(404); return res.end(); }\n req.on('data', b => sk.data(b));\n req.on('end', () => { const q = new URL(req.url, 'http://x').searchParams; sk.done = +q.get('n'); sk.tick?.(); if (q.has('end')) sk.end?.(); res.end(); });\n return;\n }\n // /style.json: the project's style snapshot ({} when none), /style-font: its font file (style.font.src)\n if (u === '/style.json') { res.writeHead(200, { 'content-type': MIME['.json'], 'cache-control': 'no-store' }); return res.end(JSON.stringify(loadStyle() || {})); }\n const fsrc = u === '/style-font' && loadStyle()?.font?.src;\n const f = fsrc ? expand(fsrc) : u.startsWith('/engine/') ? path.join(ENGINE, u.slice(8)) : u.startsWith('/p/') ? projFile(u.slice(3)) : null;\n if (!f || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { console.log(' page 404:', u); res.writeHead(404); return res.end(); }\n res.writeHead(200, { 'content-type': MIME[path.extname(f)] || 'application/octet-stream', 'cache-control': 'no-store' });\n fs.createReadStream(f).pipe(res);\n });\n return new Promise(r => srv.listen(port, '127.0.0.1', () => r(srv)));\n}\n\n// Live preview: the same runtime as the render, played in a normal Chrome tab (no encode, no mp4). render(t) runs on\n// each animation frame at the time of the audio clock (assets/mix.wav, else the spliced voice). spec.js is re-read on\n// every reload, so edit it and press R. Long-lived: run it in the background, --port fixed (default 41190).\nasync function preview() {\n const [SW, SH] = stageWH(), [WW, HH] = frameWH(), port = +flag('port', 41190), scale = +flag('scale', .5);\n const home = `/engine/short.html?scale=${scale}&w=${SW}&h=${SH}&ow=${WW}&oh=${HH}&play=1`;\n await serve({}, port, home);\n log(`preview: http://127.0.0.1:${port}/ (${path.basename(PROJ)}, scale ${scale})`);\n await new Promise(() => {});\n}\n\n// No GPU switches: headless Chrome on Apple silicon already draws the canvas, rasterizes and encodes on the GPU (ANGLE\n// Metal). Benchmarked 2026-10-21 with --use-angle=metal --enable-gpu-rasterization --ignore-gpu-blocklist: no gain.\n// One headless Chrome with the runtime booted. Each worker gets its own browser, so its own renderer process.\n// projects prepared before the mask-only matte still have RGBA frames/cutout: rematte instead of rendering him away\nasync function ensureMatte() {\n const f = fs.existsSync(P('assets/frames.json')) && readJSON(P('assets/frames.json'));\n if (f?.cutKey && !fs.existsSync(P('frames/matte'))) { log('matte missing (old format), re-running prepare'); await prepare(); }\n}\n\nconst bootLog = b => b && log(`boot: fonts ${b.fonts?.toFixed(0)} ms, paper background ${b.bg?.toFixed(0)} ms, ready at ${b.ready?.toFixed(0)} ms`);\n\n// One headless Chrome on the runtime page, driven over its DevTools pipe (chrome.mjs): the page boots ~0.3 s sooner than\n// through Playwright (benchmark 2026-10-21, M4 Max: 0.9 -> 0.6 s for 2 render workers). Playwright stays as the fallback\n// when that Chrome does not start; an error of the page itself (window.READY_ERROR) is thrown as is.\nasync function openPage(srv, scale) {\n const [WW, HH] = frameWH(), [SW, SH] = stageWH();\n const url = `http://127.0.0.1:${srv.address().port}/engine/short.html?scale=${scale}&w=${SW}&h=${SH}&ow=${WW}&oh=${HH}${flag('ahead') ? '&ahead=' + flag('ahead') : ''}`;\n try {\n const pg = await openChrome(CHROME, url, { width: WW / 2, height: HH / 2, args: ['--renderer-process-limit=1'] });\n if (flag('bench')) bootLog(await pg.evaluate(() => window.PERF_BOOT));\n return { page: { evaluate: pg.evaluate }, close: pg.close };\n } catch (e) { if (e.page) throw e; log(`chrome pipe failed (${e.message.split('\\n')[0]}), Playwright instead`); }\n const { chromium } = await import('playwright-core');\n // launchServer + kill: a graceful browser.close() of headless Chrome takes ~2.4 s on macOS, kill() ~30 ms. Nothing is\n // lost: every caller awaits its last result (encodeEnd awaits its final POST) before closing.\n const bs = await chromium.launchServer({ executablePath: CHROME, headless: true, args: ['--renderer-process-limit=1', '--disable-extensions', '--mute-audio'] });\n const browser = await chromium.connect(bs.wsEndpoint()), kill = () => bs.kill();\n const page = await browser.newPage({ viewport: { width: WW / 2, height: HH / 2 } });\n page.on('console', m => { if (m.type() === 'error' || m.text().startsWith('[rt]')) console.log(' page:', m.text()); });\n page.on('pageerror', e => console.log(' pageerror:', e.message));\n await page.goto(url);\n await page.waitForFunction(() => window.READY === true || window.READY_ERROR, null, { timeout: 60000 });\n const err = await page.evaluate(() => window.READY_ERROR);\n if (err) { await kill(); throw new Error(err); }\n if (flag('bench')) bootLog(await page.evaluate(() => window.PERF_BOOT));\n return { page, close: kill };\n}\n\nasync function withPage(scale, fn) {\n const srv = await serve(), pg = await openPage(srv, scale).catch(e => { srv.close(); throw e; });\n try { return await fn(pg.page); } finally { await pg.close(); srv.close(); }\n}\n\nasync function stills() {\n await ensureMatte();\n const tl = readJSON(P('assets/timeline.json')), m = mapper(tl);\n let ts = pos.length ? pos.map(x => x.endsWith('s') ? m(parseFloat(x)) : parseFloat(x)) : Array.from({ length: 12 }, (_, i) => (i + .5) * tl.duration / 12);\n const dir = P('stills'); fs.mkdirSync(dir, { recursive: true });\n for (const f of fs.readdirSync(dir)) if (/^t\\d/.test(f)) fs.unlinkSync(path.join(dir, f));\n const scale = +flag('scale', 1), png = !!flag('png', false); // --png: lossless stills (frame-to-frame motion QC)\n await withPage(scale, async page => {\n for (const t of ts) {\n const b64 = await page.evaluate(([t, png]) => png ? window.framePNG(t) : window.frameJPEG(t, .9), [t, png]);\n fs.writeFileSync(path.join(dir, `t${t.toFixed(2).padStart(5, '0')}.${png ? 'png' : 'jpg'}`), Buffer.from(b64, 'base64'));\n }\n });\n const files = fs.readdirSync(dir).filter(f => /^t\\d.*\\.(jpg|png)$/.test(f)).sort();\n const cols = Math.min(6, files.length), rows = Math.ceil(files.length / cols), CW = frameWH()[0] / 4, CH = frameH() / 4;\n ff([...files.flatMap(f => ['-i', path.join(dir, f)]), '-filter_complex',\n files.map((f, i) => `[${i}]scale=${CW}:${CH}[v${i}]`).join(';') + ';' +\n (files.length === 1 ? '[v0]null' : files.map((f, i) => `[v${i}]`).join('') + `xstack=inputs=${files.length}:layout=${files.map((f, i) => `${(i % cols) * CW}_${Math.floor(i / cols) * CH}`).join('|')}:fill=black`),\n '-frames:v', '1', '-q:v', '3', path.join(dir, 'contact.jpg')]);\n log(`${files.length} stills + contact.jpg in ${dir} (row-major: ${files.map(f => f.slice(1, -4)).join(' ')})`);\n}\n\n// ---------------------------------------------------------------- capture\n// One motion component (an app UI played, an illustration that acts) written as an HTML page -> a clip the spec plays as\n// `media`. The page draws its whole state at t seconds in window.setT(t) (no CSS transitions, no timers, no rAF loops),\n// so every frame is exact: one screenshot per 1/fps. --alpha keeps the transparency (png in mov, for an `overlay` scene\n// over the camera), else near-lossless H.264 (an actor card). The engine fonts are loaded into the page by family name\n// (Bricolage Grotesque, DM Sans, Inter, JetBrains Mono, Archivo, Instrument Serif). Writes the clip and\n// stills/\u003cname>-sheet.jpg (the frames at --at, else 8 even ones, over gray) to check it without a render.\nasync function capture() {\n if (!pos[0] || !(+flag('dur', 0) > 0)) throw new Error('usage: capture \u003cproject> \u003cpage.html> --dur \u003cs> [--size 1080x1920] [--alpha] [--fps 30] [--out assets/\u003cname>.mov|mp4] [--at t1,t2,...]');\n const html = path.resolve(PROJ, expand(pos[0])), name = path.basename(html, '.html'), dur = +flag('dur');\n const [w, h] = String(flag('size', '1080x1920')).split('x').map(Number), alpha = !!flag('alpha', false), fps = +flag('fps', FPS);\n const o = flag('out', true), out = path.resolve(PROJ, expand(o === true ? `assets/${name}.${alpha ? 'mov' : 'mp4'}` : o));\n const n = Math.max(1, Math.round(dur * fps)), tmp = fs.mkdtempSync(path.join(os.tmpdir(), `capture-${name}-`));\n const faces = fs.readFileSync(path.join(ENGINE, 'short.html'), 'utf8').match(/@font-face[^}]*\\}/g).join('\\n')\n .replace(/url\\('fonts\\//g, `url('${pathToFileURL(path.join(ENGINE, 'fonts')).href}/`);\n const { chromium } = await import('playwright-core');\n const bs = await chromium.launchServer({ executablePath: CHROME, headless: true, args: ['--disable-extensions', '--mute-audio', '--allow-file-access-from-files'] });\n const browser = await chromium.connect(bs.wsEndpoint()); // killed at the end, not closed (see openPage)\n try {\n const page = await browser.newPage({ viewport: { width: w, height: h }, deviceScaleFactor: 1 });\n page.on('pageerror', e => console.log(' pageerror:', e.message));\n page.on('console', m => { if (m.type() === 'error') console.log(' page:', m.text()); });\n page.on('requestfailed', r => console.log(' missing:', r.url()));\n await page.goto(pathToFileURL(html).href);\n await page.addStyleTag({ content: faces });\n await page.evaluate(() => Promise.all([...document.fonts].map(f => f.load().catch(() => {})))\n .then(() => Promise.all([...document.images].map(i => i.decode().catch(() => {})))));\n if (!(await page.evaluate(() => typeof window.setT === 'function'))) throw new Error(`${html}: no window.setT(t)`);\n for (let i = 0; i \u003c n; i++) {\n await page.evaluate(t => window.setT(t), i / fps);\n fs.writeFileSync(path.join(tmp, `${pad5(i)}.png`), await page.screenshot({ omitBackground: alpha, type: 'png' }));\n }\n } finally { await bs.kill(); }\n fs.mkdirSync(path.dirname(out), { recursive: true });\n ff(['-framerate', String(fps), '-i', path.join(tmp, '%05d.png'), ...(alpha ? ['-c:v', 'png', '-pix_fmt', 'rgba']\n : ['-c:v', 'libx264', '-crf', '10', '-preset', 'medium', '-pix_fmt', 'yuv420p', ...BT709]), out]);\n const at = flag('at', null), ts = at && at !== true ? String(at).split(',').map(Number) : Array.from({ length: 8 }, (_, i) => i * dur / 7);\n const ids = ts.map(t => Math.min(n - 1, Math.max(0, Math.round(t * fps)))), cols = Math.min(4, ids.length), CW = Math.round(w / 4), CH = Math.round(h / 4);\n fs.mkdirSync(P('stills'), { recursive: true });\n ff([...ids.flatMap(i => ['-i', path.join(tmp, `${pad5(i)}.png`)]), '-filter_complex',\n ids.map((x, i) => `color=c=0x808080:s=${CW}x${CH}[g${i}];[${i}]scale=${CW}:${CH}[s${i}];[g${i}][s${i}]overlay=shortest=1[v${i}]`).join(';') + ';' +\n (ids.length === 1 ? '[v0]null' : ids.map((x, i) => `[v${i}]`).join('') + `xstack=inputs=${ids.length}:layout=${ids.map((x, i) => `${(i % cols) * CW}_${Math.floor(i / cols) * CH}`).join('|')}:fill=gray`),\n '-frames:v', '1', '-q:v', '3', P('stills', `${name}-sheet.jpg`)]);\n for (const f of fs.readdirSync(tmp)) fs.unlinkSync(path.join(tmp, f)); fs.rmdirSync(tmp);\n log(`capture ${name}: ${n} frames ${w}x${h}${alpha ? ' alpha' : ''} -> ${out.startsWith(PROJ) ? path.relative(PROJ, out) : out} (${(n / fps).toFixed(2)} s) + stills/${name}-sheet.jpg (t ${ts.map(t => t.toFixed(2)).join(' ')})`);\n}\n\n// decode check of an Annex B piece (software decode, about 1 s per 30 s of 1080x1920): resolves, or rejects with the error\nconst decodes = (buf, a, b) => new Promise((res, rej) => {\n const p = spawn(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-f', 'h264', '-i', '-', '-f', 'null', '-'], { stdio: ['pipe', 'ignore', 'pipe'] }); let e = '';\n p.stderr.on('data', d => { e += d; }); p.stdin.on('error', () => {}); p.once('error', rej);\n p.once('close', c => c ? rej(new Error(`frames ${a}-${b} do not decode: ${e.slice(-300)}`)) : res());\n p.stdin.end(buf);\n});\n\n// Render cache keys. global: the engine code, style, timeline, prepared frames, the output settings and every spec field\n// that is not windowed. Per chunk: the windowed timed items that can draw in it (scenes, actors, broll: from - pre - 2\n// to to + 2) and the layout/zoom keys that move it (the last key 6 s before the chunk onward: the spring has settled by then),\n// plus the size and mtime of every image or video file those items name.\nconst CH = 300;\nfunction renderChunks(tl, n0, n1, fps, W, H, rate, scale) {\n const raw = loadSpec(), spec = mapSpec(raw, mapper(tl)), fstat = f => { try { const s = fs.statSync(f); return `${s.size}:${s.mtimeMs}`; } catch { return '-'; } };\n const eng = ['short.html', ...fs.readdirSync(path.join(ENGINE, 'lib')).filter(f => f.endsWith('.js')).map(f => 'lib/' + f)].map(f => fs.readFileSync(path.join(ENGINE, f)));\n const items = ['scenes', 'actors', 'broll'].flatMap(k => (spec[k] || []).map(o => ({ k, o, a: typeof o.from === 'number' ? o.from - (o.pre || 0) - 2 : -1e9, b: typeof o.from === 'number' ? (o.to ?? 1e9) + 2 : 1e9 })));\n const keyed = [['layout', spec.layout?.keys], ['zoom', spec.zoom?.keys]].filter(([, k]) => k?.length);\n const rest = { ...spec, scenes: 0, actors: 0, broll: 0, layout: { ...spec.layout, keys: 0 }, zoom: { ...spec.zoom, keys: 0 } };\n const files = o => { const out = []; JSON.stringify(o, (k, v) => (typeof v === 'string' && /\\.(png|jpe?g|webp|gif|svg|mp4|mov)$/i.test(v) && out.push(v + '=' + fstat(projFile(expand(v)))), v)); return out; };\n // assets/source.mp4 too: a recomposed source (new crops, same frame count) rewrites the camera frames, not frames.json\n const st = loadStyle(), glob = md5(Buffer.concat([...eng, Buffer.from(JSON.stringify([st, st?.font?.src && fstat(expand(st.font.src)), rest, files(rest), W, H, fps, rate, scale, CH, fstat(P('assets/source.mp4'))]))]\n .concat(['assets/timeline.json', 'assets/frames.json'].filter(f => fs.existsSync(P(f))).map(f => fs.readFileSync(P(f))))));\n const dir = P('.cache/render', `${W}x${H}@${fps}`); fs.mkdirSync(dir, { recursive: true });\n const out = [];\n for (let c = Math.floor(n0 / CH); c * CH \u003c n1; c++) {\n const a = Math.max(n0, c * CH), b = Math.min(n1, (c + 1) * CH), t0 = a / fps, t1 = b / fps;\n const its = items.filter(i => i.b >= t0 && i.a \u003c= t1).map(i => [i.k, i.o, files(i.o)]);\n const ks = keyed.map(([n, k]) => { const j = Math.max(0, k.findLastIndex(e => e[0] \u003c= t0 - 6)); return [n, k.slice(j).filter(e => e[0] \u003c t1 + 1 / fps)]; });\n out.push({ id: c, a, b, f: path.join(dir, `${md5(glob + JSON.stringify([a, b, its, ks]))}.h264`) });\n }\n return out;\n}\n\n// The mp4 mux, started before the encode: chunks go into ffmpeg's stdin in order as soon as each one is there (cached,\n// or just encoded), so most of the parsing overlaps the encode and no joined .h264 is written. Every chunk starts on an\n// IDR with its own SPS/PPS, so the Annex B streams concatenate as is. Chrome's SPS timing confuses the raw parser:\n// rebuild timestamps from the frame index (setts), tag bt709 limited range, in the SPS and in the MP4 colr box (left\n// alone, the box keeps WebCodecs' sRGB transfer and players trust the box). Written to \u003cout>.part, renamed when done,\n// so a failed render leaves the previous video in place.\nfunction muxer(chunks, out, fps) {\n const part = out + '.part', p = spawn(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'h264', '-i', '-', '-c:v', 'copy', '-bsf:v',\n `h264_metadata=video_full_range_flag=0:colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1,setts=ts=N/(${fps}*TB):duration=1/(${fps}*TB)`,\n ...BT709, '-video_track_timescale', String(fps * 1000), '-movflags', '+faststart', '-f', 'mp4', part], { stdio: ['pipe', 'inherit', 'inherit'] });\n const done = new Promise((res, rej) => p.on('close', c => c ? rej(new Error('ffmpeg mux exit ' + c)) : res())); done.catch(() => {});\n p.stdin.on('error', () => {}); // a dead ffmpeg surfaces through `done`\n const write = b => new Promise(r => p.stdin.write(b, () => r()));\n let i = 0, chain = Promise.resolve();\n const feed = () => (chain = chain.then(async () => { for (; i \u003c chunks.length && chunks[i].ready; i++) await write(chunks[i].buf || fs.readFileSync(chunks[i].f)); }));\n return {\n feed,\n async end() { await feed(); if (i \u003c chunks.length) throw new Error('mux: chunk missing'); p.stdin.end(); await done; fs.renameSync(part, out); },\n abort() { p.kill(); try { fs.unlinkSync(part); } catch {} },\n };\n}\n\nasync function render() {\n await ensureMatte();\n const tl = readJSON(P('assets/timeline.json'));\n const scale = +flag('scale', 1), fps = +flag('fps', FPS), draft = scale \u003c 1;\n const t0 = +flag('from', 0), t1 = Math.min(tl.duration, +flag('to', tl.duration));\n const n0 = Math.round(t0 * fps), n1 = Math.round(t1 * fps), N = n1 - n0;\n fs.mkdirSync(P('output'), { recursive: true });\n const out = flag('out') ? path.resolve(PROJ, flag('out')) : P('output', draft ? 'draft-video.mp4' : 'video.mp4'); // --out \u003cpath>: e.g. a test clip\n const W = Math.round(frameWH()[0] * scale / 2) * 2, H = Math.round(frameH() * scale / 2) * 2;\n const rate = draft ? 4e6 : W * H > 1.5e6 ? 32e6 : 18e6;\n // workers: parallel Chrome renderers sharing 2 s work units (default 2; the hardware encoder is the limit: 3 workers or a\n // deeper encode queue are 5-10 % slower, benchmark 2026-10-21 on an M4 Max; --workers n)\n const K = Math.max(1, Math.min(+flag('workers', 2), Math.floor(N / 30) || 1));\n const start = Date.now(), jpeg = () => renderJPEG({ scale, fps, n0, N, out, W, H, draft, start });\n if (flag('jpeg')) return jpeg();\n // chunk cache: 10 s chunks on absolute frame numbers, each keyed by what can change its pixels. A spec fix\n // re-encodes only the chunks it touches; the rest are reused byte for byte (each chunk opens on an IDR). --fresh skips it.\n const chunks = renderChunks(tl, n0, n1, fps, W, H, rate, scale), fresh = !!flag('fresh');\n for (const c of chunks) c.hit = c.ready = !fresh && fs.existsSync(c.f);\n let mux = null;\n const todo = chunks.filter(c => !c.hit), N2 = todo.reduce((x, c) => x + c.b - c.a, 0);\n log(`render ${N} frames ${W}x${H}@${fps}: ${chunks.length - todo.length}/${chunks.length} chunks cached, ${N2} frames to encode, ${Math.min(K, todo.reduce((x, c) => x + Math.ceil((c.b - c.a) / (2 * fps)), 0))} workers -> ${out}`);\n let fallback = null;\n if (todo.length) {\n // work units: 2 s pieces of the chunks to encode, each opening on a keyframe (the same keyframes as one encoder per\n // chunk). Each worker starts on its own contiguous run, an equal share of the frames (its image cache stays warm); a\n // worker that runs out takes the back half of the longest run left, so the workers finish together instead of one\n // waiting on the other's last 10 s chunk. A chunk lands in the cache (.part, then rename) once all its units are in.\n const G = 2 * fps, units = [];\n for (const c of todo) { c.parts = []; for (let a = c.a; a \u003c c.b; a += G) { const u = { c, a, b: Math.min(c.b, a + G), bufs: [] }; c.parts.push(u); units.push(u); } c.left = c.parts.length; }\n const nw = Math.min(K, units.length), runs = Array.from({ length: nw }, () => []);\n { let acc = 0; for (const u of units) { runs[Math.min(nw - 1, Math.floor((acc + (u.b - u.a) / 2) * nw / N2))].push(u); acc += u.b - u.a; } }\n let steals = 0;\n const next = run => {\n if (!run.length) { const v = runs.reduce((m, r) => r.length > m.length ? r : m); if (v.length) { steals++; run.push(...v.splice(v.length >> 1)); } }\n return run.shift();\n };\n const sinks = {}, perf = {}, writes = []; let lastPct = -1, base = 0, uid = 0, bad = null;\n const tick = () => {\n const d = base + Object.values(sinks).reduce((x, s) => x + s.done, 0), pct = Math.floor(d / N2 * 20) * 5;\n if (pct !== lastPct) { lastPct = pct; const el = (Date.now() - start) / 1000; log(`${pct}% (${d}/${N2}) ${(d / el).toFixed(1)} fps, eta ${Math.round((N2 - d) / (d / el || 1))}s`); }\n };\n const finish = u => { // every frame of unit u is in\n const done = sinks[u.id].done; delete sinks[u.id]; base += u.b - u.a;\n if (done !== u.b - u.a) bad ||= new Error(`frames ${u.a}-${u.b}: the encoder returned ${done} of ${u.b - u.a} frames`);\n // decode check: the unit opens on an IDR with its SPS/PPS, so it decodes on its own, while the hardware encoder\n // works on the next units (the CPU is mostly idle then). `final` no longer decodes the whole video after the render.\n u.ok = decodes(Buffer.concat(u.bufs), u.a, u.b); u.ok.catch(() => {});\n const c = u.c; if (--c.left) return;\n c.buf = Buffer.concat(c.parts.flatMap(p => p.bufs)); c.ready = true;\n // into the cache once every unit of it decoded\n const w = Promise.all(c.parts.map(p => p.ok)).then(() => fs.promises.writeFile(c.f + '.part', c.buf)).then(() => fs.promises.rename(c.f + '.part', c.f)).then(() => { c.buf = null; });\n w.catch(() => {}); writes.push(w); c.parts = null;\n mux?.feed();\n };\n const s2 = await serve(sinks);\n try {\n const b0 = Date.now(), pages = await Promise.all(Array.from({ length: nw }, () => openPage(s2, scale)));\n perf.boot = Date.now() - b0;\n try {\n fallback = !(await pages[0].page.evaluate(async ([width, height, bitrate]) => window.HAS_WEBCODECS && (await VideoEncoder.isConfigSupported({ codec: 'avc1.640034', width, height, bitrate, framerate: 30, avc: { format: 'annexb' } }).catch(() => ({}))).supported, [W, H, rate]));\n if (!fallback) {\n mux = muxer(chunks, out, fps);\n await Promise.all(pages.map(async ({ page }, w) => {\n await page.evaluate(([fps, rate]) => window.encodeStart(fps, rate), [fps, rate]);\n for (let u; (u = next(runs[w]));) {\n u.id = String(++uid); sinks[u.id] = { data: b => u.bufs.push(b), end: () => finish(u), done: 0, tick };\n await page.evaluate(([a, b, sink]) => window.encodeUnit(a, b, sink), [u.a, u.b, `/sink/${u.id}`]);\n }\n const r = await page.evaluate(() => window.encodeEnd());\n for (const [k, v] of Object.entries(r || {})) perf[k] = (perf[k] || 0) + v;\n }));\n if (bad) throw bad;\n await Promise.all(units.map(u => u.ok)); // every unit decoded\n await Promise.all(writes);\n }\n } finally { await Promise.all(pages.map(p => p.close().catch(() => {}))); }\n } catch (e) { mux?.abort(); throw e; } finally { s2.close(); }\n if (fallback) { log('no H.264 WebCodecs in this browser: JPEG fallback'); return jpeg(); }\n // benchmark: where each frame's time goes, summed over the workers (ms per frame), and the wall clock of the encode\n if (perf.frames) { const per = k => (perf[k] / perf.frames).toFixed(1), wall = (Date.now() - start) / 1000;\n log(`bench: boot ${(perf.boot / 1000).toFixed(1)}s, ${N2} frames in ${wall.toFixed(1)}s = ${(N2 / wall).toFixed(0)} fps (${(N2 / fps / wall).toFixed(1)}x realtime), per frame: images ${per('load')} ms, draw ${(+per('render') - +per('load')).toFixed(1)} ms, encode ${(+per('submit') + +per('encWait')).toFixed(1)} ms, ${steals} steals`);\n if (flag('bench')) fs.writeFileSync(flag('bench'), JSON.stringify({ frames: N2, wall, workers: nw, steals, ...perf }, null, 2)); }\n }\n mux ||= muxer(chunks, out, fps);\n const now = new Date();\n for (const c of chunks) if (c.hit) fs.utimesSync(c.f, now, now); // cache recency, for the prune below\n await mux.end();\n // every frame of this file decoded (now, or when its cached chunk was made): `final` skips its own decode of it\n { const f = P('.cache/render/qc.json'), qc = fs.existsSync(f) ? readJSON(f) : {}, st = fs.statSync(out); qc[out] = `${st.size}:${st.mtimeMs}`; fs.writeFileSync(f, JSON.stringify(qc, null, 2)); }\n // a full-length render prunes the chunks it did not use (same size and fps), so the cache stays about one video big\n if (n0 === 0 && n1 === Math.round(tl.duration * fps)) {\n const keep = new Set(chunks.map(c => path.basename(c.f))), dir = path.dirname(chunks[0].f);\n for (const f of fs.readdirSync(dir)) if (!keep.has(f)) fs.unlinkSync(path.join(dir, f));\n }\n log(`video done in ${((Date.now() - start) / 1000).toFixed(1)}s`);\n}\n\n// Fallback (--jpeg, or no H.264 WebCodecs): one page, canvas JPEG over CDP, piped to ffmpeg (Apple hardware encoder, else x264).\nasync function renderJPEG({ scale, fps, n0, N, out, W, H, draft, start }) {\n const enc = spawn(FFMPEG, ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'mjpeg', '-i', '-',\n '-vf', `scale=${W}:${H}:in_range=full:out_range=tv,format=yuv420p`, ...h264(draft ? '4M' : W * H > 1.5e6 ? '32M' : '18M', draft ? '6M' : W * H > 1.5e6 ? '44M' : '26M'),\n '-profile:v', 'high', '-color_range', 'tv', '-colorspace', 'bt709', '-color_primaries', 'bt709', '-color_trc', 'bt709', '-r', String(fps), '-movflags', '+faststart', out],\n { stdio: ['pipe', 'inherit', 'inherit'] });\n const done = new Promise((res, rej) => enc.on('close', c => c ? rej(new Error('ffmpeg exit ' + c)) : res()));\n let lastPct = -1;\n log(`render ${N} frames ${W}x${H}@${fps} (JPEG path) -> ${out}`);\n await withPage(scale, async page => {\n for (let i = 0; i \u003c N; i++) {\n const b64 = await page.evaluate(t => window.frameJPEG(t, .97), (n0 + i) / fps);\n if (!enc.stdin.write(Buffer.from(b64, 'base64'))) await new Promise(r => enc.stdin.once('drain', r));\n const pct = Math.floor((i + 1) / N * 20) * 5;\n if (pct !== lastPct) { lastPct = pct; const el = (Date.now() - start) / 1000, f = (i + 1) / el; log(`${pct}% (${i + 1}/${N}) ${f.toFixed(1)} fps, eta ${Math.round((N - i - 1) / f)}s`); }\n }\n });\n enc.stdin.end(); await done;\n log(`video done in ${((Date.now() - start) / 1000).toFixed(0)}s`);\n}\n\n// ---- init / words: project setup without any personal path. The creator profile is LOCAL, never global: $SHORT_EDITING_HOME,\n// else the nearest .short-editing/ folder in the short's folder or a parent (the home folder excluded: ~/.short-editing is\n// never a style), else none (engine default). It holds .env (ELEVEN_KEY) and the styles (from the studio-setup skill),\n// each in its own folder \u003cprofile>/styles/\u003cname>/, the default one included: \u003cprofile>/style.json then only names it,\n// {\"default\": \"\u003cname>\"} (a plain file, no link). An older profile keeps its default style right in \u003cprofile>/style.json.\nconst HOME = (start = PROJ || process.cwd()) => expand(process.env.SHORT_EDITING_HOME || (s => { for (let d = s; d !== path.dirname(d); d = path.dirname(d)) if (d !== os.homedir() && (fs.existsSync(path.join(d, '.short-editing')) || fs.existsSync(path.join(d, 'video-agent')))) return path.join(d, fs.existsSync(path.join(d, '.short-editing')) ? '.short-editing' : 'video-agent'); return path.join(s, '.short-editing'); })(path.resolve(start)));\nconst styleDir = (home, name) => { if (name) return path.join(home, 'styles', name); try { const d = readJSON(path.join(home, 'style.json')).default; if (typeof d === 'string') return path.join(home, 'styles', d); } catch {} return home; };\nconst KEYFILES = () => [path.join(HOME(), '.env'), path.join(process.cwd(), '.env'), path.join(os.homedir(), '.short-editing/.env')]; // keys only may be shared machine-wide\n// newest take in the profile's A-roll folder (style.folders.aroll), for `init --source latest`\nfunction latestTake(st) {\n const dir = st?.folders?.aroll && expand(st.folders.aroll);\n if (!dir || !fs.existsSync(dir)) throw new Error('--source latest needs folders.aroll in the creator profile (run studio-setup)');\n const vids = fs.readdirSync(dir).filter(f => /\\.(mp4|mov|m4v)$/i.test(f) && !f.startsWith('.')).map(f => path.join(dir, f));\n if (!vids.length) throw new Error(`no video in the A-roll folder ${dir}`);\n return vids.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0];\n}\nasync function init() {\n if (fs.existsSync(P('source/spec.js'))) throw new Error(`${P('source/spec.js')} already exists, not overwriting`);\n for (const d of ['assets', 'files', 'source', 'output', 'stills']) fs.mkdirSync(P(d), { recursive: true });\n // --style \u003cname>: a named style of the profile (\u003cprofile>/styles/\u003cname>/), else the default one; the style's assets/\n // folder (e.g. a mascot pose pack) is copied into the project so the short stays self-contained\n const sname = flag('style'), sdir = styleDir(HOME(), sname && sname !== true ? sname : null);\n if (sdir !== HOME() && !fs.existsSync(path.join(sdir, 'style.json'))) throw new Error(`no style \"${sname || path.basename(sdir)}\": ${sdir}/style.json missing`);\n const prof = path.join(sdir, 'style.json'), st = fs.existsSync(prof) ? prof : path.join(ENGINE, 'templates/style.json');\n fs.copyFileSync(st, P('source/style.json'));\n if (sdir !== HOME() && fs.existsSync(path.join(sdir, 'assets'))) fs.cpSync(path.join(sdir, 'assets'), P('assets'), { recursive: true });\n const style = readJSON(P('source/style.json'));\n let src = flag('source'); if (src === true) src = null;\n if (src === 'latest') src = latestTake(style);\n if (src) fs.copyFileSync(expand(src), P('assets/source.mp4'), fs.constants.COPYFILE_FICLONE); // APFS clone: instant, no extra disk\n // a Recorder take (its \u003ctake>.json sidecar names the script slug): origin.json links the video to the take, and through it to\n // the script, so the studio shows Script -> Take -> Video both ways. The copy above no longer knows where it came from.\n if (src) try { if (readJSON(expand(src).replace(/\\.\\w+$/, '.json')).slug) fs.writeFileSync(P('origin.json'), JSON.stringify({ takes: [path.basename(expand(src))] }, null, 2) + '\\n'); } catch {}\n const take = fs.existsSync(P('assets/source.mp4')); // copied now, or already downloaded there (Tella)\n // camera framing: the profile's (same take size) or detected on this take (Apple Vision, ~2 s), else the template's\n const yt = flag('format') === 'youtube'; // long-form 16:9: templates/youtube.js, the camera keeps the whole take (crop = full frame)\n let spec = fs.readFileSync(path.join(ENGINE, yt ? 'templates/youtube.js' : 'templates/spec.js'), 'utf8'), cam = null, how = 'template default, check a frame';\n const camera = await import('./camera.mjs'), sz = take && camera.size(P('assets/source.mp4')); // rotation-aware (iPhone)\n if (yt && take) try { const f = camera.detectCamera(P('assets/source.mp4'));\n cam = { crop: [0, 0, sz.w, sz.h], eyes: [f.crop[0] + f.eyes[0], f.crop[1] + f.eyes[1]] }; how = f.faces ? 'full take, face detected' : 'full take, no face found'; } catch (e) { log(`camera detection skipped: ${e.message}`); }\n else if (style.camera?.crop && (!sz || (sz.w === style.camera.w && sz.h === style.camera.h))) { cam = style.camera; how = 'creator profile'; }\n else if (take) try { cam = camera.detectCamera(P('assets/source.mp4')); how = cam.faces ? 'face detected on this take' : 'no face found, centered'; } catch (e) { log(`camera detection skipped: ${e.message}`); }\n if (cam) spec = spec.replace(/cam: \\{[^\\n]*\\},[^\\n]*/, `cam: { crop: [${cam.crop.join(', ')}], eyes: [${cam.eyes.join(', ')}] }, // ${how}`);\n // a creator style with a fixed camera card (layout.seam): one seam for the whole short, written in the starter spec\n const seam = style.layout?.seam;\n if (!yt && Number.isFinite(seam)) spec = spec.replace(/ *\\/\\/ seam: [^\\n]*\\n *layout: \\{[^\\n]*\\},[^\\n]*/, ` // seam fixed by the creator style (layout.seam): the camera card never resizes. Only 1920 (paper alone) or 0 (full camera) when its notes allow it\\n layout: { keys: [[0, ${seam}]] },`);\n fs.writeFileSync(P('source/spec.js'), spec);\n log(`init ${PROJ}: style from ${st === prof ? prof : 'engine default (no profile, run studio-setup)'}${src ? `, take ${path.basename(src)} -> assets/source.mp4` : ''}, cam: ${how}`);\n}\n\nfunction elevenKey() {\n if (process.env.ELEVEN_KEY || process.env.ELEVENLABS_API_KEY) return process.env.ELEVEN_KEY || process.env.ELEVENLABS_API_KEY;\n for (const env of KEYFILES()) {\n const m = fs.existsSync(env) && fs.readFileSync(env, 'utf8').match(/^\\s*(?:export\\s+)?(?:ELEVEN_KEY|ELEVENLABS_API_KEY)\\s*=\\s*[\"']?([^\"'\\s#]+)/m);\n if (m) return m[1];\n }\n throw new Error(`no ElevenLabs key: set ELEVEN_KEY or put ELEVEN_KEY=... in ${KEYFILES()[0]}`);\n}\nasync function words() {\n const lang = pos[0] || loadStyle()?.creator?.lang, wav = P('assets/source.wav'); // default: the creator's language\n if (!fs.existsSync(wav) || fs.statSync(wav).mtimeMs \u003c fs.statSync(P('assets/source.mp4')).mtimeMs)\n ff(['-i', P('assets/source.mp4'), '-vn', '-ac', '1', '-ar', '48000', '-c:a', 'pcm_s16le', wav]);\n let key; try { key = elevenKey(); } catch { return (await import('./whisper.mjs')).whisperWords(PROJ, lang, wav, log); }\n const t0 = Date.now();\n for (let k = 1; ; k++) {\n const fd = new FormData();\n fd.append('model_id', 'scribe_v1'); fd.append('timestamps_granularity', 'word'); fd.append('diarize', 'false');\n if (lang) fd.append('language_code', lang);\n fd.append('file', new Blob([fs.readFileSync(wav)], { type: 'audio/wav' }), 'source.wav');\n let r; try { r = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { method: 'POST', headers: { 'xi-api-key': key }, body: fd, signal: AbortSignal.timeout(300000) }); }\n catch (e) { if (k >= 3) throw e; log(`network error (${e.message}), retry ${k}/3`); await new Promise(z => setTimeout(z, 5000 * k)); continue; }\n if (r.status === 401 || r.status === 403) throw new Error(`ElevenLabs auth failed (HTTP ${r.status}): check the key`);\n if (!r.ok) { if (k >= 3 || ![429, 500, 502, 503, 504].includes(r.status)) throw new Error(`ElevenLabs HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`); log(`HTTP ${r.status}, retry ${k}/3`); await new Promise(z => setTimeout(z, 5000 * k)); continue; }\n const res = await r.json(), n = (res.words || []).filter(w => w.type === 'word').length;\n if (!n) throw new Error('transcription returned 0 words (silent audio? wrong language?)');\n fs.writeFileSync(P('assets/words-scribe.json'), JSON.stringify(res, null, 2));\n return log(`words: ${n} words, lang ${res.language_code} (${((Date.now() - t0) / 1000).toFixed(1)}s) -> assets/words-scribe.json`);\n }\n}\n\nasync function audio() { await import('./audio.mjs').then(m => m.buildAudio(PROJ, loadSpec())); }\n\n// Apple AAC when ffmpeg has it: the native encoder at 192k can burst +8 dB on one frame (true peak +6.5 dBTP from a -2.2 dBFS mix)\nconst AAC = () => hasEncoder('aac_at') ? ['-c:a', 'aac_at', '-b:a', '192k'] : ['-c:a', 'aac', '-b:a', '256k'];\n\n// async ffmpeg: resolves with its stderr (ebur128 writes a line per 100 ms of audio), rejects on a non-zero exit\nconst ffRun = args => new Promise((res, rej) => {\n const p = spawn(FFMPEG, ['-hide_banner', ...args], { stdio: ['ignore', 'ignore', 'pipe'] }); let e = '';\n p.stderr.on('data', d => { e += d; }); p.once('error', rej);\n p.once('close', c => c ? rej(new Error(e.slice(-400))) : res(e));\n});\n\nasync function final() {\n const tl = readJSON(P('assets/timeline.json'));\n const slug = path.basename(PROJ), vid = fs.existsSync(P('output/video.mp4')) ? P('output/video.mp4') : P('output/draft-video.mp4');\n const out = P('output', `${slug}${vid.includes('draft') ? '-draft' : ''}.mp4`);\n // the video stream is copied as is: the decode check (a full software decode, ~1 s for 30 s of 1080x1920, VideoToolbox\n // is 3x slower here) and the poster read the render itself while the audio is encoded and muxed\n const st = fs.statSync(vid), qf = P('.cache/render/qc.json'), inRender = fs.existsSync(qf) && readJSON(qf)[vid] === `${st.size}:${st.mtimeMs}`;\n const check = inRender ? Promise.resolve(null) : ffRun(['-nostats', '-i', vid, '-map', '0:v', '-f', 'null', '-']).then(() => null, e => e);\n const poster = ffRun(['-loglevel', 'error', '-y', '-ss', String(flag('poster', 1.2)), '-i', vid, '-frames:v', '1', '-q:v', '2', P('output', `${slug}-poster.jpg`)]).then(() => null, e => e);\n // written next to it then renamed: the open studio reloads on a new render and must never catch a half-written file\n const part = path.join(path.dirname(out), `.${path.basename(out)}.part`);\n await ffRun(['-loglevel', 'error', '-y', '-i', vid, '-i', P('assets/mix.wav'), '-map', '0:v', '-map', '1:a', '-c:v', 'copy', ...BT709, ...AAC(), '-ar', '48000',\n '-t', tl.duration.toFixed(3), '-movflags', '+faststart', '-f', 'mp4', part]);\n fs.renameSync(part, out);\n // loudness on the muxed AAC (it can add overs the mix did not have)\n const eb = await ffRun(['-nostats', '-i', out, '-map', '0:a', '-af', 'ebur128=peak=true', '-f', 'null', '-']);\n const v = probe(out, 'stream=codec_name,width,height,r_frame_rate').split('\\n').slice(0, 4), d = +probe(out);\n const ce = await check; if (ce) throw new Error(`QC decode failed: ${ce.message}`);\n const pe = await poster; if (pe) throw new Error(`poster failed: ${pe.message}`);\n const I = eb.match(/I:\\s+(-?[\\d.]+) LUFS/g)?.pop()?.match(/-?[\\d.]+/)[0], tp = eb.match(/Peak:\\s+(-?[\\d.]+) dBFS/g)?.pop()?.match(/-?[\\d.]+/)[0];\n log(`QC: ${v.join(' ')} | ${d.toFixed(2)}s (timeline ${tl.duration.toFixed(2)}s) | ${I} LUFS, true peak ${tp} dBTP | decode ok${inRender ? ' (checked in render)' : ''}`);\n log(`final: ${out}`);\n}\n\nconst run = { init, words, prepare, rough, stills, capture, render, audio, final, preview, all: async () => {\n // the mix does not need the render: it runs beside it, in its own process (its DSP would stall this event loop, which\n // streams the encoded units out of Chrome)\n const a = new Promise((res, rej) => { const p = spawn(process.execPath, [fileURLToPath(import.meta.url), 'audio', ...process.argv.slice(3)], { stdio: 'inherit' });\n p.once('error', rej); p.once('close', c => c ? rej(new Error('audio exit ' + c)) : res()); });\n a.catch(() => {}); await render(); await a; await final();\n} }[cmd];\nif (!run) { console.error('unknown command', cmd); process.exit(1); }\nawait run();\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":79895,"content_sha256":"16e25dd937be7a52c4154350a3b219ae591d34cf35c1854c86e4b2034cfd49cf"},{"filename":"engine/templates/race.js","content":"// Race spec: model A vs model B on the same prompt(s), side by side, live cost + time meters, then the bill.\n// Paper-only (silent) motion video. Fill MODELS, ROUNDS and the few strings below; everything else is generated.\n// Copy to \u003cproject>/source/spec.js, then: prepare -> stills -> render --eco -> audio -> final.\nconst FORMAT = 'wide'; // 'wide' 1920x1080 (X / YouTube, like the reference race videos) | 'square' 1080x1080 | 'short' 1080x1920\nconst CLIPS = '~/Movies/Shorts/motion-\u003cslug>/assets/clips/'; // absolute path (~ works) to this project's clips\nconst MODELS = [\n { name: 'GPT-6.1 Sol', logo: 'openai', effort: 'medium' }, // logos: 'openai' | 'claude' | 'codex'; effort = reasoning effort, always shown\n { name: 'Opus 5.5', logo: 'claude', effort: 'medium' },\n];\n// One round per test. clips = [modelA, modelB] (file names in CLIPS), cost in $, time in seconds (real run duration).\nconst ROUNDS = [\n { task: '3D car crash', clips: ['3d-car-crash-a.mp4', '3d-car-crash-b.mp4'], cost: [0.51, 0.90], time: [1149, 3118] },\n];\nconst CLIP_W = 1440; // clips must be recorded at >= 1440 wide (1440x900), smaller = blurry panels on 1920\nconst FOOTER = 'Same prompt · medium effort · API prices';\nconst ROUND = 8; // seconds per round (the clip keeps playing after the meters stop)\nconst OUTRO = true; // total bill bars + closing question when there are 2+ rounds\n\n// ---------------------------------------------------------------- generated\nconst media = {}, scenes = [];\nconst ratio = (a, b) => (Math.max(a, b) / Math.min(a, b)).toFixed(1).replace(/\\.0$/, '') + '×';\nlet t = 0;\nROUNDS.forEach((r, i) => {\n const a = t, b = a + ROUND, cheap = r.cost[0] \u003c= r.cost[1] ? 0 : 1;\n r.clips.forEach((c, j) => { media[`r${i}m${j}`] = { src: CLIPS + c, width: CLIP_W }; });\n scenes.push({\n type: 'race', from: a, to: b - .1, pre: i === 0 ? .5 : .15,\n header: { text: ROUNDS.length > 1 ? `${i + 1}/${ROUNDS.length} · ${r.task}` : r.task, at: a },\n footer: { text: FOOTER, at: a },\n countFrom: a + .5, countTo: a + ROUND * .5, winnerAt: a + ROUND * .5 + .4,\n chip: { text: `${MODELS[cheap].name}: ${ratio(...r.cost)} cheaper`, at: a + ROUND * .5 + .8 },\n panels: MODELS.map((m, j) => ({ ...m, effort: m.effort && `${m.effort[0].toUpperCase() + m.effort.slice(1)} effort`, media: `r${i}m${j}`, cost: r.cost[j], costSuffix: r.suffix?.[j] || '', time: r.time?.[j] })),\n });\n t = b;\n});\nif (OUTRO && ROUNDS.length > 1) {\n const tot = MODELS.map((_, j) => ROUNDS.reduce((s, r) => s + r.cost[j], 0)), max = Math.max(...tot), lo = tot.indexOf(Math.min(...tot));\n const a = t, H = FORMAT === 'short' ? 1920 : 1080;\n scenes.push(\n { type: 'bars', from: a, to: a + 5, y: H / 2 - 120,\n tags: [{ text: `Total bill · ${ROUNDS.length} tests`, at: a + .1 }],\n rows: MODELS.map((m, j) => ({ label: `${m.name}${m.effort ? ` (${m.effort})` : ''} · ${tot[j].toFixed(2)}`, value: tot[j] / max, at: a + .3 + j * .6, blue: j === lo, dark: j !== lo })),\n gapAt: a + 2, gapChip: { text: `${ratio(...tot)} cheaper`, at: a + 2.4 } },\n { type: 'title', from: a + 5.2, y: H / 2 - 60, size: 110, lines: [\n { text: 'Which one', at: a + 5.3 }, { text: 'would you ship?', at: a + 5.7, emph: 'ship?', emphAt: a + 6.2 }] },\n );\n t = a + 8.5;\n}\n\nwindow.SPEC = {\n title: `${MODELS[0].name} vs ${MODELS[1].name}`,\n format: FORMAT === 'short' ? undefined : FORMAT,\n silent: true,\n duration: t,\n media,\n layout: { keys: [[0, FORMAT === 'short' ? 1920 : 1080]] },\n scenes,\n music: { bpm: 112 },\n mix: { music: -24, sfx: -27, ceil: -2.2 },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3634,"content_sha256":"bc4d33f423e62dad1e7494106cc669fa35aa761e0a82d4a77fc8935d24bae02c"},{"filename":"engine/templates/spec.js","content":"// Short: \"\u003cone-line promise>\". All times are SOURCE seconds (read them from files/words.md).\n// Parameters: references/scene-library.md. Delete what you don't use.\n// Pace (speed, tighten), caption look, music bpm and mix come from source/style.json (the creator style snapshot\n// written by `init`). Set a key here only to override it for this one short.\nwindow.SPEC = {\n title: '\u003ctitle>',\n media: {\n // demo: { src: '~/path/to/clip.mp4', from: 0, dur: 10 },\n },\n cam: { crop: [400, 0, 1200, 1080], eyes: [600, 400] }, // check a source frame, center the face\n cut: [], // [[a, b]] retakes / dead air to drop\n\n // seam: 960 half, 1240 paper-heavy, 0 full camera. Change it every few seconds.\n layout: { keys: [[0, 960]] },\n zoom: { keys: [[0, 1]] },\n\n actors: [\n // { id: 'demo', media: 'demo', offset: 0, from: 0.5, to: 6, pre: .45,\n // keys: [[0.5, [60, 250, 960]]], labelKeys: [[0.5, 'Label']], dot: 'blue' },\n ],\n\n scenes: [\n // { type: 'stat', from: 6, to: 9, value: 100, suffix: '%', countFrom: 6.4, countTo: 7.1, y: 760, size: 300 },\n // { type: 'title', from: 9, to: 11, lines: [{ text: 'Line one', at: 9 }, { text: 'line two.', at: 9.8, emph: 'two.', emphAt: 10.2 }] },\n // { type: 'cta', layer: 'over', from: 40, clickAt: 41.4, y: 1420 }, // name, sub, button, done: from style.creator; without a profile (or to override) set them here\n ],\n\n music: { breaks: [] }, // [[a, b]] drums drop (a punchline, a full-camera beat); a library bed instead: src + offset, see references/music.md\n\n captions: {\n replace: [],\n emph: [],\n hide: [], // ranges where a scene already shows the words (title, typing, CTA -> [ctaFrom, 99])\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1738,"content_sha256":"ad6b63c76f5ff1dd8a876f514a99bb32d2ae42907c5bd578d155c3103bdcfe9b"},{"filename":"engine/templates/style.json","content":"{\n \"version\": 1,\n \"name\": \"Paper (default)\",\n \"pace\": { \"speed\": 1.15, \"tighten\": { \"sentence\": 0.08, \"comma\": 0.05, \"word\": 0.03, \"lead\": 0.03, \"tail\": 0.8 } },\n \"captions\": { \"style\": \"sticker\", \"size\": 84 },\n \"music\": { \"bpm\": 112 },\n \"mix\": { \"music\": -28, \"sfx\": -28.5, \"duck\": 0.5 }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":297,"content_sha256":"fd4cf76fb6c576c804aeaf466a03f52d63c2858cf173ec516ba8311ed64e116c"},{"filename":"engine/templates/youtube.js","content":"// YouTube long-form (16:9, 1920x1080): \"\u003cone-line promise>\". All times are SOURCE seconds (files/words.md).\n// Runtime: lib/runtime-yt.js. Scenes draw on a 1080x1080 paper stage (same coordinates as format 'square': x 0..1080,\n// centre 540, y 0..1080) that sits LEFT of the camera. Parameters: references/scene-library.md, rhythm: the\n// youtube-editing skill's references/motion-design.md.\nconst L = n => `assets/logos/${n}.png`; // real logos only (official site / app icon), drawn flat\nconst IC = n => `assets/icons/${n}.png`; // house icons (bare Google Noto emoji): copy from \u003cskill>/assets/icons/, never another pack\n// frosted glass panel LEFT of the speaker on full camera ('over' layer, output px): x 48..648, centre CX, y 150..930.\n// The default way to show a source, a quote or a list while he talks: no layout move at all.\nconst GX = 48, GW = 600, CX = GX + GW / 2;\nconst glass = (from, to, items) => ({ type: 'board', layer: 'over', from, to, pre: .4, items: [\n { k: 'glass', x: GX, y: 150, w: GW, h: 780, at: from, pre: .4, rise: 0, tint: .8 }, ...items] });\n\nwindow.SPEC = {\n format: 'youtube',\n title: '\u003ctitle>',\n media: {\n // article: { src: 'assets/sources/fortune.png' }, // a captured source (still, Ken Burns)\n // demo: { src: '~/path/to/clip.mp4', from: 0, dur: 10, fill: true }, // full-screen B-roll (fill crops to 16:9)\n },\n cam: { crop: [0, 0, 1920, 1080], eyes: [960, 400] }, // the whole take; eyes = the face (source px), set by init\n cut: [], // [[a, b]] retakes, false starts, dead air to drop\n\n // Long-form pace: only real pauses go (budget per boundary in s), breathing stays, no speed-up.\n speed: 1,\n tighten: { sentence: .36, comma: .22, word: .12, lead: .15, tail: 1.2 },\n\n // [t, x], x = the camera's left edge: 0 full camera, 1080 split (paper 1080 left, camera 840 right), 1920 paper only\n // (the stage glides to the centre). layout.w: spring stiffness (9 = a calm half-second move).\n // Default 0 + glass panels. 1920 for any schema / board / tree. 1080 only for a long run of sources (10 s+).\n // Never split -> full -> split: one move per idea, 2.5 s apart at least.\n layout: { keys: [[0, 0]] },\n // zoom.keys [[t, z]] slow punch-ins; jump cuts alternate 1 / zoom.punch automatically (zoom.jumps: false to stop it).\n zoom: { keys: [[0, 1]], punch: 1.1 },\n\n actors: [],\n scenes: [\n // glass(40, 48, [{ k: 'text', text: 'C\\'est du bullshit.', size: 62, x: CX, y: 250, at: 40.2, emph: 'bullshit.', emphAt: 40.6, maxW: 540 },\n // { k: 'img', img: IC('skull'), s: 110, x: CX, y: 420, at: 42.0 }]),\n // { type: 'tweet', layer: 'over', from: 60, to: 66, rect: [GX, 250, GW], size: 30, photo: true, ... }, // a tweet LEFT of him on full camera\n // { type: 'title', from: 70, to: 74, lines: [{ text: 'Chapitre 1', at: 70 }, { text: 'Le sujet', at: 70.6, emph: 'sujet' }] },\n // { type: 'cta', layer: 'over', from: 400, clickAt: 401.4, y: 900 },\n ],\n broll: [], // [{ media, from, to, offset, focus, zoom }] full-screen 1920x1080 over everything\n\n music: { chapters: [[0, 'enquete']] }, // [[at, bedId]] one bed per chapter, 2 s crossfade at each `at`\n mix: { music: -30 }, // long-form: the bed sits further under the voice\n\n captions: { on: false, size: 52, replace: [], emph: [], hide: [] }, // YouTube CC carries the words; on: true for small stickers\n};\n// house icon tiles carry their own depth: no drop shadow\nfor (const s of window.SPEC.scenes) for (const it of s.items || []) if (it.k === 'img' && it.img.startsWith('assets/icons/')) it.shadow = false;\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3640,"content_sha256":"29a6fd79e38f5b0cd6f91f11e637e606c4b5866aad053dd027acd5c2b49f4e84"},{"filename":"engine/vision-web.mjs","content":"#!/usr/bin/env node\n// MediaPipe twin of faces.swift and matte.swift, for Windows and any machine without Apple Vision: same command line,\n// same output files, computed in headless Chrome (playwright-core + @mediapipe/tasks-vision on the CPU, models fetched once).\n// node vision-web.mjs faces \u003ccamDir> \u003cout.json> [[cx, cy, w, h, roll], ...] per frame, gaps filled, smoothed\n// node vision-web.mjs matte \u003ccamDir> \u003coutDir> \u003cjobFile> job lines \"\u003cframe> \u003crawMaskPath> \u003cwrite 0|1>\" (see matte.swift)\n// node vision-web.mjs fetch download the models now (the installer does, so edits work offline)\n// platform.mjs picks this path when Apple Vision is not there (SHORT_EDITING_VISION=web forces it, to compare on a Mac).\nimport fs from 'node:fs';\nimport path from 'node:path';\nimport { createRequire } from 'node:module';\nimport { ENGINE, CHROME, RUNTIME, CACHE } from './platform.mjs';\n\nconst MODELS = {\n faces: ['blaze_face_short_range.tflite', 'https://storage.googleapis.com/mediapipe-models/face_detector/blaze_face_short_range/float16/1/blaze_face_short_range.tflite'],\n matte: ['selfie_multiclass_256x256.tflite', 'https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/1/selfie_multiclass_256x256.tflite'],\n};\nconst MODEL_DIR = path.join(RUNTIME || CACHE, 'models');\n\nasync function model(k) {\n const [name, url] = MODELS[k], f = path.join(MODEL_DIR, name);\n if (fs.existsSync(f)) return f;\n fs.mkdirSync(MODEL_DIR, { recursive: true });\n const r = await fetch(url);\n if (!r.ok) throw new Error(`model download failed (${r.status}): ${url}`);\n fs.writeFileSync(f + '.tmp', Buffer.from(await r.arrayBuffer())); fs.renameSync(f + '.tmp', f);\n return f;\n}\n\n// Everything the page reads or writes goes through one fake origin: /mp/* = the tasks-vision package, /file?f= reads a\n// local file, POST /put?f= writes one. No server, no port.\nconst TYPES = { '.mjs': 'text/javascript', '.js': 'text/javascript', '.wasm': 'application/wasm', '.jpg': 'image/jpeg', '.png': 'image/png', '.tflite': 'application/octet-stream' };\nconst PAGE = `\u003c!doctype html>\u003cscript type=\"module\">import * as V from '/mp/vision_bundle.mjs'; window.V = V; window.READY = true;\u003c/script>`;\nasync function withPage(fn) {\n const { chromium } = createRequire(path.join(ENGINE, 'package.json'))('playwright-core');\n const MP = path.dirname(createRequire(path.join(ENGINE, 'package.json')).resolve('@mediapipe/tasks-vision'));\n const browser = await chromium.launch({ executablePath: CHROME, headless: true, args: ['--disable-extensions', '--mute-audio'] });\n try {\n const page = await browser.newPage();\n page.on('pageerror', e => console.error(' pageerror:', e.message));\n await page.route('http://vision.local/**', async route => {\n const u = new URL(route.request().url()), f = u.searchParams.get('f');\n if (u.pathname === '/') return route.fulfill({ body: PAGE, contentType: 'text/html' });\n if (u.pathname === '/put') { const t = f + '.tmp'; fs.writeFileSync(t, route.request().postDataBuffer()); fs.renameSync(t, f); return route.fulfill({ body: 'ok' }); }\n const file = u.pathname === '/file' ? f : u.pathname.startsWith('/mp/') ? path.join(MP, decodeURIComponent(u.pathname.slice(4))) : null;\n if (!file || !fs.existsSync(file)) return route.fulfill({ status: 404, body: 'not found' });\n return route.fulfill({ body: fs.readFileSync(file), contentType: TYPES[path.extname(file)] || 'application/octet-stream' });\n });\n await page.goto('http://vision.local/');\n await page.waitForFunction(() => window.READY === true, null, { timeout: 60000 });\n return await fn(page);\n } finally { await browser.close(); }\n}\nconst fileURL = f => 'http://vision.local/file?f=' + encodeURIComponent(f);\n\n// ---- faces: the widest face per frame in frame px (y down), roll in radians (clockwise on screen)\nasync function faces(dir, out) {\n const m = await model('faces'), files = fs.readdirSync(dir).filter(f => f.endsWith('.jpg')).sort();\n const raw = await withPage(async page => {\n await page.evaluate(async m => {\n const fset = await V.FilesetResolver.forVisionTasks('/mp/wasm');\n window.det = await V.FaceDetector.createFromOptions(fset, { baseOptions: { modelAssetPath: m, delegate: 'CPU' }, runningMode: 'IMAGE', minDetectionConfidence: .5 });\n }, fileURL(m));\n const res = [];\n for (const [i, f] of files.entries()) {\n res.push(await page.evaluate(async url => {\n const img = await createImageBitmap(await (await fetch(url)).blob()), W = img.width, H = img.height;\n const best = det.detect(img).detections.sort((a, b) => b.boundingBox.width - a.boundingBox.width)[0];\n img.close();\n if (!best) return null;\n const b = best.boundingBox, [r, l] = best.keypoints; // the subject's right eye (image left), then left eye\n // BlazeFace boxes run ~15% larger and a bit lower than Apple Vision's: scale them to the same face box\n const k = .86, w = b.width * k, h = b.height * k;\n return [b.originX + b.width / 2, b.originY + b.height / 2 - .03 * b.height, w, h, r && l ? Math.atan2((l.y - r.y) * H, (l.x - r.x) * W) : 0];\n }, fileURL(path.join(dir, f))));\n if (i % 200 === 0) console.log(`faces ${i + 1}/${files.length}`);\n }\n return res;\n });\n // fill gaps from the last detection (the first one before it), then a centred moving average, radius 3 frames\n let last = raw.find(Boolean) || null;\n const filled = raw.map(v => (last = v || last));\n const outRows = filled.map((_, i) => {\n const acc = [0, 0, 0, 0, 0]; let n = 0;\n for (let j = Math.max(0, i - 3); j \u003c= Math.min(filled.length - 1, i + 3); j++) if (filled[j]) { filled[j].forEach((x, k) => acc[k] += x); n++; }\n return n ? acc.map(x => Math.round(x / n * 10) / 10) : [0, 0, 0, 0, 0];\n });\n fs.writeFileSync(out, JSON.stringify(outRows));\n console.log(`faces done: ${outRows.length} frames, ${raw.filter(Boolean).length} detected`);\n}\n\n// ---- matte: person = 1 - background confidence, upscaled to the cam frame, cached as a gray PNG; then a temporal EMA\n// (40% previous) and a 5x5 gaussian, written as \u003coutDir>/%05d.png with alpha = person (rgb 0), like matte.swift\nasync function matte(camDir, outDir, jobFile) {\n const m = await model('matte');\n fs.mkdirSync(outDir, { recursive: true });\n const jobs = fs.readFileSync(jobFile, 'utf8').split('\\n').map(l => l.trim().split(' ')).filter(f => f.length === 3 && /^\\d+$/.test(f[0]));\n await withPage(async page => {\n await page.evaluate(async m => {\n const fset = await V.FilesetResolver.forVisionTasks('/mp/wasm');\n window.seg = await V.ImageSegmenter.createFromOptions(fset, { baseOptions: { modelAssetPath: m, delegate: 'CPU' }, runningMode: 'IMAGE', outputConfidenceMasks: true, outputCategoryMask: false });\n const G = [0.0398, 0.2420, 0.4364, 0.2420, 0.0398];\n const png = async (c, put) => fetch(put, { method: 'POST', body: await c.convertToBlob({ type: 'image/png' }) });\n let ema = null, tmp = null, prev = -2;\n window.matteFrame = async ({ i, cam, cache, cached, cachePut, outPut, write }) => {\n let W, H, raw;\n if (cached) {\n const bm = await createImageBitmap(await (await fetch(cache)).blob()); W = bm.width; H = bm.height;\n const c = new OffscreenCanvas(W, H), x = c.getContext('2d'); x.drawImage(bm, 0, 0); bm.close();\n const d = x.getImageData(0, 0, W, H).data; raw = new Uint8Array(W * H); for (let j = 0; j \u003c W * H; j++) raw[j] = d[4 * j];\n } else {\n const img = await createImageBitmap(await (await fetch(cam)).blob()); W = img.width; H = img.height;\n const r = seg.segment(img), bg = r.confidenceMasks[0], mw = bg.width, mh = bg.height, p = bg.getAsFloat32Array();\n // person = 1 - background, 0.3..0.7 stretched to 0..1 (no grey haze in the background)\n const small = new OffscreenCanvas(mw, mh), sx = small.getContext('2d'), id = sx.createImageData(mw, mh);\n for (let j = 0; j \u003c mw * mh; j++) { const v = Math.round(Math.min(1, Math.max(0, (.7 - p[j]) / .4)) * 255); id.data[4 * j] = id.data[4 * j + 1] = id.data[4 * j + 2] = v; id.data[4 * j + 3] = 255; }\n sx.putImageData(id, 0, 0); r.close(); img.close();\n const c = new OffscreenCanvas(W, H), x = c.getContext('2d'); x.imageSmoothingQuality = 'high'; x.drawImage(small, 0, 0, W, H);\n const d = x.getImageData(0, 0, W, H).data; raw = new Uint8Array(W * H); for (let j = 0; j \u003c W * H; j++) raw[j] = d[4 * j];\n await png(c, cachePut);\n }\n if (!ema || ema.length !== W * H || i !== prev + 1) { ema = Float32Array.from(raw); tmp = new Float32Array(W * H); }\n else for (let j = 0; j \u003c W * H; j++) ema[j] += .6 * (raw[j] - ema[j]);\n prev = i;\n if (!write) return;\n // separable 5x5 gaussian, edges extended; alpha = mask, rgb 0\n for (let y = 0; y \u003c H; y++) for (let x = 0; x \u003c W; x++) {\n let s = 0; for (let k = -2; k \u003c= 2; k++) s += G[k + 2] * Math.floor(ema[y * W + Math.min(W - 1, Math.max(0, x + k))]);\n tmp[y * W + x] = s;\n }\n const c = new OffscreenCanvas(W, H), x = c.getContext('2d'), id = x.createImageData(W, H);\n for (let y = 0; y \u003c H; y++) for (let xx = 0; xx \u003c W; xx++) {\n let s = 0; for (let k = -2; k \u003c= 2; k++) s += G[k + 2] * tmp[Math.min(H - 1, Math.max(0, y + k)) * W + xx];\n id.data[4 * (y * W + xx) + 3] = Math.min(255, Math.round(s));\n }\n x.putImageData(id, 0, 0);\n await png(c, outPut);\n };\n }, fileURL(m));\n let written = 0;\n for (const [i, rawPath, w] of jobs) {\n const name = String(+i).padStart(5, '0');\n await page.evaluate(a => window.matteFrame(a), {\n i: +i, cam: fileURL(path.join(camDir, name + '.jpg')), cache: fileURL(rawPath), cached: fs.existsSync(rawPath),\n cachePut: 'http://vision.local/put?f=' + encodeURIComponent(rawPath), outPut: 'http://vision.local/put?f=' + encodeURIComponent(path.join(outDir, name + '.png')), write: w === '1',\n });\n if (w === '1' && ++written % 10 === 0) console.log(`matte ${written}`);\n }\n });\n console.log('matte done');\n}\n\nconst [cmd, ...a] = process.argv.slice(2);\ntry {\n if (cmd === 'faces' && a.length === 2) await faces(...a);\n else if (cmd === 'matte' && a.length === 3) await matte(...a);\n else if (cmd === 'fetch') { for (const k of Object.keys(MODELS)) console.log(await model(k)); }\n else { console.log('usage: node vision-web.mjs faces \u003ccamDir> \u003cout.json> | matte \u003ccamDir> \u003coutDir> \u003cjobFile> | fetch'); process.exit(1); }\n} catch (e) { console.error(e.message); process.exit(1); }\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":10729,"content_sha256":"d9bea41ee3ebafbf772d56b4870acc0294976784900523f6005338713e8f3612"},{"filename":"engine/whisper.mjs","content":"// Word timestamps on this computer with whisper.cpp, free and offline: writes \u003cproject>/assets/words-scribe.json in the\n// same shape as ElevenLabs Scribe ({language_code, text, words: [{text, start, end, type: 'word' | 'spacing'}]}, seconds),\n// so `prepare` and everything after it work the same. `short.mjs words` calls it when no ElevenLabs key is set.\n// node whisper.mjs \u003cproject> [lang] (also: node whisper.mjs --check, node whisper.mjs --model)\n// whisper-cli: WHISPER_CLI, else \u003cskill>/.runtime/whisper/ (setup.mjs on Windows), else the PATH (brew install whisper-cpp).\n// Model: WHISPER_MODEL (a ggml name like small, medium, large-v3-turbo-q5_0, or a .bin path), default large-v3-turbo-q5_0\n// (574 MB, about real time on a laptop); `small` (466 MB) is 2 to 3 times faster and mishears more names.\nimport fs from 'node:fs';\nimport os from 'node:os';\nimport path from 'node:path';\nimport { spawnSync } from 'node:child_process';\nimport { fileURLToPath } from 'node:url';\nimport { FFMPEG, IS_WIN } from './platform.mjs';\n\nconst ENGINE = path.dirname(fileURLToPath(import.meta.url));\nconst SKILL = path.dirname(ENGINE);\nexport const MODEL_DIR = IS_WIN ? path.join(process.env.LOCALAPPDATA || os.tmpdir(), 'whisper-cpp') : path.join(os.homedir(), '.cache', 'whisper-cpp');\nconst exists = f => { try { return fs.existsSync(f); } catch { return false; } };\n\nexport function findWhisper() {\n if (process.env.WHISPER_CLI) return process.env.WHISPER_CLI;\n const local = path.join(SKILL, '.runtime', 'whisper');\n const walk = d => exists(d) ? fs.readdirSync(d, { withFileTypes: true }).flatMap(e => e.isDirectory() ? walk(path.join(d, e.name)) : [path.join(d, e.name)]) : [];\n const hit = walk(local).find(f => /^whisper-cli(\\.exe)?$/.test(path.basename(f)));\n if (hit) return hit;\n for (const name of ['whisper-cli', 'whisper-cpp']) {\n const r = spawnSync(IS_WIN ? 'where' : 'which', [name], { encoding: 'utf8' });\n if (!r.status && r.stdout.trim()) return r.stdout.trim().split(/\\r?\\n/)[0];\n }\n return null;\n}\n\nexport function modelPath() {\n const m = process.env.WHISPER_MODEL || 'large-v3-turbo-q5_0';\n return m.endsWith('.bin') ? path.resolve(m) : path.join(MODEL_DIR, `ggml-${m}.bin`);\n}\n\n// downloads the model once (Hugging Face, ggml-org), to a .part file renamed at the end\nexport async function ensureModel(log = console.log) {\n const file = modelPath();\n if (exists(file)) return file;\n const name = path.basename(file), url = `https://huggingface.co/ggerganov/whisper.cpp/resolve/main/${name}`;\n fs.mkdirSync(path.dirname(file), { recursive: true });\n log(`whisper: downloading ${name} (about 0.5 GB, once) -> ${path.dirname(file)}`);\n const r = await fetch(url);\n if (!r.ok) throw new Error(`model download failed: HTTP ${r.status} on ${url} (WHISPER_MODEL=small, medium or large-v3-turbo-q5_0)`);\n const total = +r.headers.get('content-length') || 0, part = file + '.part', out = fs.createWriteStream(part);\n let got = 0, shown = 0;\n for await (const chunk of r.body) {\n out.write(chunk); got += chunk.length;\n if (total && got / total - shown >= .1) { shown = got / total; log(`whisper: ${Math.round(shown * 100)} %`); }\n }\n await new Promise((ok, ko) => out.end(e => e ? ko(e) : ok()));\n fs.renameSync(part, file);\n return file;\n}\n\n// whisper.cpp tokens (sub-words with their own offsets) into words: a token starting with a space opens a new word\nfunction toWords(json) {\n const words = [];\n for (const seg of json.transcription || []) for (const t of seg.tokens || []) {\n const text = t.text || '';\n if (!text.trim() || /^\\[_.*_\\]$/.test(text.trim()) || /^\\[.*\\]$/.test(text.trim())) continue;\n const from = t.offsets.from / 1000, to = t.offsets.to / 1000, last = words.at(-1);\n if (last && !/^\\s/.test(text)) { last.text += text; last.end = Math.max(last.end, to); continue; }\n words.push({ text: text.trim(), start: from, end: Math.max(to, from + .02) });\n }\n // keep the timeline monotonic: a word never starts before the previous one ends\n for (let i = 1; i \u003c words.length; i++) if (words[i].start \u003c words[i - 1].end) words[i].start = Math.min(words[i - 1].end, words[i].end - .02);\n return words.filter(w => w.text);\n}\n\n// whisper drops repeats and false starts and spreads their time over the next words, so a retake\n// becomes invisible. A prompt written with hesitations makes it transcribe what was really said.\nconst VERBATIM = {\n fr: \"Euh, alors, j'ai, j'ai testé tous les, euh, tous les.\",\n en: 'Um, so, I, I tried all the, uh, all the.',\n es: 'Eh, bueno, yo, yo probé todos los, eh, todos los.',\n de: 'Äh, also, ich, ich hab alle, äh, alle.',\n it: 'Ehm, allora, ho, ho provato tutti i, ehm, tutti i.',\n pt: 'Hum, então, eu, eu testei todos os, hum, todos os.',\n};\n\nexport async function whisperWords(PROJ, lang, wav, log = console.log) {\n const cli = findWhisper();\n if (!cli) throw new Error(IS_WIN ? 'whisper.cpp not found: run node \u003cskill>/engine/setup.mjs (it installs it)'\n : `whisper.cpp not found: ${process.platform === 'darwin' ? 'brew install whisper-cpp' : 'build whisper.cpp and put whisper-cli on the PATH'}, or set WHISPER_CLI`);\n const model = await ensureModel(log), t0 = Date.now();\n const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'whisper-')), w16 = path.join(tmp, 'a.wav');\n let r = spawnSync(FFMPEG, ['-v', 'error', '-y', '-i', wav, '-ac', '1', '-ar', '16000', '-c:a', 'pcm_s16le', w16]);\n if (r.status) throw new Error(`ffmpeg could not read ${wav}: ${String(r.stderr).slice(-300)}`);\n log(`whisper: transcribing on this computer (${path.basename(model)}, about real time)`);\n const threads = String(Math.max(2, Math.min(8, os.cpus().length)));\n r = spawnSync(cli, ['-m', model, '-f', w16, '-l', lang || 'auto', '-t', threads, '-ojf', '-of', path.join(tmp, 'out'), '-np',\n ...(VERBATIM[lang] ? ['--prompt', VERBATIM[lang]] : [])],\n { encoding: 'utf8', maxBuffer: 1 \u003c\u003c 28 });\n if (r.status) throw new Error(`whisper-cli failed (${r.status}): ${(r.stderr || '').slice(-400)}`);\n const json = JSON.parse(fs.readFileSync(path.join(tmp, 'out.json'), 'utf8'));\n fs.rmSync(tmp, { recursive: true, force: true });\n const words = toWords(json);\n if (!words.length) throw new Error('transcription returned 0 words (silent audio? wrong language?)');\n const out = [];\n words.forEach((w, i) => { if (i) out.push({ text: ' ', start: words[i - 1].end, end: w.start, type: 'spacing' }); out.push({ ...w, type: 'word' }); });\n const res = { language_code: json.result?.language || lang || '', text: words.map(w => w.text).join(' '), words: out, source: 'whisper.cpp' };\n fs.writeFileSync(path.join(PROJ, 'assets/words-scribe.json'), JSON.stringify(res, null, 2));\n log(`words: ${words.length} words, lang ${res.language_code} (${((Date.now() - t0) / 1000).toFixed(1)}s, whisper.cpp) -> assets/words-scribe.json`);\n log('tip: an ElevenLabs key (ELEVENLABS_API_KEY in video-agent/.env) makes words use Scribe, which hears every retake and false start');\n}\n\nif (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {\n const [a, lang] = process.argv.slice(2);\n if (a === '--check') { const c = findWhisper(); console.log(c ? `whisper-cli: ${c}\\nmodel: ${modelPath()}${exists(modelPath()) ? '' : ' (not downloaded yet)'}` : 'whisper-cli: missing'); process.exit(c ? 0 : 1); }\n else if (a === '--model') await ensureModel();\n else if (!a) { console.error('usage: node whisper.mjs \u003cproject> [lang] | --check | --model'); process.exit(1); }\n else {\n const PROJ = path.resolve(a), wav = path.join(PROJ, 'assets/source.wav'), src = path.join(PROJ, 'assets/source.mp4');\n if (!exists(wav)) spawnSync(FFMPEG, ['-v', 'error', '-y', '-i', src, '-vn', '-ac', '1', '-ar', '48000', '-c:a', 'pcm_s16le', wav]);\n await whisperWords(PROJ, lang, wav);\n }\n}\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":7830,"content_sha256":"6e4875f7158a4281ec174732e0b4bcf90b4f7180d5fbc53573bb0514b5655a9b"},{"filename":"examples/claude-5x-value/source/spec.js","content":"// Short: \"Annule ton abonnement Codex : Anthropic donne 5,6x plus de valeur\". All times are SOURCE seconds (files/words.md).\n// Proof = the SemiAnalysis charts posted by @scaling01 (\"this is brutal\", Oct 5 2026): the real image is shown in a\n// `screen` window with zooms and rings on the numbers the creator reads, and a big native counter under it for mobile.\nconst L = { codex: 'assets/logos/codex.png', claude: 'assets/logos/claude.png', openai: 'assets/logos/openai.png', spacex: 'assets/logos/spacex-word.png', ferrari: 'assets/logos/ferrari.png', semi: 'assets/images/av-semianalysis.jpg' };\nconst CHART = 'assets/images/value-by-plan.jpg'; // 2048 x 1161, rects below are in these px\nconst R = {\n claude200: [1753, 473, 236, 49], // $11,726 58.6x\n gpt200: [635, 389, 205, 41], // $2,084 10.4x\n gpt100: [517, 620, 205, 41], // $1,055 10.6x\n claude100: [1058, 707, 205, 41], // $5,725 57.3x\n};\n\nwindow.SPEC = {\n title: 'Annule ton abonnement Codex',\n media: {\n avScaling: { src: 'assets/images/av-scaling01.jpg', width: 176 },\n chart: { src: 'assets/images/value-by-plan.jpg', width: 1080 },\n },\n cam: { crop: [532, 0, 1200, 1080], eyes: [575, 445], splitZoom: 1.25 }, // eyes +20 px and a tighter card: the red and green wall poster stays out of the card edge\n grade: true, // cooler, brighter face (the room light is warm and dim)\n cut: [[78.0, 80.36]], // \"c'est devenu Space-- c'est, c'est,\" stutter\n\n layout: { frame: true, keys: [\n [0, 960], // hook: Codex subscription, struck\n [12.70, 1240], // the tweet, then the real chart with zooms\n [33.88, 960], // native bars: 5,6x more\n [43.50, 1240], // the $100 plan on the chart\n [56.34, 0], // full screen, direct address: \"il faut à tout prix utiliser un abonnement Anthropic, les amis\"\n [62.84, 1240], // the ostrich buries its head, then wakes up\n [70.80, 1240], // Codex = Ferrari, Claude Code = SpaceX rocket\n ] },\n\n actors: [],\n\n scenes: [\n // 1. hook: a study, cancel your Codex plan\n { type: 'board', from: 4.70, to: 12.60, pre: .8, items: [\n { k: 'logo', logo: L.semi, s: 84, x: 360, y: 300, at: 4.70, pre: .8 },\n { k: 'text', text: 'SemiAnalysis', size: 54, align: 'left', x: 420, y: 300, at: 4.70, pre: .75 },\n { k: 'card', x: 140, y: 400, w: 800, h: 400, at: 4.70, pre: .7 },\n { k: 'logo', logo: L.codex, s: 170, x: 540, y: 535, at: 4.70, pre: .65, dimAt: 7.74 },\n { k: 'text', text: 'Abonnement Codex', size: 64, x: 540, y: 710, at: 4.70, pre: .6, strikeAt: 7.42, strikeW: 620 },\n ] },\n\n // 2. the tweet with the real chart\n { type: 'tweet', from: 12.70, to: 15.62, rect: [60, 230, 960], avatar: 'avScaling', name: 'Lisan al Gaib', handle: '@scaling01',\n text: 'this is brutal', size: 44, media: 'chart', mediaAr: 1161 / 2048,\n footer: '8:33 PM · Oct 5, 2026 · **138.2K** Views' },\n\n // 3. $200 plans on the real chart: Claude 11 726 $ (58,6x), then ChatGPT 2 084 $ (10x)\n { type: 'board', from: 15.70, to: 33.80, items: [\n { k: 'screen', img: CHART, x: 540, y: 540, w: 960, h: 600, at: 15.70, morph: true, // grows out of the tweet's image\n viewKeys: [[15.70, [1024, 580, 1]], [16.46, [1180, 455, 1.25]], [18.04, [1500, 480, 1.9]], [26.78, [1100, 455, 1.25]], [28.54, [760, 430, 1.9]]],\n rings: [{ at: 18.04, to: 26.70, rect: R.claude200 }, { at: 29.42, rect: R.gpt200 }] },\n { k: 'logo', logo: L.claude, s: 110, x: 150, y: 1010, at: 16.46, to: 26.70, // enters centered on \"à deux cents euros\", glides left for the count\n posKeys: [[16.46, [540, 1010]], [17.10, [150, 1010]]], scaleKeys: [[16.46, 1.3], [17.10, 1]], posW: 12 },\n { k: 'counter', value: 11726, suffix: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, size: 150, depth: 1, align: 'left', x: 240, y: 1010, at: 18.04, to: 22.8, countFrom: 18.04, countTo: 20.08 },\n { k: 'text', text: '×58,6', size: 150, depth: 1, color: 'blue', align: 'left', x: 240, y: 1010, at: 23.08, to: 26.70 },\n { k: 'logo', logo: L.openai, s: 110, x: 150, y: 1010, at: 28.10, // same move on \"l'abonnement OpenAI\"\n posKeys: [[28.10, [540, 1010]], [28.85, [150, 1010]]], scaleKeys: [[28.10, 1.3], [28.85, 1]], posW: 14 },\n { k: 'counter', value: 2084, suffix: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, size: 150, depth: 1, align: 'left', x: 240, y: 1010, at: 29.42, to: 32.6, countFrom: 29.42, countTo: 31.04 },\n { k: 'text', text: '×10', size: 150, depth: 1, align: 'left', x: 240, y: 1010, at: 33.08 },\n ] },\n\n // 4. native bars: Opus 5.5 gives 5,6x more usage\n { type: 'board', from: 33.88, to: 43.40, items: [\n { k: 'text', text: 'Valeur pour 200 $/mois', size: 56, x: 540, y: 260, at: 33.88 },\n { k: 'bar', x: 90, y: 415, w: 900, h: 90, value: 1, blue: true, logo: L.claude, label: 'Opus 5.5', valueText: '11 726

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, at: 34.50, fillFrom: 34.60, fillTo: 35.60 },\n { k: 'bar', x: 90, y: 605, w: 900, h: 90, value: 2084 / 11726, dark: true, logo: L.openai, label: 'GPT-6.1 Sol', valueText: '2 084

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, at: 34.70, fillFrom: 34.90, fillTo: 35.60 },\n { k: 'text', text: \"5,6× plus d'usage\", emph: '5,6×', size: 84, x: 540, y: 775, at: 38.94 },\n ] },\n\n // 5. same on the $100 plan\n { type: 'board', from: 43.50, to: 57.10, items: [\n { k: 'screen', img: CHART, x: 540, y: 540, w: 960, h: 600, at: 43.50,\n viewKeys: [[43.50, [1024, 580, 1]], [45.42, [1100, 680, 1.3]], [49.30, [890, 684, 1.9]]],\n rings: [{ at: 50.48, to: 52.70, rect: R.gpt100 }, { at: 52.74, rect: R.claude100 }] },\n // one hero number at a time in the same slot, like the $200 plan: OpenAI, then Claude\n { k: 'logo', logo: L.openai, s: 110, x: 150, y: 1010, at: 50.20, to: 52.50 },\n { k: 'counter', value: 1055, suffix: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, size: 150, depth: 1, align: 'left', x: 240, y: 1010, at: 50.48, to: 52.50, countFrom: 50.48, countTo: 51.36 },\n { k: 'logo', logo: L.claude, s: 110, x: 150, y: 1010, at: 52.74 },\n { k: 'counter', value: 5725, suffix: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, size: 150, depth: 1, align: 'left', x: 240, y: 1010, at: 52.74, countFrom: 52.74, countTo: 54.34 },\n ] },\n\n // 6. \"arrêtez de faire les autruches\": head in the sand, then \"réveillez-vous et switchez\" -> Claude\n { type: 'board', from: 62.84, to: 70.70, items: [\n { k: 'ostrich', s: 1, x: 455, y: 1010, at: 63.04, downFrom: 64.70, downTo: 65.70, upAt: 66.56 },\n { k: 'logo', logo: L.claude, s: 150, x: 830, y: 400, at: 67.88 },\n ] },\n\n // 7. Codex was the Ferrari, Claude Code is the SpaceX rocket: one column each, the object over its brand\n { type: 'board', from: 70.80, items: [\n // Codex opens as the hero, centered, then steps into its column when the Ferrari arrives\n { k: 'logo', logo: L.codex, s: 140, x: 290, y: 290, at: 70.80, dimAt: 76.58,\n posKeys: [[70.80, [540, 560]], [74.02, [290, 290]]], scaleKeys: [[70.80, 1.7], [74.02, 1]] },\n { k: 'text', text: 'Codex', size: 52, x: 290, y: 430, at: 70.90, dimAt: 76.58,\n posKeys: [[70.80, [540, 800]], [74.02, [290, 430]]], scaleKeys: [[70.80, 1.5], [74.02, 1]] },\n { k: 'ferrari', s: .82, x: 290, y: 930, at: 74.20 },\n { k: 'logo', logo: L.ferrari, s: 96, x: 290, y: 1060, at: 74.40 },\n { k: 'logo', logo: L.claude, s: 140, x: 790, y: 290, at: 77.30 },\n { k: 'text', text: 'Claude Code', size: 52, x: 790, y: 430, at: 77.30 },\n { k: 'starship', s: .6, x: 790, y: 755, at: 77.66, igniteAt: 80.96, liftFrom: 82.18, liftTo: 84.0, liftY: 100 },\n { k: 'img', img: L.spacex, s: 330, h: 50, x: 790, y: 1060, at: 81.26 },\n ] },\n ],\n\n music: { bed: 'enquete', offset: 10.7 },\n\n captions: {\n replace: [\n ['deux cents euros', '200 €'],\n ['onze mille sept cent vingt-six dollars', '11 726

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

],\n ['cinquante-huit virgule six fois', '58,6 fois'],\n ['deux mille quatre-vingt-quatre dollars', '2 084

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

],\n ['dix', '10'],\n ['au plus cinq point cinq', 'Opus 5.5'],\n ['cinq virgule six fois', '5,6 fois'],\n ['cent dollars', '100

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

],\n ['mille cinquante-cinq', '1 055

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

],\n ['cinq mille sept cent vingt-cinq dollars', '5 725

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

],\n ],\n emph: ['Codex', '11 726

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, '58,6 fois', '5,6 fois', '5 725

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, 'switchez', 'SpaceX'],\n hide: [[18.0, 20.4], [23.0, 25.1], [29.4, 31.4], [33.0, 33.8], [50.4, 51.4], [52.7, 54.6]], // the big number on screen already says it\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":8385,"content_sha256":"7573703d1188633ca374aa77169fc2c75646c2b10e5a5145890f5251ec639927"},{"filename":"examples/claude-5x-value/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 55.39s (source 84.203s, 59 keeps)\n\n| src | short | word |\n|---|---|---|\n| 4.70 | 0.03 | Attention, |\n| 5.26 | 0.46 | une |\n| 5.40 | 0.58 | étude |\n| 5.72 | 0.86 | vient |\n| 5.90 | 1.01 | de |\n| 6.00 | 1.10 | montrer |\n| 6.36 | 1.39 | que |\n| 6.50 | 1.51 | tu |\n| 6.62 | 1.61 | dois |\n| 6.84 | 1.80 | à |\n| 6.98 | 1.87 | tout |\n| 7.14 | 2.01 | prix |\n| 7.42 | 2.25 | annuler |\n| 7.78 | 2.56 | ton |\n| 7.94 | 2.70 | abonnement |\n| 8.30 | 3.01 | Codex |\n| 8.80 | 3.42 | et |\n| 8.92 | 3.52 | je |\n| 8.98 | 3.58 | t'explique |\n| 9.38 | 3.92 | pourquoi. |\n| 12.70 | 4.29 | On |\n| 12.80 | 4.38 | a |\n| 12.96 | 4.52 | ce |\n| 13.16 | 4.69 | tweet |\n| 13.68 | 4.97 | qui |\n| 13.84 | 5.11 | montre |\n| 14.32 | 5.50 | que |\n| 14.68 | 5.63 | l'abonnement |\n| 15.70 | 6.12 | Anthropic |\n| 16.46 | 6.78 | à |\n| 16.66 | 6.89 | deux |\n| 16.86 | 7.07 | cents |\n| 17.10 | 7.28 | euros |\n| 17.50 | 7.57 | te |\n| 17.64 | 7.69 | donne |\n| 18.04 | 8.04 | onze |\n| 18.40 | 8.35 | mille |\n| 18.92 | 8.60 | sept |\n| 19.26 | 8.86 | cent |\n| 19.56 | 9.07 | vingt-six |\n| 20.08 | 9.52 | dollars |\n| 20.46 | 9.85 | de |\n| 20.58 | 9.95 | valeur. |\n| 21.44 | 10.35 | C'est |\n| 21.60 | 10.49 | un |\n| 21.74 | 10.61 | multiplicateur |\n| 22.72 | 11.47 | de |\n| 23.08 | 11.55 | cinquante-huit |\n| 23.86 | 12.23 | virgule |\n| 24.30 | 12.61 | six |\n| 24.50 | 12.75 | fois. |\n| 25.14 | 12.99 | C'est |\n| 25.36 | 13.18 | juste |\n| 25.94 | 13.51 | immense. |\n| 26.78 | 13.95 | Alors |\n| 27.04 | 14.18 | que |\n| 27.18 | 14.30 | de |\n| 27.26 | 14.37 | l'autre |\n| 27.46 | 14.54 | côté, |\n| 28.02 | 14.80 | l'abonnement |\n| 28.54 | 15.25 | OpenAI |\n| 29.04 | 15.69 | te |\n| 29.16 | 15.79 | donne |\n| 29.42 | 16.02 | deux |\n| 29.62 | 16.19 | mille |\n| 29.86 | 16.40 | quatre-vingt-quatre |\n| 31.04 | 17.37 | dollars |\n| 31.36 | 17.65 | de |\n| 31.48 | 17.75 | valeur, |\n| 31.88 | 18.04 | soit |\n| 32.00 | 18.14 | un |\n| 32.10 | 18.23 | multiplicateur |\n| 32.90 | 18.93 | de |\n| 33.08 | 19.08 | dix. |\n| 33.88 | 19.43 | En |\n| 34.04 | 19.57 | clair, |\n| 34.50 | 19.88 | actuellement |\n| 35.06 | 20.37 | au |\n| 35.20 | 20.49 | plus |\n| 35.42 | 20.68 | cinq |\n| 35.64 | 20.88 | point |\n| 35.84 | 21.05 | cinq, |\n| 36.54 | 21.34 | même |\n| 36.82 | 21.58 | s'il |\n| 37.06 | 21.79 | coûte |\n| 37.28 | 21.98 | plus |\n| 37.46 | 22.14 | cher, |\n| 38.18 | 22.45 | il |\n| 38.28 | 22.53 | te |\n| 38.40 | 22.64 | donne |\n| 38.94 | 22.82 | cinq |\n| 39.22 | 23.06 | virgule |\n| 39.74 | 23.48 | six |\n| 40.02 | 23.67 | fois |\n| 40.38 | 23.95 | plus |\n| 40.76 | 24.28 | d'usage |\n| 41.44 | 24.85 | comparé |\n| 42.08 | 25.37 | au |\n| 42.22 | 25.50 | prix |\n| 42.46 | 25.70 | du |\n| 42.58 | 25.81 | modèle. |\n| 43.50 | 26.29 | C'est |\n| 43.90 | 26.61 | similaire |\n| 44.52 | 27.12 | avec |\n| 44.72 | 27.29 | l'abonnement |\n| 45.24 | 27.75 | à |\n| 45.42 | 27.90 | cent |\n| 45.58 | 28.04 | dollars |\n| 45.88 | 28.30 | par |\n| 46.02 | 28.42 | mois, |\n| 46.38 | 28.59 | par |\n| 46.58 | 28.77 | mois |\n| 46.84 | 28.99 | qui |\n| 46.96 | 29.10 | est |\n| 47.14 | 29.25 | bien |\n| 47.34 | 29.43 | moins |\n| 47.54 | 29.60 | compétitif, |\n| 48.24 | 30.15 | mais |\n| 48.40 | 30.29 | quand |\n| 48.56 | 30.43 | même, |\n| 49.30 | 30.70 | on |\n| 49.42 | 30.80 | se |\n| 49.58 | 30.94 | retrouve |\n| 49.96 | 31.27 | avec |\n| 50.48 | 31.69 | mille |\n| 50.74 | 31.92 | cinquante-cinq |\n| 51.36 | 32.46 | de |\n| 51.46 | 32.55 | valeur |\n| 51.78 | 32.79 | pour |\n| 51.94 | 32.93 | OpenAI |\n| 52.50 | 33.42 | et |\n| 52.74 | 33.60 | cinq |\n| 53.00 | 33.83 | mille |\n| 53.20 | 34.00 | sept |\n| 53.40 | 34.17 | cent |\n| 53.58 | 34.33 | vingt-cinq |\n| 54.34 | 34.90 | dollars |\n| 54.68 | 35.20 | de |\n| 54.80 | 35.30 | valeur |\n| 55.16 | 35.62 | pour |\n| 55.32 | 35.76 | Anthropic. |\n| 56.34 | 36.30 | Donc |\n| 56.62 | 36.54 | il |\n| 56.80 | 36.70 | faut |\n| 57.10 | 36.93 | à |\n| 57.24 | 37.05 | tout |\n| 57.40 | 37.19 | prix |\n| 57.72 | 37.47 | utiliser |\n| 58.14 | 37.83 | un |\n| 58.26 | 37.94 | abonnement |\n| 58.54 | 38.18 | Anthropic |\n| 59.00 | 38.58 | dès |\n| 59.14 | 38.70 | maintenant, |\n| 59.44 | 38.96 | les |\n| 59.64 | 39.14 | amis. |\n| 59.92 | 39.38 | Si |\n| 60.02 | 39.47 | vous |\n| 60.18 | 39.61 | avez |\n| 60.32 | 39.73 | un |\n| 60.42 | 39.81 | abonnement |\n| 60.74 | 40.09 | OpenAI, |\n| 61.56 | 40.49 | arrêtez |\n| 62.28 | 41.11 | de |\n| 62.46 | 41.27 | faire |\n| 62.84 | 41.60 | les |\n| 63.04 | 41.77 | autruches, |\n| 63.70 | 42.35 | arrêtez |\n| 64.06 | 42.66 | de |\n| 64.18 | 42.77 | mettre |\n| 64.56 | 43.10 | votre |\n| 64.80 | 43.25 | tête |\n| 65.24 | 43.63 | dans |\n| 65.58 | 43.92 | la |\n| 65.78 | 44.04 | terre |\n| 66.46 | 44.63 | et |\n| 66.56 | 44.72 | réveillez-vous |\n| 67.56 | 45.59 | et |\n| 67.88 | 45.87 | switchez. |\n| 68.56 | 46.28 | Enfin, |\n| 68.84 | 46.53 | là, |\n| 69.04 | 46.70 | là, |\n| 69.22 | 46.86 | là, |\n| 69.30 | 46.93 | il |\n| 69.36 | 46.98 | y |\n| 69.38 | 47.00 | a |\n| 69.48 | 47.08 | plus |\n| 69.60 | 47.19 | débat, |\n| 69.98 | 47.52 | je |\n| 70.02 | 47.55 | suis |\n| 70.12 | 47.64 | désolé. |\n| 70.80 | 48.00 | Autant |\n| 71.12 | 48.28 | vous |\n| 71.26 | 48.40 | dire |\n| 71.56 | 48.61 | que |\n| 71.70 | 48.73 | pendant |\n| 71.88 | 48.88 | un |\n| 71.94 | 48.93 | moment, |\n| 72.26 | 49.21 | Codex |\n| 73.18 | 49.95 | était |\n| 74.02 | 50.25 | la |\n| 74.20 | 50.41 | Ferrari |\n| 75.14 | 51.16 | des |\n| 75.28 | 51.29 | modèles |\n| 75.64 | 51.60 | IA. |\n| 76.58 | 51.90 | Là, |\n| 77.30 | 52.15 | Claude |\n| 77.66 | 52.46 | Code, |\n| 80.38 | 52.74 | c'est |\n| 80.50 | 52.84 | devenu |\n| 80.78 | 53.08 | la |\n| 80.96 | 53.24 | fusée |\n| 81.26 | 53.50 | SpaceX |\n| 82.18 | 54.01 | qui |\n| 82.32 | 54.13 | va |\n| 82.42 | 54.22 | dans |\n| 82.52 | 54.31 | les |\n| 82.68 | 54.45 | étoiles |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":5778,"content_sha256":"09990c1aec39c9c775decfcbd12763ca0b910abecb61811c489f9374f591c601"},{"filename":"examples/find-skills/source/ig-reel.html","content":"\u003c!doctype html>\n\u003chtml>\u003chead>\u003cmeta charset=\"utf-8\">\n\u003c!-- Comment CTA « Tu le veux ? Commente FINDER et je t'envoie le skill du moment que tu me suis. »: the camera is full screen\n and the Instagram Reels UI animates over it, as if the viewer were watching this very reel. A finger taps the comment\n icon on « Commente », the comments sheet comes up, FINDER is typed on « FINDER » and posted, the comment lands, then\n toncompte's DMs drop in as iOS banners (the viewer's, then two other followers'), with find-skills as the attachment.\n Transparent overlay: window.setT(t) draws the state at t seconds, t = 0 = the overlay scene's `from` (short 28.79).\n 400x711.1 CSS at zoom 2.7 = 1080x1920. Adapted from shorts/short-gouindr-faille/source/ig-reel.html (validated). -->\n\u003cstyle>\n html, body { margin: 0; background: transparent; }\n body { zoom: 2.7; width: 400px; height: 711.12px; overflow: hidden; color: #f5f5f5; -webkit-font-smoothing: antialiased;\n font-family: -apple-system, \"SF Pro Text\", \"Helvetica Neue\", \"Inter\", sans-serif; }\n #app { position: relative; width: 400px; height: 711.12px; overflow: hidden; }\n svg { display: block; }\n /* everything but the sheet stays inside x 60..1020, y 120..1820 px (22..378, 44..674 CSS) */\n #gtop { position: absolute; left: 0; top: 0; width: 400px; height: 150px; background: linear-gradient(to bottom, rgba(0,0,0,.36), rgba(0,0,0,.14) 55%, rgba(0,0,0,0)); }\n #gbot { position: absolute; left: 0; bottom: 0; width: 400px; height: 190px; background: linear-gradient(to top, rgba(0,0,0,.55), rgba(0,0,0,.2) 55%, rgba(0,0,0,0)); }\n .sh { filter: drop-shadow(0 1px 2px rgba(0,0,0,.4)); }\n .tsh { text-shadow: 0 1px 3px rgba(0,0,0,.5); }\n #status { position: absolute; left: 0; top: 46px; width: 400px; height: 26px; display: flex; align-items: center; justify-content: space-between;\n padding: 0 26px 0 30px; box-sizing: border-box; font-size: 14px; font-weight: 600; letter-spacing: -.2px; z-index: 30; }\n #status .ic { display: flex; align-items: center; gap: 5px; }\n #rtitle { position: absolute; left: 26px; top: 80px; font-size: 22px; font-weight: 700; letter-spacing: -.5px; display: flex; align-items: center; gap: 4px; }\n #rcam { position: absolute; right: 26px; top: 80px; }\n .rail { position: absolute; left: 357px; top: 0; width: 0; }\n .rail .it { position: absolute; left: -20px; width: 40px; display: flex; flex-direction: column; align-items: center; }\n .rail .ct { font-size: 12.8px; font-weight: 600; margin-top: 5px; height: 17px; position: relative; width: 60px; text-align: center; letter-spacing: -.1px; overflow: hidden; }\n #audio { position: absolute; left: 346px; top: 640px; width: 28px; height: 28px; border-radius: 6px; overflow: hidden; border: 1.5px solid #fff; box-sizing: border-box; }\n #audio img { width: 100%; height: 100%; object-fit: cover; transform: scale(1.32); }\n #info { position: absolute; left: 24px; top: 582px; width: 300px; }\n #info > div { will-change: transform; }\n #info .who { display: flex; align-items: center; gap: 9px; }\n #info .who img { width: 30px; height: 30px; border-radius: 50%; }\n #info .who b { font-size: 14px; font-weight: 700; letter-spacing: -.1px; }\n #follow { height: 26px; padding: 0 11px; border: 1px solid rgba(255,255,255,.75); border-radius: 8px; font-size: 13px; font-weight: 600;\n display: flex; align-items: center; margin-left: 2px; }\n #cap { font-size: 13.5px; line-height: 18px; margin-top: 10px; white-space: nowrap; letter-spacing: -.1px; }\n #cap i { font-style: normal; color: rgba(255,255,255,.72); }\n #music { font-size: 12.8px; margin-top: 7px; display: flex; align-items: center; gap: 6px; letter-spacing: -.1px; }\n #dim { position: absolute; inset: 0; background: #000; z-index: 25; }\n /* comments sheet: its top sits at 448 CSS (1210 px), under the chin, so the face and the captions (y 1125) stay above it */\n #sheet { position: absolute; left: 0; top: 448px; width: 400px; height: 263.12px; background: #262626; border-radius: 16px 16px 0 0; z-index: 26; overflow: hidden;\n box-shadow: 0 -8px 30px rgba(0,0,0,.35); }\n #handle { position: absolute; left: 182px; top: 8px; width: 36px; height: 4px; border-radius: 2px; background: #6e6e6e; }\n #stitle { position: absolute; left: 0; top: 20px; width: 400px; text-align: center; font-size: 15.5px; font-weight: 700; letter-spacing: -.2px; background: #262626; height: 30px; }\n #splane { position: absolute; right: 16px; top: 19px; }\n #hair { position: absolute; left: 0; top: 50px; width: 400px; height: .5px; background: rgba(255,255,255,.14); }\n #list { position: absolute; left: 0; top: 0; width: 400px; height: 204px; overflow: hidden; }\n .crow { position: absolute; left: 0; width: 400px; height: 60px; }\n .crow .hl { position: absolute; inset: 0; background: rgba(0,149,246,.22); }\n .cav { position: absolute; left: 14px; top: 9px; width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center;\n font-weight: 800; font-size: 13px; color: #fff; overflow: hidden; }\n .cav img, .cav svg { width: 100%; height: 100%; }\n .cbody { position: absolute; left: 56px; top: 6px; width: 290px; }\n .cname { font-size: 13px; line-height: 17px; display: flex; gap: 6px; align-items: baseline; }\n .cname b { font-weight: 600; letter-spacing: -.1px; } .cname span { color: #a8a8a8; font-size: 12.8px; }\n .ctext { font-size: 15px; line-height: 20px; letter-spacing: -.15px; white-space: nowrap; }\n .creply { font-size: 12.8px; line-height: 16px; color: #a8a8a8; font-weight: 600; margin-top: 1px; }\n .clike { position: absolute; left: 360px; top: 13px; width: 28px; display: flex; flex-direction: column; align-items: center; gap: 1px;\n font-size: 12.8px; color: #a8a8a8; }\n .clike .hw { position: relative; width: 15px; height: 15px; }\n .clike .hw > div { position: absolute; left: 0; top: 0; }\n #composer { position: absolute; left: 0; top: 204px; width: 400px; height: 59.12px; background: #262626; }\n #composer .hair { position: absolute; left: 0; top: 0; width: 400px; height: .5px; background: rgba(255,255,255,.12); }\n #cav2 { position: absolute; left: 14px; top: 13px; width: 32px; height: 32px; border-radius: 50%; overflow: hidden; }\n #field { position: absolute; left: 56px; top: 9px; width: 330px; height: 40px; border-radius: 20px; border: 1px solid #3c3c3c; box-sizing: border-box; }\n #ftext { position: absolute; left: 15px; top: 0; height: 38px; display: flex; align-items: center; font-size: 17px; font-weight: 600; letter-spacing: .2px; }\n #ftext span { display: inline-block; }\n #ph { position: absolute; left: 15px; top: 0; height: 38px; display: flex; align-items: center; font-size: 15px; color: #8e8e8e; letter-spacing: -.1px; }\n #caret { width: 2px; height: 21px; border-radius: 1px; background: #0095F6; margin-left: 1px; }\n #pub { position: absolute; right: 15px; top: 0; height: 38px; display: flex; align-items: center; font-size: 15px; font-weight: 700; color: #0095F6; transform-origin: 70% 50%; }\n /* iOS communication notifications: sender avatar with the app badge, name + time, three lines of body, the attachment\n thumbnail on the right. The newest drops on top, the older ones tuck behind it (iOS stack). */\n .ban { position: absolute; left: 23px; top: 50px; width: 354px; height: 100px; border-radius: 22px; background: rgba(36,36,38,.97);\n box-shadow: 0 10px 34px rgba(0,0,0,.42), 0 0 0 .5px rgba(255,255,255,.09) inset; transform-origin: 50% 100%; }\n .ban .av { position: absolute; left: 12px; top: 29px; width: 40px; height: 40px; border-radius: 50%; }\n .ban .badge { position: absolute; left: 35px; top: 52px; width: 19px; height: 19px; border-radius: 5.5px; overflow: hidden; box-shadow: 0 0 0 1.6px rgba(36,36,38,1); }\n .ban .badge img { width: 100%; height: 100%; transform: scale(1.06); }\n .ban .tx { position: absolute; left: 64px; top: 11px; width: 228px; }\n .ban .t1 { font-size: 15.5px; line-height: 20px; font-weight: 700; letter-spacing: -.2px; white-space: nowrap; }\n .ban .t1 span { font-weight: 400; color: #a3a3a3; font-size: 13px; letter-spacing: -.05px; }\n .ban .t2 { font-size: 14.5px; line-height: 19px; letter-spacing: -.2px; margin-top: 1px; white-space: nowrap; }\n .ban .t2 code { font-family: inherit; }\n .ban .when { position: absolute; right: 14px; top: 11px; font-size: 12.8px; line-height: 18px; color: #a3a3a3; letter-spacing: -.05px; }\n .ban .thumb { position: absolute; right: 13px; top: 38px; width: 38px; height: 38px; border-radius: 9px; background: #fff; overflow: hidden; }\n .ban .thumb img { position: absolute; left: 4px; top: 4px; width: 30px; height: 30px; }\n .ban .shade { position: absolute; inset: 0; border-radius: 22px; background: #000; }\n /* the viewer's finger */\n #dot { position: absolute; left: 0; top: 0; width: 34px; height: 34px; margin: -17px 0 0 -17px; border-radius: 50%; z-index: 60;\n box-sizing: border-box; border: 2px solid rgba(255,255,255,.95); box-shadow: 0 2px 10px rgba(0,0,0,.3); }\n .rip { position: absolute; left: 0; top: 0; width: 60px; height: 60px; margin: -30px 0 0 -30px; border-radius: 50%; z-index: 59;\n box-sizing: border-box; border: 2px solid rgba(255,255,255,.9); }\n\u003c/style>\u003c/head>\n\u003cbody>\u003cdiv id=\"app\">\n \u003cdiv id=\"gtop\">\u003c/div>\u003cdiv id=\"gbot\">\u003c/div>\n\n \u003cdiv id=\"status\" class=\"tsh\">\u003cspan>9:41\u003c/span>\u003cdiv class=\"ic\">\n \u003csvg width=\"18\" height=\"12\" viewBox=\"0 0 18 12\" fill=\"#fff\">\u003crect x=\"0\" y=\"8\" width=\"3\" height=\"4\" rx=\"1\"/>\u003crect x=\"5\" y=\"5.5\" width=\"3\" height=\"6.5\" rx=\"1\"/>\u003crect x=\"10\" y=\"3\" width=\"3\" height=\"9\" rx=\"1\"/>\u003crect x=\"15\" y=\"0\" width=\"3\" height=\"12\" rx=\"1\"/>\u003c/svg>\n \u003csvg width=\"16\" height=\"12\" viewBox=\"0 0 16 12\" fill=\"#fff\">\u003cpath d=\"M8 2.6c2.2 0 4.2.85 5.7 2.250l1.2-1.25C13.1 1.9 10.7.9 8 .9S2.9 1.9 1.1 3.6l1.2 1.25C3.8 3.45 5.8 2.6 8 2.6z\"/>\u003cpath d=\"M8 6.1c1.25 0 2.4.5 3.250 1.3l1.2-1.25A6.4 6.4 0 0 0 8 4.4c-1.7 0-3.3.65-4.45 1.750l1.2 1.25C5.6 6.6 6.75 6.1 8 6.1z\"/>\u003cpath d=\"M8 9.4l2-2.05a2.8 2.8 0 0 0-4 0z\" transform=\"translate(0 1.4)\"/>\u003c/svg>\n \u003csvg width=\"27\" height=\"13\" viewBox=\"0 0 27 13\">\u003crect x=\".5\" y=\".5\" width=\"23\" height=\"12\" rx=\"3.6\" fill=\"none\" stroke=\"rgba(255,255,255,.45)\"/>\u003crect x=\"2\" y=\"2\" width=\"18\" height=\"9\" rx=\"2.2\" fill=\"#fff\"/>\u003cpath d=\"M25 4.5v4c.8-.3 1.3-1.1 1.3-2s-.5-1.7-1.3-2z\" fill=\"rgba(255,255,255,.45)\"/>\u003c/svg>\n \u003c/div>\u003c/div>\n\n \u003cdiv id=\"rtitle\" class=\"tsh\">Reels\u003csvg width=\"14\" height=\"14\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#fff\" stroke-width=\"3\" stroke-linecap=\"round\" stroke-linejoin=\"round\" style=\"margin-top:3px\">\u003cpath d=\"M6 9l6 6 6-6\"/>\u003c/svg>\u003c/div>\n \u003cdiv id=\"rcam\" class=\"sh\">\u003csvg width=\"26\" height=\"26\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#fff\" stroke-width=\"1.9\" stroke-linejoin=\"round\">\u003cpath d=\"M3 8.6c0-1.2 1-2.1 2.1-2.1h2.3l1.4-2h6.4l1.4 2h2.3c1.2 0 2.1 1 2.1 2.1v9.3c0 1.2-1 2.1-2.1 2.1H5.1C4 20 3 19 3 17.9z\"/>\u003ccircle cx=\"12\" cy=\"12.8\" r=\"3.7\"/>\u003c/svg>\u003c/div>\n\n \u003cdiv class=\"rail\">\n \u003cdiv class=\"it sh rit\" style=\"top:420px\">\n \u003csvg width=\"28\" height=\"28\" viewBox=\"0 0 24 24\">\u003cpath fill=\"#fff\" d=\"M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.141 14.072 2.5 12.167 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938m0-2a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.550 5.827 5.015 7.970.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.940a6.985 6.985 0 0 0-6.708-7.218Z\"/>\u003c/svg>\n \u003cdiv class=\"ct tsh\">24,1 k\u003c/div>\n \u003c/div>\n \u003cdiv class=\"it sh rit\" style=\"top:481px\">\n \u003csvg id=\"comsvg\" width=\"28\" height=\"28\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2\" stroke-linejoin=\"round\">\u003cpath d=\"M20.656 17.008a9.993 9.993 0 1 0-3.59 3.615L22 22Z\"/>\u003c/svg>\n \u003cdiv class=\"ct tsh\">3 408\u003c/div>\n \u003c/div>\n \u003cdiv class=\"it sh rit\" style=\"top:542px\">\n \u003csvg width=\"27\" height=\"27\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#fff\" stroke-width=\"2\" stroke-linejoin=\"round\">\u003cline x1=\"22\" x2=\"9.218\" y1=\"3\" y2=\"10.083\"/>\u003cpolygon points=\"11.698 20.334 22 3.001 2 3.001 9.218 10.084 11.698 20.334\"/>\u003c/svg>\n \u003cdiv class=\"ct tsh\">6 912\u003c/div>\n \u003c/div>\n \u003cdiv class=\"it sh rit\" style=\"top:602px\">\n \u003csvg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"#fff\">\u003ccircle cx=\"5\" cy=\"12\" r=\"1.7\"/>\u003ccircle cx=\"12\" cy=\"12\" r=\"1.7\"/>\u003ccircle cx=\"19\" cy=\"12\" r=\"1.7\"/>\u003c/svg>\n \u003c/div>\n \u003c/div>\n \u003cdiv id=\"audio\">\u003cimg src=\"../assets/images/avatar.png\">\u003c/div>\n\n \u003cdiv id=\"info\" class=\"tsh\">\n \u003cdiv class=\"who\">\u003cimg src=\"../assets/images/avatar.png\">\u003cb>toncompte\u003c/b>\u003cdiv id=\"follow\">Suivre\u003c/div>\u003c/div>\n \u003cdiv id=\"cap\">Commente FINDER, je t'envoie le skill\u003ci>… plus\u003c/i>\u003c/div>\n \u003cdiv id=\"music\">\u003csvg width=\"12\" height=\"12\" viewBox=\"0 0 24 24\" fill=\"#fff\">\u003cpath d=\"M9 3v11.6A3.5 3.5 0 1 0 11 18V8h8V3z\"/>\u003c/svg>\u003cspan>toncompte · Son original\u003c/span>\u003c/div>\n \u003c/div>\n\n \u003cdiv id=\"dim\">\u003c/div>\n \u003cdiv id=\"sheet\">\n \u003cdiv id=\"list\">\u003c/div>\n \u003cdiv id=\"handle\">\u003c/div>\n \u003cdiv id=\"stitle\">Commentaires\u003c/div>\n \u003cdiv id=\"splane\">\u003csvg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#f5f5f5\" stroke-width=\"2\" stroke-linejoin=\"round\">\u003cline x1=\"22\" x2=\"9.218\" y1=\"3\" y2=\"10.083\"/>\u003cpolygon points=\"11.698 20.334 22 3.001 2 3.001 9.218 10.084 11.698 20.334\"/>\u003c/svg>\u003c/div>\n \u003cdiv id=\"hair\">\u003c/div>\n \u003cdiv id=\"composer\">\n \u003cdiv class=\"hair\">\u003c/div>\n \u003cdiv id=\"cav2\">\u003c/div>\n \u003cdiv id=\"field\">\u003cdiv id=\"ph\">Ajouter un commentaire…\u003c/div>\u003cdiv id=\"ftext\">\u003cspan id=\"typed\">\u003c/span>\u003ci id=\"caret\">\u003c/i>\u003c/div>\u003cdiv id=\"pub\">Publier\u003c/div>\u003c/div>\n \u003c/div>\n \u003c/div>\n\n \u003cdiv id=\"bans\">\u003c/div>\n \u003cdiv class=\"rip\" id=\"rip0\">\u003c/div>\u003cdiv class=\"rip\" id=\"rip1\">\u003c/div>\n \u003cdiv id=\"dot\">\u003c/div>\n\u003c/div>\n\u003cscript>\nconst $ = id => document.getElementById(id);\nconst clamp = (v, a = 0, b = 1) => Math.min(b, Math.max(a, v));\nconst seg = (t, a, b) => clamp((t - a) / (b - a));\n// critically damped spring from rest (never overshoots)\nconst S = (x, w = 14) => x \u003c= 0 ? 0 : 1 - (1 + w * x) * Math.exp(-w * x);\n// same spring with a launch velocity k in [0,1]: still never overshoots\nconst SV = (x, w = 14, k = .6) => x \u003c= 0 ? 0 : 1 - (1 + w * (1 - k) * x) * Math.exp(-w * x);\nconst out = p => 1 - Math.pow(1 - p, 3), io = p => p \u003c .5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;\nconst lerp = (a, b, p) => a + (b - a) * p;\n// a press that rises and settles back to 0, never below 0\nconst bump = (d, tau) => d \u003c= 0 ? 0 : (d / tau) * Math.exp(1 - d / tau);\nconst HEART_O = 'M16.792 3.904A4.989 4.989 0 0 1 21.5 9.122c0 3.072-2.652 4.959-5.197 7.222-2.512 2.243-3.865 3.469-4.303 3.752-.477-.309-2.143-1.823-4.303-3.752C5.141 14.072 2.5 12.167 2.5 9.122a4.989 4.989 0 0 1 4.708-5.218 4.21 4.21 0 0 1 3.675 1.941c.84 1.175.98 1.763 1.12 1.763s.278-.588 1.11-1.766a4.17 4.17 0 0 1 3.679-1.938m0-2a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.55 5.827 5.015 7.97.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.94a6.985 6.985 0 0 0-6.708-7.218Z';\nconst HEART_F = 'M16.792 1.904a6.04 6.04 0 0 0-4.797 2.127 6.052 6.052 0 0 0-4.787-2.127A6.985 6.985 0 0 0 .5 9.122c0 3.61 2.55 5.827 5.015 7.97.283.246.569.494.853.747l1.027.918a44.998 44.998 0 0 0 3.518 3.018 2 2 0 0 0 2.174 0 45.263 45.263 0 0 0 3.626-3.115l.922-.824c.293-.26.59-.519.885-.774 2.334-2.025 4.98-4.32 4.98-7.94a6.985 6.985 0 0 0-6.708-7.218Z';\nconst HEART = fill => `\u003csvg width=\"15\" height=\"15\" viewBox=\"0 0 24 24\">\u003cpath fill=\"${fill}\" d=\"${fill === '#ff3040' ? HEART_F : HEART_O}\"/>\u003c/svg>`;\n// Instagram's default avatar (the viewer, « toi »)\nconst DEFAV = '\u003csvg viewBox=\"0 0 32 32\">\u003ccircle cx=\"16\" cy=\"16\" r=\"16\" fill=\"#4d4d4d\"/>\u003ccircle cx=\"16\" cy=\"12.6\" r=\"5.6\" fill=\"#c4c4c4\"/>\u003cpath d=\"M5.6 27.6c1.8-4.9 5.9-7.5 10.4-7.5s8.6 2.6 10.4 7.5A15.9 15.9 0 0 1 16 32a15.9 15.9 0 0 1-10.4-4.4z\" fill=\"#c4c4c4\"/>\u003c/svg>';\nconst AV = '../assets/images/avatar.png', IG = '../assets/logos/instagram.png', SK = '../assets/logos/skills-sh.png';\n\n// ---- event times (seconds from the overlay start = short 28.79). Words: Tu 0, le .09, veux .16, Commente .34, FINDER .65,\n// et 1.09, je 1.16, t'envoie 1.23, le 1.44, skill 1.52, du 1.80, moment 1.88, que 2.09, tu 2.18, me 2.26, suis 2.40\n// (voice ends ~2.6, then the frame holds to 5.35 while the DMs keep landing)\nconst TAP_COM = .30, T_SHEET = .33, TAP_FIELD = .62, TYPE = [.70, .78, .86, .94, 1.02, 1.10], T_PUB_ON = .70, TAP_PUB = 1.25, T_POST = 1.29;\nconst T_BAN = [2.00, 2.90, 3.80], T_OTHER = [2.50, 3.40], T_LIKE = 4.45;\n\n$('cav2').innerHTML = DEFAV;\nconst OLD = [\n ['lucas.dev', '1 min', 'FINDER', 'linear-gradient(135deg,#2bb673,#0b6e4f)', 'L', '41'],\n ['nina.codes', '2 min', 'FINDER', 'linear-gradient(135deg,#ff7a59,#c2185b)', 'N', '28'],\n ['tom_saas', '5 min', 'Il était temps que quelqu\\'un en parle', 'linear-gradient(135deg,#5b4bdb,#1b9cff)', 'T', '63'],\n];\nconst mkRow = (name, age, text, av, likes, hl) => {\n const r = document.createElement('div'); r.className = 'crow';\n r.innerHTML = (hl ? '\u003cdiv class=\"hl\">\u003c/div>' : '') + `\u003cdiv class=\"cav\"${av.bg ? ` style=\"background:${av.bg}\"` : ''}>${av.html}\u003c/div>\n \u003cdiv class=\"cbody\">\u003cdiv class=\"cname\">\u003cb>${name}\u003c/b>\u003cspan>${age}\u003c/span>\u003c/div>\u003cdiv class=\"ctext\">${text}\u003c/div>\u003cdiv class=\"creply\">Répondre\u003c/div>\u003c/div>\n \u003cdiv class=\"clike\">\u003cdiv class=\"hw\">\u003cdiv class=\"h0\">${HEART('#a8a8a8')}\u003c/div>\u003cdiv class=\"h1\">${HEART('#ff3040')}\u003c/div>\u003c/div>\u003cspan class=\"lk\">${likes}\u003c/span>\u003c/div>`;\n $('list').appendChild(r); return r;\n};\nconst LIST_TOP = 54, ROW_H = 60;\nconst oldRows = OLD.map(([n, a, tx, bg, ini, l]) => mkRow(n, a, tx, { bg, html: ini }, l));\nconst newRow = mkRow('toi', \"à l'instant\", 'FINDER', { html: DEFAV }, '', true);\nconst others = [\n mkRow('julie.codes', \"à l'instant\", 'FINDER', { bg: 'linear-gradient(135deg,#f7b733,#fc4a1a)', html: 'J' }, ''),\n mkRow('sarah.dev', \"à l'instant\", 'FINDER', { bg: 'linear-gradient(135deg,#00b4db,#0083b0)', html: 'S' }, ''),\n];\n[...oldRows, ...others].forEach(r => r.querySelector('.h1').style.opacity = 0);\nconst letters = 'FINDER'.split('').map(c => { const s = document.createElement('span'); s.textContent = c; $('typed').appendChild(s); return s; });\nconst railIt = [...document.querySelectorAll('.rit')], infoIt = [...$('info').children];\nconst nh0 = newRow.querySelector('.h0'), nh1 = newRow.querySelector('.h1'), nhw = newRow.querySelector('.hw'), nlk = newRow.querySelector('.lk');\n\n// DM banners: the viewer's, then two other followers' (same message, the recipient named next to the sender)\nconst MSG = 'Voici le skill find-skills :\u003cbr>npx skills add vercel-labs/skills\u003cbr>--skill find-skills';\nconst BANS = [['', MSG], ['à julie.codes', MSG], ['à sarah.dev', MSG]].map(([to, msg], i) => {\n const b = document.createElement('div'); b.className = 'ban'; b.style.zIndex = 40 + i;\n b.innerHTML = `\u003cimg class=\"av\" src=\"${AV}\">\u003cdiv class=\"badge\">\u003cimg src=\"${IG}\">\u003c/div>\n \u003cdiv class=\"tx\">\u003cdiv class=\"t1\">toncompte${to ? ` \u003cspan>${to}\u003c/span>` : ''}\u003c/div>\u003cdiv class=\"t2\">${msg}\u003c/div>\u003c/div>\n \u003cdiv class=\"when\">maintenant\u003c/div>\u003cdiv class=\"thumb\">\u003cimg src=\"${SK}\">\u003c/div>\u003cdiv class=\"shade\">\u003c/div>`;\n $('bans').appendChild(b); return b;\n});\n\n// the viewer's finger: [t0, t1, x, y] eased moves (CSS px); it drifts to Publier while the letters are typed\nconst COM = [357, 495], FIELD = [170, 681], PUB = [348, 681];\nconst MOVES = [[0, 0, 318, 600], [.04, .27, COM[0], COM[1]], [.40, .58, FIELD[0], FIELD[1]], [.86, 1.16, PUB[0], PUB[1]], [1.36, 1.60, 320, 770]];\nconst TAPS = [TAP_COM, TAP_FIELD, TAP_PUB];\nfunction curAt(t) {\n let x = MOVES[0][2], y = MOVES[0][3];\n for (const [a, b, nx, ny] of MOVES) { if (t \u003c a) break; const p = b > a ? io(seg(t, a, b)) : 1; x = lerp(x, nx, p); y = lerp(y, ny, p); }\n return [x, y];\n}\nconst pressAt = (t, a) => { const d = t - a; return d > -.05 && d \u003c .09 ? clamp(1 - Math.abs(d - .015) / .065) : 0; };\nconst show = (el, v) => { el.style.display = v > .001 ? '' : 'none'; };\n\nwindow.setT = function (t) {\n // the chrome is there from the first frame (a quick settle), the rail and the info fade under the opening sheet\n const hc = io(seg(t, T_SHEET + .02, T_SHEET + .2));\n const g = clamp(seg(t, 0, .16));\n $('gtop').style.opacity = g; $('gbot').style.opacity = g * (1 - hc);\n [['status', 0], ['rtitle', .02], ['rcam', .04]].forEach(([id, d]) => {\n const q = SV(t - d, 22, .7), el = $(id);\n el.style.transform = `translateY(${-14 * (1 - q)}px)`; el.style.opacity = clamp(q * 1.5);\n });\n railIt.forEach((el, i) => {\n const q = SV(t - i * .025, 22, .7);\n el.style.transform = `translateX(${36 * (1 - q)}px)`; el.style.opacity = clamp(q * 1.6) * (1 - hc);\n });\n { const q = SV(t - .08, 22, .7); $('audio').style.transform = `translateX(${36 * (1 - q)}px) rotate(${(t * 40) % 360}deg)`; $('audio').style.opacity = clamp(q * 1.6) * (1 - hc); }\n infoIt.forEach((el, i) => {\n const q = SV(t - (.02 + i * .03), 22, .7);\n el.style.transform = `translateY(${16 * (1 - q)}px)`; el.style.opacity = clamp(q * 1.5) * (1 - hc);\n });\n $('comsvg').style.transform = `scale(${1 - .2 * pressAt(t, TAP_COM)})`;\n\n // sheet slides up over the bottom of the reel (the face stays in the clear), an 8 % dim at most on the reel\n const ps = SV(t - T_SHEET, 18, .6);\n $('sheet').style.transform = `translateY(${(1 - ps) * 290}px)`; show($('sheet'), ps);\n $('dim').style.opacity = .08 * ps; show($('dim'), ps);\n const hq = S(t - (T_SHEET + .05), 20);\n $('stitle').style.opacity = hq; $('splane').style.opacity = hq;\n\n // list: your comment slides in on top on Publier and stays pinned there, the other followers' FINDER land under it\n const pn = SV(t - T_POST, 16, .5), po = T_OTHER.map(a => SV(t - a, 15, .5)), pos = po[0] + po[1];\n oldRows.forEach((r, i) => {\n const q = S(t - (T_SHEET + .08 + i * .04), 18);\n r.style.top = (LIST_TOP + (i + pn + pos) * ROW_H + (1 - q) * 14) + 'px'; r.style.opacity = q;\n });\n const slotIn = (r, p, top) => {\n r.style.display = p > 0 ? '' : 'none';\n r.style.top = top + 'px'; r.style.opacity = clamp((p - .08) / .45);\n r.style.clipPath = `inset(0 0 ${(1 - p) * ROW_H}px 0)`;\n r.style.transform = `scale(${.97 + .03 * p})`; r.style.transformOrigin = '20% 0%';\n };\n slotIn(newRow, pn, LIST_TOP);\n slotIn(others[1], po[1], LIST_TOP + ROW_H);\n slotIn(others[0], po[0], LIST_TOP + ROW_H + po[1] * ROW_H);\n newRow.querySelector('.hl').style.opacity = clamp(pn * 2) * (1 - .5 * io(seg(t, 3.0, 3.6)));\n // toncompte likes your comment (the red heart fills in) during the hold\n const lq = S(t - T_LIKE, 20);\n nh1.style.opacity = lq; nh0.style.opacity = 1 - lq;\n nhw.style.transform = `scale(${1 + .28 * bump(t - T_LIKE, .08)})`;\n nlk.textContent = t >= T_LIKE ? '1' : ''; nlk.style.opacity = lq;\n\n // field: tapped, FINDER typed letter by letter on « FINDER », Publier lights up, cleared after the post\n const focused = t >= TAP_FIELD + .02 && t \u003c TAP_PUB + .02, cleared = t >= TAP_PUB + .03;\n let n = 0;\n letters.forEach((s, i) => {\n const on = t >= TYPE[i] && !cleared; if (on) n++;\n const q = S(t - TYPE[i], 45);\n s.style.display = on ? '' : 'none'; s.style.transform = `translateY(${(1 - q) * 3}px) scale(${.7 + .3 * q})`; s.style.opacity = .3 + .7 * q;\n });\n $('ph').style.display = n ? 'none' : '';\n const typing = t >= TYPE[0] && t \u003c TYPE[TYPE.length - 1] + .12;\n $('caret').style.opacity = focused && (typing || Math.floor((t - TAP_FIELD) * 2.4) % 2 === 0) ? 1 : 0;\n $('ph').style.left = (focused ? 18 : 15) + 'px';\n $('field').style.borderColor = focused ? '#555' : '#3c3c3c';\n const pon = cleared ? 0 : S(t - T_PUB_ON, 26), pp = pressAt(t, TAP_PUB);\n $('pub').style.opacity = (.35 + .65 * pon) * (1 - .45 * pp);\n $('pub').style.transform = `scale(${1 - .08 * pp})`;\n\n // DM banners drop in from the top; each new one lands on top and the older ones tuck behind it (iOS stack)\n const bq = T_BAN.map(a => SV(t - a, 13, .6));\n const under = 1 - clamp(bq[0] * 1.4); // the status bar and the Reels header go under the first banner\n $('status').style.opacity = under * clamp(SV(t, 22, .7) * 1.5);\n $('rtitle').style.opacity = under * clamp(SV(t - .02, 22, .7) * 1.5); $('rcam').style.opacity = under * clamp(SV(t - .04, 22, .7) * 1.5);\n BANS.forEach((b, i) => {\n const q = bq[i]; show(b, q);\n let depth = 0; for (let j = i + 1; j \u003c BANS.length; j++) depth += bq[j];\n b.style.transform = `translateY(${-150 * (1 - q) + 9 * depth}px) scale(${.96 + .04 * q - .055 * depth})`;\n b.style.opacity = clamp(q * 3) * (1 - clamp(depth - 1.4));\n const c = 1 - clamp((depth - .3) * 2.5);\n b.querySelectorAll('.tx,.av,.badge,.when,.thumb').forEach(e => e.style.opacity = c);\n b.querySelector('.shade').style.opacity = .3 * clamp(depth);\n });\n\n // finger dot + ripples\n const [fx, fy] = curAt(t);\n let press = 0; for (const a of TAPS) press = Math.max(press, pressAt(t, a));\n const vis = clamp(seg(t, .02, .12) - seg(t, 1.38, 1.56));\n show($('dot'), vis);\n $('dot').style.transform = `translate(${fx}px,${fy}px) scale(${1 - .16 * press})`;\n $('dot').style.opacity = vis; $('dot').style.background = `rgba(255,255,255,${.32 + .3 * press})`;\n const rips = TAPS.filter(a => t >= a && t \u003c a + .42).slice(-2);\n [0, 1].forEach(i => {\n const el = $('rip' + i), a = rips[i];\n if (a === undefined) { el.style.display = 'none'; return; }\n const p = out((t - a) / .42), [rx, ry] = curAt(a);\n el.style.display = ''; el.style.transform = `translate(${rx}px,${ry}px) scale(${.45 + .85 * p})`; el.style.opacity = .85 * (1 - p);\n });\n return true;\n};\nwindow.setT(0);\n\u003c/script>\u003c/body>\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":25913,"content_sha256":"ebb66086e521f7ecfe0c8675ad87b4aa4be768d42942985e581b7d08106e187d"},{"filename":"examples/find-skills/source/install-term.html","content":"\u003c!doctype html>\n\u003chtml>\u003chead>\u003cmeta charset=\"utf-8\">\n\u003c!-- Beat: \"Et il te donne juste pas une liste, il l'installe lui-même, le bon skill, à ta place.\"\n The real Claude Code terminal: a session already open with a dim list of 3 skills (struck away on \"liste\"),\n then a Bash tool call types `npx skills add heygen-com/hyperframes --skill hyperframes` (\"l'installe\"),\n the orange spinner runs, the result lands (\"bon skill\") with a big blue check (\"skill\"), Claude answers (\"à ta place\").\n Opaque 960x840 card. Every state is a pure function of t in window.setT(t). Duration 3.0 s. -->\n\u003cstyle>\n html, body { margin: 0; background: #1E1E1E; }\n body { width: 960px; height: 840px; overflow: hidden; -webkit-font-smoothing: antialiased; position: relative; }\n #bar { position: absolute; left: 0; top: 0; width: 960px; height: 68px; background: #2A2A2A; border-bottom: 1.5px solid #111; box-sizing: border-box; }\n .tl { position: absolute; top: 23px; width: 22px; height: 22px; border-radius: 50%; }\n #title { position: absolute; left: 0; right: 0; top: 0; height: 68px; display: flex; align-items: center; justify-content: center; gap: 12px;\n font-family: \"JetBrains Mono\"; font-weight: 600; font-size: 34px; color: #8E8E8E; }\n #title img { width: 36px; height: 36px; border-radius: 9px; display: block; }\n #view { position: absolute; left: 0; top: 68px; width: 960px; height: 656px; overflow: hidden; }\n #fade { position: absolute; left: 0; top: 68px; width: 960px; height: 22px; background: linear-gradient(#1E1E1E, rgba(30,30,30,0)); }\n #doc { position: absolute; left: 0; top: 0; width: 960px; }\n .ln { position: absolute; left: 40px; width: 880px; font-family: \"JetBrains Mono\"; font-size: 38px; line-height: 56px; height: 56px;\n white-space: pre; color: #ECECEC; }\n .dim { color: #8E8E8E; }\n .b { font-weight: 800; }\n .dot { display: inline-block; width: 1ch; height: 56px; vertical-align: top; position: relative; }\n .dot::before { content: \"\"; position: absolute; left: 50%; top: 50%; width: 20px; height: 20px; margin: -10px 0 0 -10px; border-radius: 50%; background: var(--c, #ECECEC); }\n .elbow { display: inline-block; width: 1ch; height: 56px; vertical-align: top; position: relative; }\n .elbow::before { content: \"\"; position: absolute; left: 6px; top: 6px; width: 14px; height: 22px; border-left: 3px solid #6E6E6E; border-bottom: 3px solid #6E6E6E; }\n #welcome { position: absolute; left: 40px; width: 880px; height: 152px; box-sizing: border-box; border: 2.5px solid #D97757; border-radius: 14px; }\n #welcome .ln { left: 26px; }\n .strike { position: absolute; height: 3px; top: 28px; background: #9A9A9A; border-radius: 2px; transform-origin: 0 50%; }\n #spin { color: #D97757; }\n #spin .g { display: inline-block; width: 1ch; text-align: center; }\n #input { position: absolute; left: 40px; top: 724px; width: 880px; height: 76px; box-sizing: border-box; border: 2px solid #4A4A4A; border-radius: 12px; }\n #input .ln { left: 20px; top: 8px; color: #8E8E8E; }\n .caret { display: inline-block; width: 22px; height: 44px; background: #CFCFCF; vertical-align: -10px; }\n #chk { position: absolute; width: 168px; height: 168px; }\n #chk i { position: absolute; left: 13px; top: 13px; width: 142px; height: 142px; border-radius: 50%; background: #FFFFFF; }\n #chk img { position: absolute; left: 0; top: 0; width: 168px; height: 168px; display: block; }\n .hi { font-weight: 800; }\n\u003c/style>\u003c/head>\n\u003cbody>\n \u003cdiv id=\"bar\">\n \u003ci class=\"tl\" style=\"left:26px; background:#FF5F57\">\u003c/i>\n \u003ci class=\"tl\" style=\"left:62px; background:#FEBC2E\">\u003c/i>\n \u003ci class=\"tl\" style=\"left:98px; background:#28C840\">\u003c/i>\n \u003cdiv id=\"title\">\u003cimg src=\"../assets/logos/claude.png\">claude\u003c/div>\n \u003c/div>\n \u003cdiv id=\"view\">\u003cdiv id=\"doc\">\n \u003cdiv id=\"welcome\">\n \u003cdiv class=\"ln\" style=\"top:18px\">\u003cspan style=\"color:#D97757\">✻\u003c/span> \u003cspan class=\"b\">Welcome to Claude Code!\u003c/span>\u003c/div>\n \u003cdiv class=\"ln dim\" style=\"top:74px\"> cwd: ~/ma-video\u003c/div>\n \u003c/div>\n \u003cdiv class=\"ln dim\" id=\"ask\">> trouve un skill pour mes vidéos\u003c/div>\n \u003cdiv class=\"ln\" id=\"h0\">\u003cspan class=\"dot\">\u003c/span> \u003cspan id=\"h0t\">Voici 3 skills possibles :\u003c/span>\u003c/div>\n \u003cdiv class=\"ln dim\" id=\"i0\"> 1. remotion-video\u003c/div>\n \u003cdiv class=\"ln dim\" id=\"i1\"> 2. ffmpeg-tools\u003c/div>\n \u003cdiv class=\"ln dim\" id=\"i2\"> 3. video-captions\u003c/div>\n \u003ci class=\"strike\" id=\"s0\">\u003c/i>\u003ci class=\"strike\" id=\"s1\">\u003c/i>\u003ci class=\"strike\" id=\"s2\">\u003c/i>\n \u003cdiv class=\"ln\" id=\"c0\">\u003c/div>\n \u003cdiv class=\"ln\" id=\"c1\">\u003c/div>\n \u003cdiv class=\"ln\" id=\"c2\">\u003c/div>\n \u003cdiv class=\"ln\" id=\"spin\">\u003cspan class=\"g\" id=\"sg\">✻\u003c/span> Installing… \u003cspan class=\"dim\">(esc to interrupt)\u003c/span>\u003c/div>\n \u003cdiv class=\"ln\" id=\"r0\">\u003cspan class=\"dim\"> \u003c/span>\u003cspan class=\"elbow\">\u003c/span> \u003cspan style=\"color:#4EBA65\">✓\u003c/span> Installed \u003cspan class=\"hi\" id=\"hw\">hyperframes\u003c/span>\u003c/div>\n \u003cdiv class=\"ln dim\" id=\"r1\"> to ~/.claude/skills\u003c/div>\n \u003cdiv id=\"chk\">\u003ci>\u003c/i>\u003cimg src=\"../assets/icons/glyph/check.png\">\u003c/div>\n \u003cdiv class=\"ln\" id=\"m0\">\u003c/div>\n \u003cdiv class=\"ln\" id=\"m1\">\u003c/div>\n \u003c/div>\u003c/div>\n \u003cdiv id=\"fade\">\u003c/div>\n \u003cdiv id=\"input\">\u003cdiv class=\"ln\">> \u003ci class=\"caret\">\u003c/i>\u003c/div>\u003c/div>\n\u003cscript>\nconst $ = id => document.getElementById(id);\nconst S = (x, w) => x \u003c= 0 ? 0 : 1 - (1 + w * x) * Math.exp(-w * x);\nconst cl = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));\nconst mix = (a, b, k) => a.map((v, i) => Math.round(v + (b[i] - v) * k));\nconst LH = 56, CH = 38 * 0.6, GAP = 26;\n\n// document layout (y inside #doc)\nlet y = 26;\nconst Y = {};\nY.welcome = y; y += 152 + GAP;\nY.ask = y; y += LH + GAP * .6;\nY.h0 = y; y += LH;\nY.i0 = y; y += LH; Y.i1 = y; y += LH; Y.i2 = y; y += LH + GAP;\nY.c0 = y; y += LH; Y.c1 = y; y += LH; Y.c2 = y; y += LH;\nY.spin = y; Y.r0 = y; y += LH; Y.r1 = y; y += LH + GAP;\nY.m0 = y; y += LH; Y.m1 = y; y += LH;\n$('welcome').style.top = Y.welcome + 'px';\nfor (const k of ['ask', 'h0', 'i0', 'i1', 'i2', 'c0', 'c1', 'c2', 'spin', 'r0', 'r1', 'm0', 'm1']) $(k).style.top = Y[k] + 'px';\n['i0', 'i1', 'i2'].forEach((k, i) => { // strike under each skill name (from col 5 to the end of the name)\n const len = $(k).textContent.length, s = $('s' + i);\n s.style.left = (40 + 5 * CH - 6) + 'px'; s.style.width = ((len - 5) * CH + 12) + 'px'; s.style.top = (Y[k] + 29) + 'px';\n});\n// big check at the right of the result rows\n$('chk').style.left = (960 - 40 - 168) + 'px'; $('chk').style.top = (Y.r0 + LH - 84) + 'px';\n\n// scroll: the transcript bottom stays 20 px above the input box (view height 640, input top 724 - 68 = 656)\nconst VIEW_B = 636;\nconst bottomAt = [ [1.06, Y.c2 + LH], [1.56, Y.spin + LH], [2.07, Y.r1 + LH], [2.56, Y.m1 + LH] ];\nlet cur = Math.max(0, Y.i2 + LH - VIEW_B);\nconst SCR = bottomAt.map(([t0, b], i) => { let tgt = Math.max(cur, b - VIEW_B); if (i === bottomAt.length - 1) tgt = Math.max(tgt, Y.i0); const d = tgt - cur; cur = tgt; return [t0, d]; });\n\nconst enter = (el, t0, w = 18, dy = 14) => { // eased fade + rise, never overshoots\n const p = S(t - t0, w); el.style.opacity = t \u003c t0 ? 0 : cl(p * 1.25); el.style.transform = `translateY(${(1 - p) * dy}px)`;\n};\nlet t = 0;\nconst CMD = ['npx skills add', 'heygen-com/hyperframes', '--skill hyperframes'];\nconst MSG = [\"Skill installé, je l'utilise\", 'pour ta vidéo.'];\nconst typed = (lines, n) => { const out = []; for (const s of lines) { out.push(s.slice(0, Math.max(0, n))); n -= s.length + 1; } return out; };\nconst SPIN = ['·', '✢', '✳', '✶', '✻', '✽'];\n\nwindow.setT = function (tt) {\n t = tt;\n // scroll\n let sc = SCR.reduce((a, [t0, d]) => a + d * S(t - t0, 17), Math.max(0, Y.i2 + LH - VIEW_B));\n $('doc').style.transform = `translateY(${-sc}px)`;\n\n // .76 \"liste\": the list dims and gets struck through, one line after the other\n const dk = S(t - .76, 12);\n $('h0t').style.color = `rgb(${mix([236, 236, 236], [120, 120, 120], dk).join(',')})`;\n ['i0', 'i1', 'i2'].forEach((k, i) => {\n const p = S(t - .76 - i * .06, 16);\n $(k).style.opacity = 1 - .55 * p;\n const s = $('s' + i); s.style.transform = `scaleX(${p})`; s.style.opacity = t \u003c .76 + i * .06 ? 0 : .9 - .3 * p;\n });\n\n // 1.10 \"l'installe\": the Bash tool call, the command types fast (1.10 to 1.55)\n const nC = CMD.join(' ').length, n = Math.round(nC * cl((t - 1.12) / .43)), tc = typed(CMD, n);\n const done = S(t - 2.15, 16), dotC = mix([142, 142, 142], [78, 186, 101], done);\n $('c0').innerHTML = `\u003cspan class=\"dot\" style=\"--c:rgb(${dotC.join(',')})\">\u003c/span> \u003cspan class=\"b\">Bash\u003c/span>(${tc[0]}`;\n $('c1').textContent = ' ' + tc[1];\n $('c2').textContent = ' ' + tc[2] + (n >= nC ? ')' : '');\n enter($('c0'), 1.10, 20, 16);\n $('c1').style.opacity = tc[1].length ? 1 : 0; $('c2').style.opacity = tc[2].length ? 1 : 0;\n\n // 1.60 to 2.10: the orange spinner (glyph cycles every 0.1 s), gone when the result lands\n const sp = $('spin');\n const sIn = S(t - 1.60, 22), sOut = cl((t - 2.08) / .1);\n sp.style.opacity = t \u003c 1.60 ? 0 : cl(sIn * 1.3) * (1 - sOut);\n sp.style.transform = `translateY(${(1 - sIn) * 12}px)`;\n $('sg').textContent = SPIN[Math.max(0, Math.floor((t - 1.60) / .1)) % SPIN.length];\n\n // 2.15 \"bon skill\": the result, then 2.27 \"skill\": the big blue check lands, \"hyperframes\" goes blue\n enter($('r0'), 2.15, 20, 14); enter($('r1'), 2.21, 20, 14);\n const hb = S(t - 2.27, 16);\n $('hw').style.color = `rgb(${mix([236, 236, 236], [37, 142, 255], hb).join(',')})`;\n const ck = S(t - 2.17, 20), k = $('chk');\n k.style.opacity = t \u003c 2.17 ? 0 : cl(ck * 1.6);\n k.style.transform = `translateY(${(1 - ck) * -26}px) scale(${.62 + .38 * ck})`;\n\n // 2.60 to 2.95 \"à ta place\": Claude's answer types in\n const mC = MSG.join(' ').length, m = Math.round(mC * cl((t - 2.62) / .26)), tm = typed(MSG, m);\n $('m0').innerHTML = `\u003cspan class=\"dot\">\u003c/span> ${tm[0]}`;\n $('m1').textContent = ' ' + tm[1];\n enter($('m0'), 2.60, 22, 12);\n $('m1').style.opacity = tm[1].length ? 1 : 0;\n};\nwindow.setT(0);\n\u003c/script>\n\u003c/body>\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":9973,"content_sha256":"2479b1820fdbed736a31d257db096bbf7f1f29ff8f8f015cc7988ee1f95b1ead"},{"filename":"examples/find-skills/source/leaderboard.html","content":"\u003c!doctype html>\n\u003chtml>\u003chead>\u003cmeta charset=\"utf-8\">\n\u003c!-- Beat « le plus fou c'est que c'est le skill numéro un de tout le site avec presque quatre millions d'installations ».\n The real skills.sh leaderboard (refs files/refs/skills-home.png + skills-home2.png), zoomed for a phone, 960x840 opaque card.\n 0..1.0 fast eased scroll from row 19 to the top; 1.38 « numéro » blue band on row 1; 1.82 « un » #1 badge;\n 2.36 « site » other rows dim, row 1 lifts; 3.02..3.55 « quatre millions » the INSTALLS cell grows into a counter 0 -> 3.8M;\n then hold and slow drift. Everything is a pure function of t in window.setT(t). -->\n\u003cstyle>\n html, body { margin: 0; background: #0A0A0A; }\n #app { position: relative; width: 960px; height: 840px; overflow: hidden; background: #0A0A0A; color: #EDEDED;\n font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }\n .mono { font-family: 'JetBrains Mono', monospace; }\n #stage { position: absolute; inset: 0; transform-origin: 480px 330px; }\n #view { position: absolute; left: 0; top: 76px; width: 960px; height: 764px; overflow: hidden; }\n #content { position: absolute; left: 0; top: 0; width: 960px; }\n #top { position: absolute; left: 0; top: 0; width: 960px; height: 76px; background: #0A0A0A; border-bottom: 1px solid #222; z-index: 5; }\n #top img { position: absolute; left: 40px; top: 16px; width: 44px; height: 44px; }\n #top .sl { position: absolute; left: 100px; top: 16px; font: 400 36px/44px 'Inter'; color: #444; }\n #top .nm { position: absolute; left: 126px; top: 16px; font: 500 36px/44px 'Inter'; color: #EDEDED; letter-spacing: -.5px; }\n #top .nav { position: absolute; right: 40px; top: 19px; font: 400 34px/40px 'Inter'; color: #888; }\n .lab { position: absolute; left: 40px; top: 18px; font: 500 34px/40px 'JetBrains Mono'; letter-spacing: 1px; color: #EDEDED; }\n .search { position: absolute; left: 40px; top: 70px; width: 880px; height: 60px; border-bottom: 1.5px solid #CFCFCF; }\n .search svg { position: absolute; left: 0; top: 13px; }\n .search .ph { position: absolute; left: 52px; top: 10px; font: 400 34px/40px 'JetBrains Mono'; color: #777; }\n .search .kbd { position: absolute; right: 0; top: 8px; width: 40px; height: 42px; border: 1.5px solid #333; border-radius: 8px;\n font: 400 28px/42px 'JetBrains Mono'; color: #888; text-align: center; box-sizing: border-box; line-height: 39px; }\n .tabs { position: absolute; left: 40px; top: 146px; height: 52px; font: 400 34px/40px 'JetBrains Mono'; white-space: nowrap; }\n .tabs span { position: absolute; top: 0; color: #777; }\n .tabs .on { color: #EDEDED; }\n .tabs .ul { position: absolute; left: 0; top: 48px; height: 2px; background: #EDEDED; }\n .cols { position: absolute; left: 40px; top: 208px; width: 880px; height: 40px; font: 400 32px/40px 'JetBrains Mono';\n color: #888; letter-spacing: 1px; }\n .cols span { position: absolute; top: 0; }\n .hl { position: absolute; left: 40px; width: 880px; height: 1px; background: #222; }\n .row { position: absolute; left: 40px; width: 880px; height: 92px; transform-origin: 440px 46px; }\n .row.first { overflow: hidden; border-radius: 14px; }\n .row .band { position: absolute; inset: 0; border-radius: 14px; background: rgba(37,142,255,.16);\n box-shadow: inset 0 0 0 2px #258EFF; }\n .row .bar { position: absolute; left: 0; top: 0; bottom: 0; width: 8px; border-radius: 14px 0 0 14px; background: #258EFF; }\n .row .rk { position: absolute; left: 20px; top: 26px; font: 400 34px/40px 'Inter'; color: #888; }\n .row .nm { position: absolute; left: 90px; top: 7px; font: 700 37px/42px 'Inter'; color: #EDEDED; letter-spacing: -.4px; white-space: nowrap; }\n .row .rp { position: absolute; left: 90px; top: 51px; font: 400 30px/34px 'JetBrains Mono'; color: #888; white-space: nowrap; }\n .row .ins { position: absolute; right: 20px; top: 26px; font: 400 34px/40px 'JetBrains Mono'; color: #EDEDED; white-space: nowrap;\n display: flex; align-items: center; gap: 12px; }\n .row .ins svg { flex: none; }\n .row .hr { position: absolute; left: 0; right: 0; bottom: 0; height: 1px; background: #222; }\n .badge1 { position: absolute; top: 6px; height: 46px; padding: 0 14px; border-radius: 12px; background: #258EFF; color: #fff;\n font: 800 34px/46px 'Inter'; letter-spacing: -.5px; }\n .big { position: absolute; white-space: nowrap; font-family: 'JetBrains Mono'; font-weight: 700; color: #EDEDED; }\n .bigl { position: absolute; left: 90px; font: 500 34px/40px 'JetBrains Mono'; color: #888; letter-spacing: 1px; white-space: nowrap; }\n .bigv { position: absolute; }\n #fade { position: absolute; left: 0; bottom: 0; width: 960px; height: 70px; z-index: 4;\n background: linear-gradient(180deg, rgba(10,10,10,0), #0A0A0A); }\n\u003c/style>\u003c/head>\n\u003cbody>\u003cdiv id=\"app\">\u003cdiv id=\"stage\">\n \u003cdiv id=\"view\">\u003cdiv id=\"content\">\u003c/div>\u003c/div>\n \u003cdiv id=\"top\">\u003cimg src=\"../assets/logos/skills-sh.png\">\u003cspan class=\"sl\">/\u003c/span>\u003cspan class=\"nm\">Skills\u003c/span>\u003cspan class=\"nav\">Docs\u003c/span>\u003c/div>\n \u003cdiv id=\"fade\">\u003c/div>\n\u003c/div>\u003c/div>\n\u003cscript>\nconst $ = (tag, cls, parent, html) => { const e = document.createElement(tag); if (cls) e.className = cls; if (html != null) e.innerHTML = html; parent.appendChild(e); return e; };\nconst clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));\nconst S = (x, w) => x \u003c= 0 ? 0 : 1 - (1 + w * x) * Math.exp(-w * x); // critically damped, never overshoots\nconst lerp = (a, b, p) => a + (b - a) * p;\nconst VER = (size, color) => `\u003csvg width=\"${size}\" height=\"${size}\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"${color}\" stroke-width=\"1.7\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\u003cpath d=\"M12 2.6l2.1 1.6 2.6-.3 1 2.4 2.4 1-.3 2.6 1.6 2.1-1.6 2.1.3 2.6-2.4 1-1 2.4-2.6-.3L12 21.4l-2.1-1.6-2.6.3-1-2.4-2.4-1 .3-2.6L2.6 12l1.6-2.1-.3-2.6 2.4-1 1-2.4 2.6.3z\"/>\u003cpath d=\"M8.6 12.2l2.3 2.3 4.6-4.8\"/>\u003c/svg>`;\n\n// real data, skills.sh all time leaderboard (refs)\nconst DATA = [\n ['find-skills', 'vercel-labs/skills', '3.8M', 1], ['grill-me', 'mattpocock/skills', '1.3M'], ['grill-with-docs', 'mattpocock/skills', '1.1M'],\n ['agent-browser', 'vercel-labs/agent-browser', '1.1M', 1], ['improve-codebase-architecture', 'mattpocock/skills', '1.1M'], ['tdd', 'mattpocock/skills', '1.1M'],\n ['setup-matt-pocock-skills', 'mattpocock/skills', '983.1K'], ['frontend-design', 'anthropics/skills', '976.0K', 1], ['handoff', 'mattpocock/skills', '963.7K'],\n ['hyperframes-cli', 'heygen-com/hyperframes', '944.8K'], ['triage', 'mattpocock/skills', '925.5K'], ['prototype', 'mattpocock/skills', '921.1K'],\n ['hyperframes', 'heygen-com/hyperframes', '902.5K'], ['reddit-automation', 'flowkit-labs/skills', '896.2K'], ['grilling', 'mattpocock/skills', '889.0K'],\n ['media-use', 'heygen-com/hyperframes', '821.8K'], ['hyperframes-registry', 'heygen-com/hyperframes', '803.2K'], ['domain-modeling', 'mattpocock/skills', '801.1K'],\n ['hyperframes-animation', 'heygen-com/hyperframes', '794.1K'],\n];\nconst ROW_H = 92, ROWS_TOP = 262, EXP = 190;\n\nconst content = document.getElementById('content');\n$('div', 'lab', content, 'SKILLS LEADERBOARD');\nconst se = $('div', 'search', content, `\u003csvg width=\"34\" height=\"34\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#888\" stroke-width=\"2\" stroke-linecap=\"round\">\u003ccircle cx=\"11\" cy=\"11\" r=\"6.5\"/>\u003cpath d=\"M16 16l4 4\"/>\u003c/svg>\u003cspan class=\"ph\">Search skills...\u003c/span>\u003cspan class=\"kbd\">/\u003c/span>`);\nconst tabs = $('div', 'tabs', content);\nconst t1 = $('span', 'on', tabs, 'All Time (1,286,544)'), t2 = $('span', '', tabs, 'Trending (24h)'), t3 = $('span', '', tabs, 'Hot');\nconst ul = $('div', 'ul', tabs);\nconst cols = $('div', 'cols', content);\n$('span', '', cols, '#').style.left = '20px';\n$('span', '', cols, 'SKILL').style.left = '90px';\nconst ci = $('span', '', cols, 'INSTALLS'); ci.style.right = '20px';\n$('div', 'hl', content).style.top = (ROWS_TOP - 1) + 'px';\n\nconst rows = DATA.map(([nm, rp, ins, ver], i) => {\n const r = $('div', i === 0 ? 'row first' : 'row', content);\n const band = i === 0 ? $('div', 'band', r) : null, bar = i === 0 ? $('div', 'bar', r) : null;\n $('div', 'rk', r, String(i + 1));\n const n = $('div', 'nm', r, nm);\n $('div', 'rp', r, rp);\n const c = $('div', 'ins', r, (ver ? VER(30, '#9A9A9A') : '') + `\u003cspan>${ins}\u003c/span>`);\n const hr = $('div', 'hr', r);\n return { r, band, bar, n, c, hr };\n});\nconst R1 = rows[0];\nconst badge = $('div', 'badge1', R1.r, '#1');\nconst bigl = $('div', 'bigl', R1.r, 'INSTALLS');\nconst big = $('div', 'big', R1.r);\nconst bigv = $('div', 'bigv', R1.r, VER(64, '#258EFF'));\nconst fmt = v => v \u003c 1e3 ? String(Math.round(v)) : v \u003c 1e6 ? (v / 1e3).toFixed(1) + 'K' : (v / 1e6).toFixed(1) + 'M';\n\nconst VIEW_H = 764, MAXS = ROWS_TOP + DATA.length * ROW_H - VIEW_H; // row 19 bottom on the card bottom at t 0\n\nwindow.setT = function (t) {\n // tabs layout (mono widths)\n t2.style.left = (t1.offsetWidth + 34) + 'px'; t3.style.left = (t1.offsetWidth + t2.offsetWidth + 68) + 'px';\n ul.style.width = t1.offsetWidth + 'px';\n\n // 0..1.0 fast eased scroll to the top (starts moving, settles with zero speed)\n const x = clamp(t / 1.0), ss = x * x * x * (x * (x * 6 - 15) + 10), eo = 1 - Math.pow(1 - x, 3);\n const scroll = MAXS * (1 - (0.5 * ss + 0.5 * eo));\n content.style.transform = `translateY(${-scroll.toFixed(2)}px)`;\n\n // 3.02 row 1 opens, the installs cell grows into the counter\n const g = S(t - 3.02, 11);\n const ex = EXP * g;\n rows.forEach((o, i) => {\n o.r.style.top = (ROWS_TOP + i * ROW_H + (i > 0 ? ex : 0)) + 'px';\n });\n R1.r.style.height = (ROW_H + ex) + 'px';\n\n // 1.38 « numéro » blue band sweeps in from the left\n const b = S(t - 1.38, 13);\n R1.band.style.clipPath = R1.bar.style.clipPath = `inset(0 ${(100 - 100 * b).toFixed(2)}% 0 0 round 14px)`;\n R1.band.style.opacity = R1.bar.style.opacity = b > 0 ? 1 : 0;\n R1.hr.style.opacity = 1 - b;\n R1.r.querySelector('.rk').style.color = b > .5 ? '#EDEDED' : '#888';\n rows[1] && (rows[1].r.style.zIndex = 0); R1.r.style.zIndex = 2;\n\n // 1.82 « un » #1 badge lands next to the name\n const k = S(t - 1.82, 15);\n badge.style.left = (90 + R1.n.offsetWidth + 20) + 'px';\n badge.style.opacity = clamp(k * 1.6);\n badge.style.transform = `translateX(${(-16 * (1 - k)).toFixed(2)}px) scale(${(0.86 + 0.14 * k).toFixed(4)})`;\n badge.style.transformOrigin = '0 50%';\n\n // 2.36 « site » the other rows dim, row 1 lifts\n const d = S(t - 2.36, 11);\n rows.forEach((o, i) => { if (i) o.r.style.opacity = (1 - 0.6 * d).toFixed(3); });\n R1.r.style.transform = `scale(${(1 + 0.04 * d).toFixed(4)})`;\n R1.r.style.boxShadow = d > 0 ? `0 ${(18 * d).toFixed(1)}px ${(48 * d).toFixed(1)}px rgba(0,0,0,${(0.6 * d).toFixed(3)})` : 'none';\n R1.r.style.borderRadius = '14px';\n\n // 3.02..3.55 « quatre millions » counter 0 -> 3.8M (right anchored, from the cell to the big line)\n const on = t >= 3.02;\n const cf = on ? 1 : 0; // the cell becomes the counter in place (no crossfade, no double layer)\n R1.c.style.opacity = on ? 1 - cf : 1;\n const xc = clamp((t - 3.02) / 0.53), pc = 1 - Math.pow(1 - xc, 3);\n const fs = lerp(34, 150, g), top = lerp(26, 92, g), lh = fs * 1.05;\n big.style.display = on ? 'block' : 'none';\n big.textContent = fmt(3.8e6 * pc);\n big.style.fontSize = fs.toFixed(2) + 'px'; big.style.lineHeight = lh.toFixed(2) + 'px';\n big.style.right = (20 + 0.07 * fs * g).toFixed(2) + 'px'; big.style.top = (top + (40 - lh) / 2 * (1 - g)).toFixed(2) + 'px';\n big.style.opacity = cf;\n big.style.letterSpacing = (-0.07 * fs * g).toFixed(2) + 'px';\n const bw = big.offsetWidth, vs = lerp(30, 64, g) / 64;\n bigv.style.display = on ? 'block' : 'none';\n bigv.style.left = (880 - 20 - 0.07 * fs * g - bw - 22 - 64 * vs).toFixed(2) + 'px';\n bigv.style.top = (top + lh / 2 - 32 * vs + (1 - g) * 0).toFixed(2) + 'px';\n bigv.style.transformOrigin = '0 0'; bigv.style.transform = `scale(${vs.toFixed(4)})`;\n bigv.style.opacity = cf;\n bigl.style.display = on ? 'block' : 'none';\n bigl.style.top = (92 + 150 * 1.05 - 56) + 'px';\n bigl.style.opacity = clamp((g - 0.35) / 0.5);\n bigl.style.transform = `translateY(${(14 * (1 - S(t - 3.12, 11))).toFixed(2)}px)`;\n\n // 3.55..4.1 hold, slow drift\n const dr = clamp((t - 3.5) / 0.6);\n document.getElementById('stage').style.transform = `translateY(${(-8 * dr * dr).toFixed(2)}px) scale(${(1 + 0.014 * dr * dr).toFixed(4)})`;\n};\nwindow.setT(0);\n\u003c/script>\u003c/body>\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":12358,"content_sha256":"c9b11cb8ffa91afc0b07cae50ce5011071175d509db30e1b8ada27cfc9c86067"},{"filename":"examples/find-skills/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 34.14s (source 57.13s, 43 keeps)\n\n| src | short | word |\n|---|---|---|\n| 3.36 | 0.02 | Quelqu'un |\n| 3.68 | 0.30 | a |\n| 3.76 | 0.37 | créé |\n| 3.98 | 0.56 | le |\n| 4.14 | 0.67 | meilleur |\n| 4.46 | 0.92 | skill |\n| 4.82 | 1.18 | Claude |\n| 5.10 | 1.42 | que |\n| 5.20 | 1.51 | j'ai |\n| 5.36 | 1.65 | jamais |\n| 5.64 | 1.89 | vu |\n| 5.90 | 2.06 | et |\n| 6.02 | 2.16 | presque |\n| 6.46 | 2.54 | personne |\n| 6.90 | 2.93 | ne |\n| 7.00 | 3.01 | l'utilise. |\n| 7.88 | 3.52 | Ça |\n| 8.00 | 3.62 | s'appelle |\n| 8.58 | 3.95 | Find |\n| 8.90 | 4.23 | Skill |\n| 9.26 | 4.52 | et |\n| 9.36 | 4.60 | il |\n| 9.44 | 4.67 | est |\n| 9.60 | 4.81 | branché |\n| 10.06 | 5.18 | sur |\n| 10.24 | 5.34 | une |\n| 10.42 | 5.50 | bibliothèque |\n| 11.06 | 6.02 | de |\n| 11.26 | 6.14 | plus |\n| 11.50 | 6.35 | d'un |\n| 11.76 | 6.57 | million |\n| 12.20 | 6.96 | de |\n| 12.34 | 7.08 | skills |\n| 12.72 | 7.38 | Claude. |\n| 15.20 | 7.68 | donc, |\n| 15.38 | 7.84 | tu |\n| 15.50 | 7.94 | récupères |\n| 16.00 | 8.38 | direct |\n| 16.32 | 8.65 | celui |\n| 16.56 | 8.86 | dont |\n| 16.74 | 9.02 | t'as |\n| 16.92 | 9.18 | besoin. |\n| 17.60 | 9.54 | Tu |\n| 17.72 | 9.64 | lui |\n| 17.88 | 9.78 | dis |\n| 18.06 | 9.94 | juste |\n| 18.40 | 10.23 | sur |\n| 18.62 | 10.42 | quoi |\n| 18.86 | 10.63 | tu |\n| 19.00 | 10.75 | bosses, |\n| 19.30 | 11.01 | genre |\n| 19.58 | 11.26 | monter |\n| 19.88 | 11.52 | tes |\n| 20.04 | 11.66 | vidéos |\n| 20.46 | 12.02 | ou |\n| 20.60 | 12.14 | écrire |\n| 20.88 | 12.39 | tes |\n| 21.06 | 12.54 | emails |\n| 21.68 | 13.08 | et |\n| 21.76 | 13.15 | il |\n| 21.92 | 13.26 | te |\n| 22.12 | 13.41 | sort |\n| 22.32 | 13.58 | le |\n| 22.46 | 13.70 | meilleur |\n| 22.76 | 13.96 | skill |\n| 23.08 | 14.24 | pour |\n| 23.26 | 14.40 | ça. |\n| 23.74 | 14.59 | Et |\n| 23.80 | 14.64 | il |\n| 23.90 | 14.72 | te |\n| 24.02 | 14.83 | donne |\n| 24.22 | 15.00 | juste |\n| 24.40 | 15.10 | pas |\n| 24.52 | 15.21 | une |\n| 24.68 | 15.35 | liste, |\n| 25.10 | 15.62 | il |\n| 25.22 | 15.73 | l'installe |\n| 25.74 | 16.18 | lui-même, |\n| 26.30 | 16.55 | le |\n| 26.46 | 16.69 | bon |\n| 26.66 | 16.86 | skill, |\n| 27.20 | 17.13 | à |\n| 27.32 | 17.21 | ta |\n| 27.46 | 17.33 | place. |\n| 37.28 | 17.58 | Et |\n| 37.38 | 17.66 | le |\n| 37.52 | 17.79 | plus |\n| 37.70 | 17.94 | fou, |\n| 37.90 | 18.09 | c'est |\n| 38.08 | 18.24 | que |\n| 38.50 | 18.41 | c'est |\n| 38.64 | 18.53 | le |\n| 38.78 | 18.65 | skill |\n| 39.14 | 18.96 | numéro |\n| 39.64 | 19.40 | un |\n| 39.78 | 19.52 | de |\n| 40.10 | 19.62 | tout |\n| 40.30 | 19.80 | le |\n| 40.46 | 19.94 | site |\n| 40.80 | 20.20 | avec |\n| 41.02 | 20.32 | presque |\n| 41.36 | 20.60 | quatre |\n| 41.64 | 20.85 | millions |\n| 41.94 | 21.11 | d'installations. |\n| 42.88 | 21.69 | Donc, |\n| 43.08 | 21.87 | si |\n| 43.22 | 21.99 | tu |\n| 43.34 | 22.09 | dois |\n| 43.46 | 22.20 | installer |\n| 44.02 | 22.63 | qu'un |\n| 44.38 | 22.88 | seul |\n| 44.64 | 23.11 | skill, |\n| 45.26 | 23.41 | installe |\n| 45.66 | 23.76 | celui-ci. |\n| 46.32 | 24.19 | Mais |\n| 46.48 | 24.33 | surtout, |\n| 46.94 | 24.64 | installe |\n| 47.34 | 24.99 | jamais |\n| 47.58 | 25.20 | un |\n| 47.70 | 25.30 | skill |\n| 47.98 | 25.55 | sans |\n| 48.10 | 25.65 | l'avoir |\n| 48.38 | 25.87 | fait |\n| 48.56 | 26.02 | relire |\n| 48.88 | 26.30 | avec |\n| 49.16 | 26.52 | Claude |\n| 49.52 | 26.77 | Codex |\n| 49.92 | 27.09 | pour |\n| 50.08 | 27.23 | vérifier |\n| 50.42 | 27.52 | que |\n| 50.50 | 27.59 | c'est |\n| 50.66 | 27.73 | safe. |\n| 51.24 | 27.95 | Y |\n| 51.32 | 28.02 | a |\n| 51.42 | 28.10 | plein |\n| 51.60 | 28.26 | de |\n| 51.72 | 28.36 | hackers. |\n| 52.44 | 28.79 | Tu |\n| 52.54 | 28.88 | le |\n| 52.62 | 28.95 | veux |\n| 53.02 | 29.09 | ? |\n| 53.06 | 29.13 | Commande |\n| 53.46 | 29.44 | Finder |\n| 53.96 | 29.88 | et |\n| 54.04 | 29.95 | je |\n| 54.12 | 30.02 | t'envoie |\n| 54.36 | 30.23 | le |\n| 54.46 | 30.31 | skill |\n| 55.24 | 30.59 | du |\n| 55.34 | 30.67 | moment |\n| 55.58 | 30.88 | que |\n| 55.68 | 30.97 | tu |\n| 55.78 | 31.05 | me |\n| 55.94 | 31.19 | suis. |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4050,"content_sha256":"7df6dbbdeffa44dc2a812424dd94603306e9e98c013422400671abebb9b52838"},{"filename":"examples/insta-skills/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 35.01s (source 66.239s, 44 keeps)\n\n| src | short | word |\n|---|---|---|\n| 3.90 | 0.03 | Claude |\n| 4.22 | 0.31 | vient |\n| 4.40 | 0.46 | de |\n| 4.58 | 0.62 | tuer |\n| 4.92 | 0.86 | Instagram. |\n| 5.62 | 1.41 | Tu |\n| 5.74 | 1.51 | peux |\n| 5.88 | 1.64 | maintenant |\n| 6.12 | 1.84 | transformer |\n| 6.64 | 2.27 | Claude |\n| 7.04 | 2.59 | en |\n| 7.24 | 2.76 | community |\n| 7.78 | 3.23 | manager |\n| 8.32 | 3.67 | pour |\n| 8.52 | 3.84 | ton |\n| 8.72 | 4.02 | compte |\n| 8.98 | 4.24 | Instagram. |\n| 9.86 | 4.75 | Ça |\n| 9.98 | 4.85 | s'appelle |\n| 10.44 | 5.22 | Instaskill |\n| 11.20 | 5.83 | et |\n| 11.38 | 5.95 | c'est |\n| 11.60 | 6.12 | complètement |\n| 12.10 | 6.55 | gratuit. |\n| 12.84 | 6.93 | Un |\n| 12.98 | 7.03 | skill |\n| 13.44 | 7.40 | écrit |\n| 13.82 | 7.73 | tes |\n| 13.98 | 7.87 | reels |\n| 14.28 | 8.13 | avec |\n| 14.60 | 8.41 | les |\n| 14.78 | 8.56 | accroches |\n| 15.20 | 8.93 | qui |\n| 15.40 | 9.07 | font |\n| 15.52 | 9.18 | le |\n| 15.64 | 9.28 | plus |\n| 15.84 | 9.46 | de |\n| 15.94 | 9.54 | vues. |\n| 16.48 | 9.72 | Un |\n| 16.64 | 9.86 | autre |\n| 17.06 | 10.08 | monte |\n| 17.36 | 10.31 | tes |\n| 17.52 | 10.45 | rushs |\n| 17.84 | 10.73 | dans |\n| 18.00 | 10.87 | ton |\n| 18.16 | 11.01 | style |\n| 18.84 | 11.31 | et |\n| 18.96 | 11.39 | un |\n| 19.10 | 11.51 | autre |\n| 19.44 | 11.77 | envoie |\n| 19.76 | 12.05 | ton |\n| 19.92 | 12.19 | lien |\n| 20.10 | 12.35 | en |\n| 20.24 | 12.47 | DM |\n| 21.40 | 12.84 | à |\n| 21.52 | 12.95 | chaque |\n| 21.78 | 13.17 | commentaire |\n| 22.34 | 13.66 | de |\n| 22.46 | 13.76 | manière |\n| 22.76 | 14.02 | automatique. |\n| 23.68 | 14.56 | Mais |\n| 23.80 | 14.67 | le |\n| 23.94 | 14.79 | truc |\n| 24.16 | 14.98 | le |\n| 24.28 | 15.08 | plus |\n| 24.48 | 15.26 | fou, |\n| 24.74 | 15.43 | c'est |\n| 24.86 | 15.53 | le |\n| 25.00 | 15.65 | skill |\n| 25.46 | 16.05 | viralité. |\n| 26.40 | 16.58 | Il |\n| 26.56 | 16.72 | récupère |\n| 27.16 | 17.19 | tes |\n| 27.36 | 17.33 | vraies |\n| 27.70 | 17.57 | stats |\n| 28.10 | 17.88 | et |\n| 28.20 | 17.97 | les |\n| 28.40 | 18.14 | compare |\n| 28.88 | 18.53 | au |\n| 29.04 | 18.64 | compte |\n| 29.26 | 18.84 | de |\n| 29.42 | 18.97 | ta |\n| 29.58 | 19.11 | niche |\n| 29.92 | 19.41 | qui |\n| 30.10 | 19.57 | explose. |\n| 39.14 | 20.21 | Mon |\n| 39.28 | 20.33 | dernier |\n| 39.60 | 20.61 | reel |\n| 39.82 | 20.80 | a |\n| 39.94 | 20.90 | plafonné |\n| 40.52 | 21.38 | à |\n| 40.74 | 21.51 | deux |\n| 40.88 | 21.64 | mille |\n| 41.08 | 21.81 | vues |\n| 41.30 | 22.00 | et |\n| 41.38 | 22.07 | il |\n| 41.50 | 22.17 | m'a |\n| 41.64 | 22.30 | montré |\n| 42.00 | 22.61 | que |\n| 42.12 | 22.71 | les |\n| 42.26 | 22.84 | gens |\n| 42.48 | 23.03 | partaient |\n| 42.78 | 23.29 | à |\n| 42.88 | 23.37 | la |\n| 43.08 | 23.49 | dixième |\n| 43.50 | 23.86 | seconde. |\n| 44.16 | 24.25 | Il |\n| 44.26 | 24.34 | a |\n| 44.38 | 24.45 | trouvé |\n| 44.76 | 24.78 | pourquoi |\n| 45.24 | 25.19 | le |\n| 45.40 | 25.33 | reel |\n| 45.62 | 25.52 | concurrent |\n| 46.16 | 25.99 | avait |\n| 46.38 | 26.19 | fait |\n| 46.60 | 26.35 | trente |\n| 46.86 | 26.57 | fois |\n| 47.06 | 26.75 | plus |\n| 47.52 | 27.03 | et |\n| 47.62 | 27.12 | il |\n| 47.72 | 27.21 | a |\n| 47.84 | 27.31 | réécrit |\n| 48.24 | 27.66 | mon |\n| 48.38 | 27.78 | script. |\n| 52.22 | 28.07 | Commente |\n| 52.68 | 28.39 | « |\n| 52.74 | 28.44 | Insta |\n| 53.24 | 28.79 | » |\n| 53.28 | 28.82 | et |\n| 53.40 | 28.93 | je |\n| 53.48 | 29.00 | t'envoie |\n| 53.82 | 29.29 | le |\n| 53.96 | 29.41 | guide |\n| 54.22 | 29.61 | gratuit |\n| 54.80 | 30.12 | en |\n| 54.94 | 30.24 | DM |\n| 55.30 | 30.55 | à |\n| 55.48 | 30.62 | condition |\n| 55.96 | 31.04 | d'être |\n| 56.22 | 31.26 | abonné. |\n| 57.14 | 31.57 | Tu |\n| 57.24 | 31.66 | vas |\n| 57.96 | 31.79 | avoir |\n| 58.20 | 32.00 | accès |\n| 58.54 | 32.30 | directement |\n| 59.20 | 32.87 | aux |\n| 59.36 | 33.01 | skills |\n| 59.84 | 33.43 | afin |\n| 60.04 | 33.60 | de |\n| 60.12 | 33.67 | pouvoir |\n| 60.46 | 33.97 | les |\n| 60.64 | 34.12 | installer |\n| 61.04 | 34.47 | sur |\n| 61.20 | 34.61 | ton |\n| 61.38 | 34.77 | Claude |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4111,"content_sha256":"8e979cf25d43f7f368ca07fba938b13c9e986a23deabb883cd4298623ce7b360"},{"filename":"examples/kitchen-composants-premium/source/link-card.html","content":"\u003c!doctype html>\n\u003chtml>\u003chead>\u003cmeta charset=\"utf-8\">\n\u003c!-- YouTube Short end (short-kitchen-composants-premium, variants/short): no comment CTA on YouTube. On « Le lien est juste\n ici. » a paper card springs in at the top (Kitchen's real icon, the black disc of dqnamo.com, + « Kitchen, par dqnamo »), τ .35 the blue link chip drops under it, held to the end.\n style papier: Bricolage Grotesque 800, paper #FCFBF8, blue #1F5BFF, deep soft shadows. Alpha 1080x1920, y 150-470.\n window.setT(t): every state is a pure function of t. -->\n\u003cstyle>\n html, body { margin: 0; background: transparent; }\n body { width: 1080px; height: 1920px; overflow: hidden; -webkit-font-smoothing: antialiased; font-family: \"Bricolage Grotesque\"; }\n .c { position: absolute; left: 540px; width: max-content; white-space: nowrap; display: flex; align-items: center; opacity: 0; }\n #card { top: 160px; gap: 22px; padding: 18px 34px 18px 18px; border-radius: 40px; background: #FCFBF8; transform-origin: 50% 50%;\n box-shadow: 0 30px 56px -24px rgba(40,28,10,.46), 0 12px 22px -10px rgba(40,28,10,.24), 0 1px 3px rgba(40,28,10,.10), 0 0 0 1.5px rgba(40,28,10,.06), inset 0 2px 0 #FFFFFF; }\n #card img { width: 72px; height: 72px; border-radius: 18px; display: block; box-shadow: 0 4px 8px -2px rgba(40,28,10,.25); }\n #card .dot { width: 60px; height: 60px; margin: 6px; border-radius: 50%; background: #000; display: block; }\n #card span { font-weight: 800; font-size: 46px; letter-spacing: -.6px; line-height: 54px; color: #14161B; }\n #link { top: 310px; gap: 20px; padding: 24px 44px 24px 36px; border-radius: 46px; background: #1F5BFF; transform-origin: 50% 0%;\n box-shadow: 0 36px 64px -26px rgba(20,40,120,.55), 0 14px 26px -12px rgba(20,40,120,.35), inset 0 2px 0 rgba(255,255,255,.22); }\n #link span { font-weight: 800; font-size: 76px; letter-spacing: -1.6px; line-height: 86px; color: #FFFFFF; }\n #link b { font-weight: 800; color: #C9D8FF; }\n #link svg { width: 66px; height: 66px; display: block; }\n\u003c/style>\n\u003c/head>\u003cbody>\n\u003cdiv class=\"c\" id=\"card\">\u003ci class=\"dot\">\u003c/i>\u003cspan>Kitchen, par dqnamo\u003c/span>\u003c/div>\n\u003cdiv class=\"c\" id=\"link\">\u003csvg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"#C9D8FF\" stroke-width=\"2.4\" stroke-linecap=\"round\" stroke-linejoin=\"round\">\u003cpath d=\"M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7\"/>\u003cpath d=\"M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7\"/>\u003c/svg>\u003cspan>dqnamo.com\u003cb>/kitchen\u003c/b>\u003c/span>\u003c/div>\n\u003cscript>\nconst $ = id => document.getElementById(id);\n// critically damped spring from rest: never overshoots\nconst S = (x, w = 16) => x \u003c= 0 ? 0 : 1 - (1 + w * x) * Math.exp(-w * x);\nwindow.setT = function (t) {\n const a = $('card'), p = S(t, 30);\n a.style.opacity = t >= 0 ? Math.min(1, t / .06) : 0;\n a.style.transform = `translateX(-50%) translateY(${-24 * (1 - p)}px) scale(${.9 + .1 * p})`;\n const c = $('link'), d = t - .35;\n c.style.opacity = d >= 0 ? Math.min(1, d / .06) : 0;\n c.style.transform = `translateX(-50%) translateY(${-18 * (1 - S(d, 40))}px) scale(${.94 + .06 * S(d, 45)})`;\n};\nwindow.setT(0);\n\u003c/script>\u003c/body>\u003c/html>\n","content_type":"text/html; charset=utf-8","language":"markup","size":3084,"content_sha256":"2e26009bfb5640003b41e2de375ccad813f56a98442ff1df0b3bd4be9282eee7"},{"filename":"examples/kitchen-composants-premium/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 28.72s (source 46.336s, 34 keeps)\n\n| src | short | word |\n|---|---|---|\n| 6.62 | 0.02 | C'est |\n| 6.76 | 0.14 | sûrement |\n| 7.06 | 0.40 | la |\n| 7.14 | 0.47 | librairie |\n| 7.60 | 0.87 | UI |\n| 7.94 | 1.17 | la |\n| 8.06 | 1.27 | plus |\n| 8.28 | 1.43 | premium |\n| 8.76 | 1.82 | que |\n| 8.86 | 1.91 | j'ai |\n| 9.02 | 2.05 | jamais |\n| 9.28 | 2.27 | vue. |\n| 9.72 | 2.42 | Ça |\n| 9.84 | 2.53 | s'appelle |\n| 10.22 | 2.86 | Kitchen |\n| 10.80 | 3.30 | et |\n| 10.88 | 3.37 | le |\n| 11.00 | 3.48 | nom |\n| 11.16 | 3.62 | est |\n| 11.36 | 3.76 | parfait |\n| 11.84 | 4.12 | parce |\n| 12.02 | 4.28 | que |\n| 12.22 | 4.45 | chaque |\n| 12.46 | 4.63 | composant |\n| 13.00 | 5.07 | est |\n| 13.20 | 5.22 | cuisiné |\n| 13.68 | 5.63 | aux |\n| 13.84 | 5.77 | petits |\n| 14.12 | 6.02 | oignons. |\n| 14.78 | 6.33 | Il |\n| 14.86 | 6.40 | y |\n| 14.94 | 6.47 | a |\n| 14.98 | 6.50 | un |\n| 15.14 | 6.64 | lecteur |\n| 15.58 | 6.97 | cassette |\n| 19.12 | 7.41 | avec |\n| 19.42 | 7.67 | des |\n| 19.62 | 7.84 | bobines |\n| 20.02 | 8.19 | qui |\n| 20.20 | 8.35 | tournent |\n| 20.86 | 8.63 | et |\n| 20.96 | 8.72 | un |\n| 21.12 | 8.86 | bouton |\n| 21.60 | 9.19 | Supprimer |\n| 22.42 | 9.64 | où |\n| 22.52 | 9.73 | tu |\n| 22.62 | 9.81 | dois |\n| 22.80 | 9.97 | maintenir |\n| 23.22 | 10.34 | pour |\n| 23.38 | 10.45 | pouvoir |\n| 23.64 | 10.67 | confirmer. |\n| 24.40 | 11.13 | Mais |\n| 24.52 | 11.23 | mon |\n| 24.64 | 11.34 | préféré, |\n| 25.16 | 11.73 | c'est |\n| 25.28 | 11.84 | le |\n| 25.48 | 11.93 | checkout |\n| 25.96 | 12.34 | qui |\n| 26.12 | 12.45 | imprime |\n| 26.46 | 12.75 | un |\n| 26.60 | 12.87 | vrai |\n| 26.86 | 13.10 | ticket |\n| 27.16 | 13.36 | de |\n| 27.32 | 13.50 | caisse |\n| 27.88 | 13.98 | au |\n| 27.98 | 14.07 | moment |\n| 28.26 | 14.31 | où |\n| 28.36 | 14.40 | le |\n| 28.52 | 14.51 | paiement |\n| 28.92 | 14.86 | passe. |\n| 29.54 | 15.16 | Et |\n| 29.70 | 15.30 | tiens-toi |\n| 30.18 | 15.72 | prêt |\n| 30.46 | 15.96 | parce |\n| 30.64 | 16.12 | que |\n| 30.78 | 16.24 | c'est |\n| 31.04 | 16.47 | ultra |\n| 31.46 | 16.84 | simple |\n| 31.76 | 17.10 | à |\n| 31.80 | 17.13 | installer. |\n| 32.56 | 17.53 | Le |\n| 32.72 | 17.67 | code |\n| 32.92 | 17.84 | est |\n| 33.06 | 17.96 | sur |\n| 33.30 | 18.17 | chaque |\n| 33.54 | 18.38 | page. |\n| 33.94 | 18.73 | Tu |\n| 34.04 | 18.82 | le |\n| 34.14 | 18.90 | copies |\n| 34.40 | 19.13 | dans |\n| 34.56 | 19.27 | ton |\n| 34.70 | 19.39 | projet |\n| 35.12 | 19.76 | ou |\n| 35.22 | 19.84 | tu |\n| 35.40 | 20.00 | files |\n| 35.60 | 20.17 | directement |\n| 36.14 | 20.64 | ça |\n| 36.42 | 20.89 | à |\n| 36.62 | 21.03 | Cloud |\n| 36.86 | 21.24 | Code. |\n| 37.42 | 21.47 | Franchement, |\n| 37.94 | 21.86 | c'est |\n| 38.14 | 22.01 | beaucoup |\n| 38.44 | 22.27 | trop |\n| 38.68 | 22.48 | facile. |\n| 39.38 | 22.85 | Commande |\n| 39.76 | 23.18 | Kitchen |\n| 40.24 | 23.57 | et |\n| 40.30 | 23.62 | je |\n| 40.38 | 23.69 | t'envoie |\n| 40.64 | 23.92 | le |\n| 40.74 | 24.01 | lien |\n| 40.88 | 24.13 | de |\n| 40.94 | 24.18 | la |\n| 41.02 | 24.25 | librairie |\n| 41.50 | 24.64 | complète |\n| 42.20 | 25.22 | du |\n| 42.30 | 25.30 | moment |\n| 42.48 | 25.46 | que |\n| 42.56 | 25.53 | tu |\n| 42.66 | 25.62 | me |\n| 42.76 | 25.70 | suis |\n| 42.94 | 25.86 | sur |\n| 43.08 | 25.98 | Instagram. |\n| 43.58 | 26.42 | Donc |\n| 43.74 | 26.56 | commence |\n| 44.02 | 26.80 | par |\n| 44.16 | 26.92 | me |\n| 44.30 | 27.04 | suivre |\n| 44.64 | 27.34 | immédiatement |\n| 45.16 | 27.74 | et |\n| 45.24 | 27.80 | ensuite |\n| 45.54 | 28.04 | commande |\n| 45.82 | 28.28 | Kitchen. |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3589,"content_sha256":"e16479567842ced9d92a7ad2ba7d9cc9cd76f37ac70b81f498a75e782d68552b"},{"filename":"examples/README.md","content":"# Examples: real shorts made with this engine\n\nSix shorts that were edited, rendered and posted with `engine/short.mjs`. Each folder holds what made it:\n\n- `source/spec.js`: the whole edit. Its top comment plans the beats; every time is a SOURCE second of the take.\n- `source/*.html`: the motion components (`window.setT(t)`), captured into clips with `short.mjs capture`.\n- `words.md`: the transcript with source and short times, so you can read each `at` against the word it lands on.\n- `stills/contact.jpg`: the contact sheet of the final short, and `stills/\u003ccomponent>-sheet.jpg` per component. Open them: this is the finish to reach.\n\nThe takes, logos, images and captured clips are not included (the spec names them under `assets/`). To learn from an example, read its spec next to its `words.md` and its contact sheet; to reuse a component, copy its HTML into your project's `source/`, change the texts, colors and times, and capture it.\n\n| example | what to take from it | look at |\n|---|---|---|\n| [skills-montage](skills-montage/) | The smallest complete short (11 s, 4 beats): a `term` that types and fails, a `counter` struck on the word, a `tree` line by line, the payoff with the real logo and a `check`. Start here. | `source/spec.js` |\n| [claude-5x-value](claude-5x-value/) | Proof on a real chart: a `tweet`, then a `screen` with zoom keys and rings on the numbers read aloud, a big native `counter` under it for mobile, `bars`, then two funny objects (`ostrich`, `ferrari` vs `starship`). Engine-native only, no HTML. | `stills/contact.jpg` |\n| [thinking-orbs](thinking-orbs/) | Hard cuts between full camera (seam 0), split (1160) and paper alone (1920), and every beat an HTML component: animated orbs, a repo card, a grid, chips, a 60 fps meter, the Instagram and TikTok comment CTAs. The reference for `overlay` scenes and `media` with `alpha: true`. | `source/*.html`, `stills/*-sheet.jpg` |\n| [find-skills](find-skills/) | A remake of a viral reel, same beats: real-looking UI built in HTML (a skills marketplace page, a leaderboard, a terminal install, a prompt demo, a security scan), composite cards that move as one, dry punch-ins on the link phrases, `cut` for every retake. | `source/spec.js` top comment |\n| [insta-skills](insta-skills/) | A story told inside one app: an Instagram DM thread, a competitor's insights, a fetch command in a terminal, a GitHub repo card, a staff badge, then the comment CTA with the word typed in the real comment sheet. | `source/ig-cta.html` |\n| [kitchen-composants-premium](kitchen-composants-premium/) | Real captures of a UI library on the paper, a hook already moving on frame 0, a B-roll moment, a link card, and the CTA full camera with the keyword typed in the Reels comments. | `source/link-card.html` |\n\nThe usernames in the CTA components are placeholders (`toncompte`): put the creator's own handle, avatar and keyword.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2895,"content_sha256":"a8f72a4b09cae1070da0d3940401cdc04222f81b2dfc18790080537799acd846"},{"filename":"examples/skills-montage/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 11.48s (source 14.612s, 11 keeps)\n\n| src | short | word |\n|---|---|---|\n| 0.04 | 0.04 | J'ai |\n| 0.56 | 0.49 | testé |\n| 1.37 | 1.10 | tous |\n| 2.00 | 1.51 | les |\n| 2.27 | 1.71 | clics |\n| 3.16 | 2.37 | cloud |\n| 3.40 | 2.58 | montées |\n| 3.70 | 2.84 | vidéo |\n| 4.00 | 3.10 | d'internet |\n| 4.52 | 3.55 | et |\n| 4.56 | 3.59 | la |\n| 4.64 | 3.66 | plupart |\n| 4.92 | 3.90 | ne |\n| 5.08 | 4.04 | marchent |\n| 5.26 | 4.20 | même |\n| 5.46 | 4.37 | pas. |\n| 6.16 | 4.63 | Mais |\n| 6.16 | 4.63 | ils |\n| 6.18 | 4.65 | n'hésitent |\n| 6.58 | 5.00 | pas |\n| 6.60 | 5.01 | à |\n| 6.62 | 5.03 | te |\n| 6.71 | 5.11 | les |\n| 6.85 | 5.23 | vendre |\n| 7.15 | 5.49 | et |\n| 7.22 | 5.55 | ça |\n| 7.31 | 5.63 | m'a |\n| 7.53 | 5.82 | vraiment |\n| 7.80 | 6.06 | énervé. |\n| 8.52 | 6.60 | Du |\n| 8.56 | 6.63 | coup, |\n| 8.64 | 6.70 | j'ai |\n| 8.96 | 6.98 | fait |\n| 8.98 | 7.00 | tout |\n| 9.15 | 7.14 | ce |\n| 9.20 | 7.19 | qu'un |\n| 9.40 | 7.36 | dev |\n| 9.52 | 7.47 | aurait |\n| 9.89 | 7.79 | fait |\n| 9.92 | 7.81 | et |\n| 10.00 | 7.88 | j'ai |\n| 10.15 | 8.01 | construit |\n| 10.52 | 8.34 | le |\n| 10.60 | 8.41 | mien. |\n| 11.36 | 8.75 | Cette vidéo |\n| 11.40 | 8.78 | et |\n| 11.52 | 8.89 | tous |\n| 11.76 | 9.10 | mes |\n| 11.85 | 9.17 | shorts |\n| 12.15 | 9.44 | sont |\n| 12.35 | 9.61 | déjà |\n| 12.69 | 9.82 | montés |\n| 12.85 | 9.96 | avec, |\n| 13.32 | 10.37 | donc |\n| 13.35 | 10.39 | je |\n| 13.45 | 10.48 | sais |\n| 13.65 | 10.65 | que |\n| 13.80 | 10.78 | ça |\n| 13.93 | 10.90 | marche. |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":1600,"content_sha256":"5531608fb6af62b25ba451e615581cdd18082aa8b5c751704582530a822fdb7a"},{"filename":"examples/thinking-orbs/words.md","content":"# Words\n\nSource time (what spec.js uses) -> short time. Regenerated by `short.mjs prepare`.\n\nShort duration: 25.80s (source 43.881s, 34 keeps)\n\n| src | short | word |\n|---|---|---|\n| 3.52 | 0.02 | Ces |\n| 3.76 | 0.23 | orbes |\n| 4.06 | 0.49 | sont |\n| 4.34 | 0.70 | partout |\n| 4.76 | 1.07 | sur |\n| 4.92 | 1.21 | Internet |\n| 5.40 | 1.62 | et |\n| 5.54 | 1.75 | quelqu'un |\n| 5.88 | 2.04 | vient |\n| 6.06 | 2.20 | de |\n| 6.12 | 2.25 | les |\n| 6.30 | 2.41 | passer |\n| 6.62 | 2.68 | en |\n| 6.76 | 2.81 | open |\n| 7.04 | 3.05 | source. |\n| 7.68 | 3.32 | Ça |\n| 7.80 | 3.42 | s'appelle |\n| 8.30 | 3.74 | Thinking |\n| 8.88 | 4.16 | Orb |\n| 9.24 | 4.38 | et |\n| 9.38 | 4.50 | c'est |\n| 9.60 | 4.70 | toute |\n| 9.82 | 4.89 | une |\n| 9.94 | 4.99 | librairie |\n| 10.48 | 5.40 | d'orbes, |\n| 11.24 | 5.72 | une |\n| 11.50 | 5.94 | pour |\n| 11.72 | 6.13 | chaque |\n| 11.96 | 6.34 | état |\n| 12.18 | 6.53 | de |\n| 12.26 | 6.57 | ton |\n| 12.46 | 6.75 | app |\n| 12.90 | 6.93 | prêt |\n| 13.10 | 7.10 | à |\n| 13.24 | 7.22 | poser |\n| 13.56 | 7.50 | dans |\n| 13.74 | 7.66 | ton |\n| 13.94 | 7.83 | site |\n| 14.18 | 8.04 | ou |\n| 14.28 | 8.13 | ton |\n| 14.46 | 8.28 | produit. |\n| 15.20 | 8.64 | Dès |\n| 15.32 | 8.74 | que |\n| 15.40 | 8.81 | ton |\n| 15.58 | 8.97 | interface |\n| 16.14 | 9.46 | fait |\n| 16.30 | 9.59 | attendre |\n| 16.68 | 9.93 | l'utilisateur, |\n| 17.74 | 10.61 | tu |\n| 17.88 | 10.74 | remplaces |\n| 18.34 | 11.11 | ton |\n| 18.56 | 11.30 | spinner |\n| 19.32 | 11.84 | ennuyeux |\n| 20.10 | 12.32 | par |\n| 20.28 | 12.47 | une |\n| 20.52 | 12.68 | orbe |\n| 20.92 | 12.95 | et |\n| 21.00 | 13.01 | elle |\n| 21.24 | 13.16 | se |\n| 21.48 | 13.35 | fond |\n| 21.66 | 13.50 | dans |\n| 21.84 | 13.66 | n'importe |\n| 22.24 | 13.98 | quel |\n| 22.44 | 14.15 | design |\n| 23.02 | 14.65 | sans |\n| 23.22 | 14.83 | faire |\n| 23.42 | 14.97 | tache. |\n| 26.90 | 15.28 | Et |\n| 27.04 | 15.40 | rien |\n| 27.30 | 15.62 | là-dedans |\n| 27.72 | 15.99 | ne |\n| 27.84 | 16.09 | ralentit |\n| 28.26 | 16.46 | ta |\n| 28.42 | 16.57 | page. |\n| 28.98 | 16.85 | Elle |\n| 29.12 | 16.97 | respecte |\n| 29.56 | 17.36 | le |\n| 29.70 | 17.48 | mode |\n| 30.56 | 17.76 | mouvement |\n| 31.16 | 18.25 | réduit |\n| 31.48 | 18.53 | de |\n| 31.62 | 18.66 | base |\n| 32.36 | 18.95 | et |\n| 32.66 | 19.09 | tout |\n| 32.86 | 19.27 | est |\n| 33.02 | 19.41 | sous |\n| 33.16 | 19.53 | licence |\n| 33.70 | 19.94 | MIT. |\n| 34.88 | 20.30 | Donc, |\n| 35.08 | 20.48 | tu |\n| 35.22 | 20.60 | peux |\n| 35.32 | 20.68 | les |\n| 35.44 | 20.79 | mettre |\n| 36.40 | 21.10 | dans |\n| 36.56 | 21.24 | un |\n| 36.70 | 21.36 | produit |\n| 37.04 | 21.66 | payant |\n| 37.54 | 22.06 | sans |\n| 38.42 | 22.28 | rien |\n| 38.58 | 22.42 | demander |\n| 38.88 | 22.68 | à |\n| 38.98 | 22.77 | personne. |\n| 39.86 | 23.18 | Commande |\n| 40.34 | 23.51 | Orb |\n| 40.64 | 23.77 | et |\n| 40.72 | 23.84 | je |\n| 40.80 | 23.91 | t'envoie |\n| 41.04 | 24.12 | le |\n| 41.14 | 24.21 | repo |\n| 41.54 | 24.56 | du |\n| 41.62 | 24.63 | moment |\n| 41.82 | 24.80 | que |\n| 41.88 | 24.85 | tu |\n| 41.98 | 24.94 | me |\n| 42.10 | 25.04 | suis |\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3056,"content_sha256":"27592b8a656fe3e6e69f7ac532ebda214962e3acc2cf632b9fc77b8183812db8"},{"filename":"references/design-system.md","content":"# Design system: paper, one blue, Apple-calm motion\n\n## Palette (engine/lib/kit.js `C`)\n\n| token | hex | use |\n|---|---|---|\n| paper | #F1EEE6 | stage background (grain + slow light drift) |\n| sheet | #FCFBF8 | cards, chips, caption outline |\n| ink | #14161B | text |\n| ink2 / ink3 | #5C5F68 / #A19F98 | secondary text, muted labels |\n| line / dim | #E3DED3 / #D8D3C7 | hairlines, ghost bars |\n| blue | #258EFF | THE accent (codelynx.dev/aibuilder primary) |\n| blue2 / tint | #1D6FD6 / #E6F0FD | pressed state, soft blue fills |\n\nUse one blue per moment. There are no gradients, no neon, and no second accent color.\n\n## Type\n\n- Inter (variable). Headlines 800 with negative tracking (about -3.5% of size). UI 600/700. Captions 84/800. Card labels 42/700. Nothing under 34 px (mobile).\n- JetBrains Mono only for code or prompts.\n- Interfaces use the Apple system font (the creator 2026-10-18, review v2 of short-skills-montage-testes: « pour toutes les interfaces ... la font par défaut d'Apple rendrait mieux »). Every UI drawn in a captured component (a window, its title bar, an app, a sales page, a dashboard, a stats screen, a chat, a phone screen) sets `font-family: \"SF Pro\", -apple-system, BlinkMacSystemFont, \"Segoe UI\", \"Helvetica Neue\", sans-serif`, and its monospace text (a transcript, a terminal, code) `\"SF Mono\", ui-monospace, Menlo, Consolas, monospace`. Name `\"SF Pro\"` / `\"SF Mono\"` FIRST: `capture` injects them from `engine/short.html` (`@font-face` on `/System/Library/Fonts/SFNS.ttf` and `SFNSMono.ttf`); headless Chrome ignores `-apple-system` and falls back to another face (review v3: the UI still came out in Bricolage and Menlo). Check a zoomed frame of the .mov, not the tiny sheet. Chrome text keeps the real browser weights: the address in a URL bar is regular (400), never semibold (review v4: « toujours dans l'url »). Since review v5 (« faut utiliser la font système dans toutes les UI et motion design »), the motion design texts of a component use it too: the big hook title over the boards, labels, encarts, tags, counters, the demo of a caption style inside a UI. Only the captions keep the font of their caption style. Audit a page before rendering with CDP `CSS.getPlatformFontsForNode` at several t: every text must report `.SF NS` (or `.SF NS Mono`); a text inserted by `setT` later in the clip is missed by a t=0 check. Optical size (review v6, « ce n'est pas la bonne fonte », after four rounds): SFNS.ttf is variable on `opsz` 17 to 96 and Chrome sets it to the px size, so UI text drawn at 34 to 42 px came out in the tight **Display** cut, which never looks like a real interface. Every UI text uses the **Text** cut with its natural spacing: `body, body * { font-variation-settings: \"opsz\" 17 !important; letter-spacing: 0 !important; }` at the top of the page's style; only big motion titles (60 px and up: the hook title, a hero word, a price tag) opt back into `font-variation-settings: normal` with their own negative tracking. Compare a zoomed frame with a native AppKit `NSFont.systemFont` render at 13 pt (Text cut), never at a big size.\n\n## Motion\n\n- Calm is the easing, never the content: springs that never overshoot, and on every beat an action (the creator 2026-10-21: no screenshot, no still illustration, no lone logo; a software idea is played in a recreated UI, the hook is a 2 or 3 act mini story). SKILL step 3 and the style's notes hold the bar.\n- `S(x, w)` is a critically damped spring: it never overshoots and settles in about 4.7/w s. `w = 9..16` suits UI, and `w = 24..30` suits captions and typing.\n- `track(t, keys, w)` retargets smoothly between keyframes.\n- Enter: rise 20 to 90 px plus a fade. Exit: fade plus a 30 to 50 px lift. Scale changes stay within 0.965 to 1.\n\n### Premium choreography (built into the engine, every spec gets it)\n\n- **Exits are shorter than entrances and end clean.** Every scene, board item, cutout, ring and arrow leaves with `leave(x)` (kit.js): a .26 s smoothstep to exactly 0, translating on an accelerating curve. Never a spring exit: `S(x, 16)` leaves a 5 to 17% ghost for .2 to .4 s that muddies the next beat.\n- **Beats are sequential, never an in-place crossfade.** A scene that starts within .35 s of the previous scene's exit on the same layer enters at `prev.to + .16` (`linkScenes` gate; `gate: false` opts out). Two board items in the same slot (within .6 s, |dx| \u003c 300, |dy| \u003c 90) hand off: the new one waits until the old is gone (`to + .24`; `slot: false` opts out). Two numbers never stack.\n- **No parked zeros.** A counter enters with its count (`countFrom - .12`), never sits on \"0 $\" while the voice has not said the number (`hold: true` keeps the authored entrance).\n- **Shared-element morphs between beats.** When the next beat shows the same object bigger (the tweet's chart becoming the zoomable chart window), the board item takes `morph: true` and grows out of the previous scene's `hand()` rect (`morphDur` .62, smootherstep) while the old chrome fades. Tweets expose their media block; add `hand(s, R)` to any scene that should hand something on.\n- **Hero, then place.** A logo or object introduced by the voice enters big and centered (`posKeys` + `scaleKeys` 1.3 to 1.7), then glides to its slot (`posW` 12 to 14 drives both) before the number or the next object needs the room. The glide must land before the next item enters.\n- **An idea the speaker develops is ONE tableau that stays and grows** (the creator 2026-10-16 on vendre-saas: « ça switch trop vite ; il développe une idée donc on peut afficher une animation qui reste et qui développe l'idée », then « les longues animations rendent beaucoup mieux »). Across the sentences of one idea (a marketplace, an example, a story), a single board carries it: a central object, then each sentence adds or moves one element (hero then place, travels between two places, a scale bump on its word, lines that tie them, a morph of the centre into the next state); nothing leaves unless the sentence drops it. Quick swaps (a new board every 1.5 to 2.5 s) only where the speech changes idea. Text is fine when clean (a counter with its unit, one straight label).\n- **Holds breathe.** A board held 3 s or more gets a slow .025 push-in (default; `push: 0` freezes it, a number sets it).\n- **Captions never sweep across the stage.** On a split to split move (960 to 1240) the caption fades out, waits while the card moves, and fades back in 14 px below its new line; split to full screen lerps between the two rest lines. The rise block blurs in and fades out in place, never grows, shrinks or slides; the rise line blurs in too (no slide).\n- **One look per role.** The same kind of beat moves the same way all along the short (each hero number enters at the same slot with the same rise), so the eye learns the grammar.\n- Forbidden: pulse, bounce, elastic, overshoot, glow, shake, \"kiss\" pops, spinning, confetti, emoji characters typed in text (icons are Noto images, below).\n- Motion design over emojis (the creator 2026-10-16, review v1 of short-claude-tue-copywriting: « pourquoi autant d'émoji ... on utilise généralement motion design > emojis de con »): never illustrate a beat with emojis (hero, card bullet, check, decoration). Each beat is real motion design: a captured HTML component (the real app UI, pictograms drawn in SVG/CSS and lifted with a shadow, a check that draws as a stroke), real logos, typography or a paper illustration. In any card, graphic and text share one vertical center. References: shorts/short-claude-efface-internet, shorts/short-teste-ton-business-dans-claude. The Noto set below is a last resort, only when the creator asks for it.\n- Icons (last resort, see above): the house set only, Google Noto Emoji in color (the creator 2026-10-15: \"garde Noto comme librairie par défaut partout\", after a side-by-side test with Fluent 3D and Phosphor on the Editing IA Pro VSL; it replaces the Phosphor rule of 2026-10-11). The bare emoji on transparent: `assets/icons/\u003cname>.png` (the same image sits in `glyph/\u003cname>.png` for older specs), copied into `\u003cproject>/assets/icons/`, an `img` item drawn with `shadow: false`, checks included: never the engine-drawn `check` item nor the line icons `k: 'icon'`, never a tile or a square around it (the creator called the tile around a check \"un carré dégueulasse\", 2026-10-11). Built by `assets/icons/build-icons.mjs` (Noto SVGs from the Iconify API, one request, kept in `svg/`): a missing icon is one line there (house name, Noto name from icon-sets.iconify.design/noto), never drawn by hand. Never Phosphor, Fluent 3D or Apple emoji. Money in euros = the 💶 banknote (`money`), never a ` short-editing — Skillopedia . Real logos keep `paper.logos: \"flat\"` (no soft halo, a hairline edge). No stamp badges as decoration: plain text with one blue emphasized word. A stamp only as an action that lands on its spoken word with a thud (REPLACED landing on an app in a hook, « Gratuit » on a price falling to 0). A style with its own illustration library (podcast: `styles/podcast/illustrations/library`) uses ONLY that library for objects and reactions, reused from short to short, never Noto (the creator 2026-10-16: « non faut utiliser des illustrations custom partout et les réutiliser si besoin »).\n- Clicks are a 5% press (`press()`), never a bounce.\n\n## Paper\n\n- Background: fibre grain plus a very slow light drift. It is subtle and should read as texture, not noise.\n- Sheets: layered soft shadow (pre-blurred 9-slice, no per-frame blur), a grain fill and a hairline edge. `elev` 0 to 2.\n- Depth and paper come from the profile: `paper.depth` scales every shadow (above 1: ambient shadow, contact line, sheets lit from above, inset bar tracks, raised logos and fills, a key light on the paper) and `paper.tooth` embosses the paper. At their defaults (1 and 0) the look is the original one.\n- Seam: a deckle-edge tear between paper and camera, with a soft shadow on the camera side.\n\n## Layout (1080x1920)\n\n- Paper stage on top, camera below, split at `seam`. Content scenes are authored for their seam, so keep them above it.\n- Camera: crop around the face. `eyes` keeps the eyes in the upper third of the camera area.\n- Captions follow the style's `captions` look, never a look picked by hand (e.g. `band`, one black #111 chip in Geist Mono 800 capitals, 58 px, 3 words max, emphasis in light blue #5EA8FF, straddling the seam; `rise`, the spoken word on a blue block that blurs in, only when a style or the creator asks for it; `captions.blur: false` makes rise crisp). In split they sit on the paper just above the camera card, full screen on the video. 3 words and 16 characters max, emphasis words in blue.\n- Cards never change size. A card keeps one width (800) for its whole life; keys only move it. Its label sits ABOVE the card (dot + text), never straddling the edge. No stacked sheets behind cards.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":10877,"content_sha256":"c82fab9f844ec69c32bb62019dec6b973f21f9093e6f24742f660936fcb606f3"},{"filename":"references/looks.md","content":"# Styles: the look of every edit\n\nEvery edit has the same layout: the **split-screen paper look** ([style.md](style.md)). A textured paper on top with one board at a time, the camera as a card under one seam, hard cuts between split, paper only and full camera. A style only changes how it looks and sounds: colors, fonts, captions, paper, grade, pace, music and sound, the creator's handle and CTA, their logos.\n\nA style is one folder in the creator's project:\n\n```text\nvideo-agent/\n├── style.json {\"default\": \"default\"}: the style every edit uses when none is named\n└── styles/\n ├── default/\n │ ├── style.json the keys (all in style-schema.md)\n │ └── assets/\n │ ├── logos/ real logos of the tools the creator talks about (notion.png, claude.png...)\n │ └── fonts/ an own font file, optional\n └── \u003cname>/ other styles (made by /steal or copied from default/)\n```\n\n`short.mjs init` snapshots the style into the project (`source/style.json`) and copies its `assets/` there, so a board logo is `assets/logos/notion.png`. `--style \u003cname>` picks another one. No style at all: the engine defaults, already finished.\n\n## The five tested looks\n\n[`assets/looks.json`](../assets/looks.json) holds five starting points, each a `style` patch to copy into `style.json` and adjust:\n\n| look | says | for |\n|---|---|---|\n| `default` | minimal: textured paper, the system font (San Francisco on a Mac, Segoe UI on Windows), the camera full width under one straight seam, captions in mono capitals (JetBrains Mono), white on a black rounded band sitting on the seam | anyone, nothing to choose |\n| `classique` | textured paper, sticker captions, blue accent | a more playful paper look |\n| `papier` | deep shadows, the camera as a framed photo, `rise` captions (the said word on an accent block), Bricolage Grotesque | tech, explainers, premium |\n| `pop` | off-white, black and a loud yellow, big `hard` captions with a hard shadow | podcast, opinion, energy |\n| `poster` | flat orange background, hard cuts, the key word huge in condensed capitals on the camera | fast, viral, very readable |\n\n## A style.json\n\n```json\n{\n \"version\": 1,\n \"name\": \"Default\",\n \"creator\": { \"name\": \"Léa\", \"handle\": \"@lea.ia\", \"lang\": \"fr\", \"niche\": \"IA pour les indépendants\" },\n \"colors\": { \"accent\": \"#258EFF\" },\n \"captions\": { \"style\": \"rise\", \"size\": 82 },\n \"display\": { \"font\": \"Bricolage Grotesque\", \"weight\": 800, \"text\": \"Bricolage Grotesque\", \"flat\": true },\n \"font\": { \"family\": \"Bricolage Grotesque\" },\n \"pace\": { \"speed\": 1.15 },\n \"sfx\": { \"accents\": 0 },\n \"notes\": [\"Comment word: GUIDE\", \"Never zoom on my face\"]\n}\n```\n\nOnly the keys that differ from the engine defaults. `notes` are rules: follow them on every edit (the comment word of the CTA lives there).\n\n## Checking a change\n\nEdit the file by hand, then look at it on a real short: `node \u003cskill>/engine/short.mjs stills \u003cproject> 1,4,8` on any edit (after `init` with that style), and read the JPGs. The accent must stay readable on the paper, white caption text readable on its block, logos crisp, nothing cut at the edges.\n\n## Logos\n\nEvery tool named on screen shows its real logo: the press kit, the App Store icon, the official repo or site. Save it as a PNG (512 px, transparent if possible) in `styles/\u003cname>/assets/logos/\u003ctool>.png`. A logo you can't find for sure: ask the creator for the file, never draw a look-alike.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":3494,"content_sha256":"825c10ee681b006cc9aa08b4ba623dde0e2fcaa14de495dea24f5b52b03b4d25"},{"filename":"references/music.md","content":"# Music for shorts\n\nEvery short gets one bed from the library in `assets/music/`. These are 8 calm tension beds, kept by ear out of 18 generated styles. They are generated in code, so there are no rights to clear and they cost nothing. Each runs about 2 min 30 and is mastered at -16 LUFS. `tracks.json` lists the id, name, bpm, desc and dur of each one. To listen to one: `open \u003cskill>/assets/music/\u003cid>.mp3`.\n\n```js\nmusic: { bed: '\u003cid>', offset: \u003cs> }, // resolves to \u003cskill>/assets/music/\u003cid>.mp3 on any machine\n```\n\n- `bed` picks a library track by id. `src` takes any other file instead (a track the creator has the rights to). If the creator profile sets `music.src`, that track wins over the library. `mute: true` = no music.\n- A style can ship its own bed through `music.src` (nico-slowdown: `boom-bap`, a drum-forward 90 BPM hip-hop beat generated by `node engine/music.mjs boom-bap`, style in `engine/lib/chill-styles.js`, kept out of this calm library on purpose).\n- Use the generated score (no `bed`, no `src`) only when the short needs its `music.breaks` drum drops or the `pause` tape stop, since a library bed does neither. Also use it when the creator asks for it.\n- Long-form (`format: 'youtube'`): `music: { chapters: [[at, '\u003cid>'], ...] }` (SOURCE s) plays one bed per chapter, with a 2 s crossfade centred on each `at`. Without it, one bed runs the whole video.\n- Paid ads keep the stock `tension-ad.mp3` (see scene-library `music`).\n- The beds stay calm under the voice on purpose. Busy AI-generated tracks were tested and rejected as too dynamic under a voice, so never swap in one of those.\n\n## Pick by what the viewer should feel at the hook\n\n| Track (`id`) | BPM | Energy | What it sounds like | Perfect for | Avoid for |\n|---|---|---|---|---|---|\n| Sonar (`sonar`) | 80 | 1, the calmest | Sonar pings in echo, deep drone, slow pulse | Serious or reflective takes: careers, \"is AI replacing devs\", a confession, a lesson learned. Also dense explanations and fast talkers, since it leaves the most room for the voice. **Rejected under a voice on 2026-10-21** (« la music background est très étrange »: its pings read as odd noises): never in a talking-head short | Hype, launches, any talking-head short |\n| Enquête (`enquete`) | 90 | 2 | Low drone, filtered pulse, heartbeat, muted piano | Revelations and behind the scenes: \"j'ai découvert\", what a company really pays, a leak, numbers nobody shows, story-led shorts. **Default when unsure** | Upbeat tutorials |\n| Thriller (`thriller`) | 84 | 3 | Staccato 3-3-2 ostinato, distant low impacts, dark piano | Drama and warnings: controversies, lawsuits, security holes, \"arrête de faire ça\", company vs company, a model that cheats | Good news, wins |\n| Filature (`filature`) | 92 | 3 | Muted pluck ostinato, prowling double bass, film-noir vibraphone | Hidden tricks and secret features, a competitor's quiet move, \"ils ne veulent pas que tu saches\", clever hacks. Slightly playful | Emotional or heavy topics. Its brushed hit every ~1.3 s sounds like a sound effect under motion design: use it low-passed at 2.4 kHz (project copy, `music.src`) |\n| Arpège sombre (`arpege`) | 96 | 3 | Synth arpeggio that opens slowly, pulsing bass, distant lead | AI news: a new model or tool, how something works, AI product demos. **Default for tech topics** | Personal stories |\n| Code noir (`code-noir`) | 98 | 4 | Muted minimal techno, soft kick, dub chords in echo, pumping bass | Building and coding: vibe coding, Claude Code sessions, workflows, screen recordings, setups, \"j'ai shippé X en 1 jour\" | Drama |\n| Compte à rebours (`compte-a-rebours`) | 100 | 4, rising | Ticking clock, pulsed bass that opens, rising pad, distant impacts | Time pressure and verdicts: launches and promos ending (\"il reste 24h\"), challenges (\"j'ai X heures pour...\"), races and benchmarks with a winner. It is the only bed that clearly builds, so land its `c` on the payoff. **Rejected under a voice on 2026-10-12** (short-claude-arena v2: the ticking runs the whole short, \"horrible, trop fort, matche pas\"): keep it for paper-only races without voice | Any talking-head short, calm explainers |\n| Néon (`neon`) | 104 | 5, the most energetic | Pumping 16th arpeggio, octave bass, distant lead, city at night | Hype and wins: launch announcements, revenue/MRR/results, a big release. Also paper-only motion videos, races and X posts, where the music carries everything | Serious or sad topics |\n\nFast pick:\n\n| The viewer should feel... | Bed |\n|---|---|\n| Curious about a secret | Enquête, or Filature if it is a trick |\n| Worried or outraged | Thriller |\n| Impressed by new tech | Arpège sombre |\n| Like building something | Code noir |\n| Time pressure (\"who wins?\") | Compte à rebours |\n| Excited | Néon |\n| Thoughtful | Enquête (never Sonar, rejected 2026-10-21) |\n\nTie-breakers:\n\n- **Voice density:** a fast, dense talker goes one step calmer (Enquête; Sonar is rejected under a voice). A sparse voice with lots of paper can take one step more (Code noir, Néon).\n- **Rotation:** never use the bed of the previous short again. Check the last few:\n ```bash\n ls -t \u003cshorts folder>/*/source/spec.js | head -5 | xargs grep -hoE \"bed: '[a-z-]+'|music/[a-z-]+\\.mp3\" # \u003cshorts folder> = folders.shorts of the profile\n ```\n- **Say which bed you picked and why** in one line when you hand the short over, so the creator can swap it.\n\n## Pick the moment (`offset`)\n\nEvery bed follows the same arc:\n\n1. `intro`, 4 sparse bars.\n2. `a`: the pulse or bass comes in.\n3. `b`: the fullest groove before the break.\n4. `brk`: drums and bass drop out (8 bars, 4 for arpege and sonar).\n5. `c`: everything, plus a high layer.\n6. `out`.\n\nTimes are seconds into the FILE:\n\n| id | a | b (what enters) | brk | c | out |\n|---|---|---|---|---|---|\n| enquete | 10.7 | 42.7 (heartbeat, keys) | 85.3 | 106.7 | 138.7 |\n| arpege | 10.0 | 40.0 (kick) | 80.0 | 90.0 | 130.0 |\n| filature | 10.4 | 41.7 (vibraphone, hats) | 83.5 | 104.3 | 135.7 |\n| compte-a-rebours | 9.6 | 38.4 (piano, impacts) | 76.8 | 96.0 | 134.4 |\n| sonar | 12.0 | 48.0 (arpeggio) | 84.0 | 96.0 | 132.0 |\n| code-noir | 9.8 | 39.2 (kick, hats) | 78.4 | 98.0 | 137.1 |\n| thriller | 11.4 | 45.7 (piano) | 80.0 | 102.9 | 137.1 |\n| neon | 9.2 | 36.9 (kick, hats) | 73.8 | 92.3 | 138.5 |\n\n`offset` is in FILE seconds. P and E below are SHORT seconds: the short-time column of `files/words.md`.\n\n- **Default:** `offset` = the `a` time. A short has no room for a 10 s intro, so the pulse should already be there on frame 1. `b` then lifts the energy about 30 s in, where most shorts need it.\n- **One big payoff** (reveal, verdict, number, CTA) at short time P: set `offset = c - P`. The break then opens a 10 to 20 s pocket of air just before P, and the full section lands on the payoff. Best with Compte à rebours, Néon and Code noir.\n- **A full-camera emotional line** at short time E: set `offset = brk - E`. The drums leave exactly when the creator talks to the lens.\n- **Never pass `out`:** keep `offset + short length` under that track's `out` time. Past it the bed thins out and loops back to its intro.\n- The audio step fades the bed in over .4 s and out over the last 1.2 s, and ducks it under the voice. Leave `mix` at its defaults (bed -28 LUFS, duck .5, master -14).\n\n## Regenerate or extend\n\n- **The masters** in `assets/music/` are the exact files that were picked by ear. If they disappear, run `node \u003cskill>/engine/music.mjs` (run it in the background). It renders `LIBRARY` (in `music.mjs`) at seed 11 back into `assets/music/`.\n - It gives the same notes and arrangement.\n - Noise textures (hats, wind, plucks) come out as a different take, because the masters predate the per-render noise seed.\n - Renders are reproducible since that seed: same id and seed give the same bytes.\n- **A new style** goes in `engine/lib/chill-styles.js`, in the section \"tension, in the vein of enquete and arpege\".\n 1. Render it to a scratch dir: `node music.mjs \u003cid> --out \u003cdir>`.\n 2. Let the creator listen.\n 3. Only after they keep it, add the id to `LIBRARY` and a row in both tables here.\n- **Styles left in the code but not kept:** these are not for shorts by default.\n - chill: `lofi-nuit`, `nappe`, `piano-feutre`, `dub`, `trip-hop`, `nostalgie`, `bali`, `jazz-feutre`\n - tension: `labo`, `nuit-blanche`\n - `node music.mjs --all --out \u003cdir>` renders all 18.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8380,"content_sha256":"1e4c99a671ff0c779a561f2ede2a18e0d28fb47090077b19e628ffdf6fe7fc68"},{"filename":"references/performance.md","content":"# Engine performance\n\nBenchmarked 2026-10-02 on an M4 Max (10 P + 4 E cores, 36 GB) with a 91.9 s 1080x1920 short (2757 frames), matted on every frame (the old popout; prepare now mattes only the cutout spans, so the matte rows are worst cases). Every step is niced to 15 by `short.mjs`, so it yields to anything the creator is doing.\n\n| Step | Before | Now | What changed |\n|---|---|---|---|\n| prepare: cam frames | 8.2 s | 6 s | software HEVC decode on all cores (`-threads 0`); VideoToolbox copy-back is slower here |\n| prepare: matte, cold | 743 s | 66 to 86 s | mask-only output, CPU resize, 3 parallel workers |\n| prepare: matte after a `cut` change | 743 s | 27 to 37 s | raw Vision masks cached by cam JPG content: only about 15% of frames are new |\n| render | 324 s with `--eco` (37 fps without) | 18 s (150+ fps) | WebCodecs H.264 inside Chrome, 2 parallel workers |\n| audio | 3 s | 3 s | |\n| final | 12 s | 5 s | one QC pass (video decode + loudness on audio only) |\n| **whole pipeline, cold** | **about 18 min** | **92 s** | |\n| typical iteration (spec change, no new matte) | about 6 min | about 30 s | |\n\n### 2026-10-21: render, audio and final, encoder-bound\n\nSame M4 Max, 5 real shorts of 30 to 37 s (1080x1920, 30 fps), A/B runs interleaved, medians, `--fresh`. The machine sat at a load of 13 to 40 (photoanalysisd), so absolute times are pessimistic and only the A/B differences count.\n\n| Measure | Engine of 2026-10-20 | Now | What changed |\n|---|---|---|---|\n| render, encode wall summed over the 5 shorts | 24.5 s | 16.0 s | 2 s work units with steals, cropped overlays and repeated-frame aliases, CDP Chrome, hidden canvas |\n| Chrome boot per worker | 0.9 s | 0.6 s | `chrome.mjs`: Chrome on its DevTools pipe, no Playwright |\n| audio | 0.6 to 0.7 s | 0.5 to 0.6 s | the bed decodes only the stretch it uses, no reverb pass when nothing is sent to it |\n| final | 1.6 s | 1.3 s | no decode (done per unit in render), decode, poster and mux in parallel otherwise |\n| **`all` (render + audio + final), 36 s short** | **6.4 s** | **4.5 s** | audio mixed beside the render in its own process |\n\nOn one 36 s short (1081 frames) the render alone takes 4.2 s, 255 fps (8.5x realtime). Each frame costs 2.9 ms of images, 0.6 ms of drawing and 2.1 ms of encode, and the two hardware encode engines are the limit. Quality against the previous engine on the same spec: 53.3 dB PSNR on average, 44.6 dB at the worst frame (encoder noise, invisible). Hiding the canvas changes no pixel (180/180 identical frame hashes). The overlay crop changes at most 1 level out of 255, on less than 0.82 % of the pixels.\n\n## Render: WebCodecs in the page\n\n- **Chunks and units.** The video is cut into 10 s cache chunks (300 frames, on absolute frame numbers). The chunks to encode are split into 2 s work units, and each unit opens on an IDR, so the keyframes are the same as with one encoder per chunk.\n- **Workers.** Each worker is its own headless Chrome (`chrome.mjs`; Playwright is only the fallback). It takes a contiguous run of units, and a worker that runs out steals half of the longest run left. With whole 10 s chunks, one worker used to wait on the other's last chunk.\n- **Page side.** `lib/encode.js` `installEncoder` gives `encodeStart(fps, rate)`, `encodeUnit(a, b, sink)` and `encodeEnd()`. They draw each frame on the hidden canvas (not composited, since nobody looks at it), wrap it in a `VideoFrame` and encode it with the hardware `VideoEncoder` (H.264 High, Annex B, `latencyMode: 'quality'`, at most 3 frames in the encode queue).\n- **Sink and decode check.** Bytes are POSTed in 2 MB batches to the local server (`/sink/\u003cunit>`). Each unit is decoded on the CPU as soon as it is in, while the hardware encoder works on the next ones. A chunk is written to `.cache/render/\u003cW>x\u003cH>@\u003cfps>/\u003chash>.h264` (`.part`, then renamed) once all its units decoded. The render then stamps the output's size and mtime in `.cache/render/qc.json`, and `final` skips its own full decode when the stamp matches (`decode ok (checked in render)`).\n- **Chunk cache.** The hash covers:\n - the engine code, the style, `timeline.json`, `frames.json`, the output settings, and every spec field that is not windowed;\n - the scenes, actors and B-roll that can draw in the chunk (from `from - pre - 2` to `to + 2`);\n - the layout and zoom keys from 6 s before the chunk, since the spring has settled by then;\n - the size and mtime of every image or video file those items name.\n\n A fix to one card re-encodes only its 1 to 3 chunks, and an unchanged render only concatenates (1 s). A `cut` change moves every frame after it, so most chunks re-encode. `--fresh` ignores the cache. A full-length render deletes the chunks it did not use, which keeps the cache about one video in size. Measured on a 65 s youtube test: 9 s cold, 6 s after one title change (3 of 7 chunks), 1 s unchanged. Cached plus re-encoded output against a fresh render: 56.8 dB PSNR. On a 9 min 10 s youtube edit (16,505 frames): 54 s cold at about 300 fps, 5 s unchanged (56/56 chunks cached). The stream there is 2.2 GB, so the join writes chunk by chunk (one Node buffer stops at 2 GB).\n- The segments start on IDR frames with in-band SPS/PPS, so they concatenate byte for byte. The ffmpeg mux starts before the encode and gets each chunk on its stdin, in order, as soon as it is there (cached or just encoded), so no joined `.h264` is written and most of the parsing overlaps the encode. It stream-copies into `\u003cout>.part` (renamed when done) with:\n - `setts=ts=N/(fps*TB)`: Chrome's SPS timing breaks the raw timestamps.\n - `h264_metadata` writing bt709 limited range.\n - `-video_track_timescale`.\n- **Media frames (prepare).** An alpha media that is only ever an overlay (`overlayOnly`) is cropped at extraction to the box where it is visible (`alphaBox`), so each frame loads and draws far fewer pixels. Identical consecutive frames (a still screen recording) get an alias in `frames.json` (`alias`): the page loads one image instead of every copy.\n- This removes the old per-frame cost: about 12.7 ms of JPEG encode, base64 over CDP, and ffmpeg decode plus re-encode.\n- Quality is better too. Against lossless `framePNG` references it scores 38 to 40 dB PSNR, against 35 to 36 dB for the old JPEG path.\n- Workers, full length:\n\n | Workers | 1 | 2 | 4 | 6 | 8 |\n |---|---|---|---|---|---|\n | Time | 28 s | 18 s | 17 s | 18 s | 20 s |\n\n Default is 2: 4 workers only save 1 s for double the load. Rechecked on 2026-10-21 with the current engine (render-only medians on 2 shorts): 3 workers are 10 % slower (3.61 s to 3.98 s, 4.02 s to 4.45 s), and a deeper encode queue (8 instead of 3) is 6 to 8 % slower. `latencyMode: 'realtime'` was slower too. The M4 Max has two hardware encode engines, and two workers keep both busy.\n- `--eco` adds background QoS (efficiency cores only). It is about 8x slower (149 s with 2 workers, 188 s with 1). Use it only when the user is doing something heavy at the same time.\n- `--jpeg` forces the old path (JPEG over CDP, then `h264_videotoolbox`). It is also the automatic fallback when `VideoEncoder` is missing.\n- `VideoEncoder` only exists in a secure context, which is why the page is served from `http://127.0.0.1`. It is undefined on `about:blank`.\n\n## Matte: Apple Vision, cached\n\n- `matte.swift` is compiled once with `swiftc -O` into `~/Library/Caches/short-editing/matte-\u003csource hash>`, and recompiled automatically when the source changes. The interpreted `swift matte.swift` ran at about the same speed per frame, but it paid startup on every call.\n- Cost per frame:\n\n | Step | Cost |\n |---|---|\n | Vision `.accurate` | 34 ms. Its 2016x1512 mask is the floor, about 30 fps per machine. |\n | Old Core Image upscale, blur and RGBA PNG write | 46 ms, more than Vision itself |\n | Now: vImage resize, vDSP EMA, 5x5 convolution, gray+alpha PNG through ImageIO | about 5 to 10 ms |\n\n- Output is a mask only, `frames/matte/%05d.png` (gray+alpha, 15 MB per 200 frames against 133 MB for RGBA cutouts). The runtime builds the cutout bitmap in `load()`: the mask is drawn with `copy`, then the cam JPG with `source-in` on an OffscreenCanvas. `cutURL` stays virtual, so scenes do not change.\n- Parallel workers: 1 gives 19 fps, 2 give 25, 3 give 28, and 6 give 28. The Neural Engine saturates, so the default is 3 (`--matte-workers n`). The contiguous slices are balanced by cost: a cached frame costs about 1, a Vision frame about 7. Each slice primes its EMA on the 8 frames before it, so the slices join without a seam.\n- The cache is `frames/.matte-cache`: raw Vision masks keyed by the cam JPG's md5.\n - It is keyed on the frame alone. At 1.15x speed, a cut shifts which source frames neighbour each other, so a multi-frame key hit only a third of frames, against 85% now.\n - The EMA and blur pass always reruns over the whole range. It is cheap.\n - The cache is pruned to the current cut.\n- `MATTE_QUALITY=balanced` halves Vision time (512x384 mask) with coarser hair edges. Keep `.accurate` for delivery.\n\n## Rules learned\n\n- Measure before optimizing. The hardware decoder, the obvious pick, was the slowest option for both cam extraction and the QC decode. The matte bottleneck was the Core Image write, not Vision.\n- Never decode the video twice. A loudness pass uses `-vn` or rides the decode check as a second output.\n- Background QoS (`taskpolicy -b`) costs 5x to 9x. Being niced is enough to stay out of the user's way.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":9454,"content_sha256":"cd14faa1b00f28b77813ec52ad0e4f733f7784fed6ae609e0667699cd011cfb3"},{"filename":"references/scene-library.md","content":"# Scene library\n\nEverything is authored in `source/spec.js` as `window.SPEC = {...}`, with all times in SOURCE seconds (from `files/words.md`).\n\n## Time-key convention (how the engine knows what to map)\n\n`mapSpec` walks the spec and converts SOURCE seconds to SHORT seconds for:\n\n- any number whose key is `at`, `from`, `to`, `until`, or ends in `At`, `From` or `To` (`sendAt`, `countFrom`, `workTo`...)\n- the FIRST element of each entry in any array whose key ends in `keys` (`keys`, `labelKeys`...)\n- `[a, b]` pairs in `hide` and in any key ending in `spans`\n- `'p+X'` strings anywhere a time goes: X seconds into `spec.pause` (the frozen narrator break)\n- `'o+X'` strings anywhere a time goes: X seconds into the end card (`spec.outro`)\n\nThese are NOT mapped: `offset`, `dur`, sizes and positions, and everything under `media`, `cam`, `cut` and `tighten`. `music.breaks` and `music.end` are mapped by the audio step.\n\nWhen you add a new time param, name it `somethingAt` / `somethingFrom` / `somethingTo`, or it will silently stay in source time. The reverse bites too: a non-time value named `to` (a target value, a percentage) gets mapped as a time. Call it `value`.\n\nA time that falls inside a removed silence snaps to the start of the next kept segment. Prefer word start times.\n\n## Style layer (creator profile)\n\n`short.mjs init` snapshots the creator profile (the nearest local `.short-editing/style.json` from the short's folder up, never `~/.short-editing`, or `engine/templates/style.json` without one) into `source/style.json`. At load the engine layers engine defaults \u003c `source/style.json` \u003c `spec.js` (`engine/lib/style.js`): the profile sets `colors` and `font` (theme), `paper.grain`, and fills `speed`, `tighten`, `captions`, `music` and `mix` key by key, so the spec only writes what differs for this short. A `cta` scene gets `name` from `creator.name` and `sub` / `button` / `done` from `creator.cta`, else the defaults of `creator.lang` (fr: Plus de vidéos, S'abonner, Abonné). No `source/style.json` (older projects): the raw engine defaults below, unchanged. Schema: `../short-editing-setup/references/style-schema.md`.\n\n## Top level\n\n| key | meaning |\n|---|---|\n| `title` | free text |\n| `format` | `'square'` renders 1080x1080 (1:1 for X), `'wide'` 1920x1080 (16:9, race videos), default 1080x1920. Most scenes centre on `W / 2`, but hand-placed x values assume 1080 wide: check stills on `wide`. Use seam `layout.keys: [[0, 1080]]` for paper only and keep every y under ~1000. |\n| `silent` | `true` + `duration: \u003cs>`: no Tella take, no voice, no camera, no captions. Times are plain seconds; music + SFX only. `prepare` just extracts `media`. |\n| `media` | `{id: {src, from, dur, width, fill, focus}}` video clips (or one image) for cards and full-screen B-roll. `src` accepts `~`. Frames are extracted once and cached at `width` px (default 960, use 780 for phone-format portrait exports). `fill: true` crops the clip to the frame's aspect first (default width = frame width), around `focus: [x, y]` (0 to 1, default centre): use it for every `broll` media so a landscape clip fills a 9:16 frame without bars. `alpha: true` keeps the clip's transparency (a png-in-mov or ProRes 4444 rendered on a transparent background) as RGBA PNG frames, for the `overlay` scene. |\n| `broll` | `[{media, from, to, offset, zoom: .05, focus: [x, y]}]` full-screen B-roll: the clip covers the whole frame (camera and paper) between `from` and `to`, with the voice and captions on top (captions take their over-video look there: white words with a soft shadow, never the straddle's ink half or the bare-paper ink). Quick fade and settle in, a slow push (`zoom`, 0 = still), quick fade out. `hard: true` (or the style's `cuts: 'hard'`) = a straight cut in and out. `offset` = seconds into the clip at `from` (start on the clip's `best_start`). See \"Full-screen B-roll\" below. |\n| `cam` | `{crop: [x, y, w, h] in source px, eyes: [x, y] inside the crop, splitZoom: 1.2, keep: [[x, y, w, h, r]]}`. The card and full screen center on the eyes, so moving `crop` alone changes nothing on screen: to push a cut-off object (a poster sliver) out of the card edge, move `eyes` a few px toward the other side and/or raise `splitZoom` (1.25). `keep`: rounded rects in cam-crop px that every cutout always keeps, for props in front of him that the person matte drops (his mic and boom arm would otherwise leave a paper-coloured hole in his chest). `r` is a number or `[tl, tr, br, bl]`. Measure on `frames/cam` vs `frames/matte` alpha. `wb: false` turns off the automatic white point (see `grade`). |\n| `speed` | playback speed of the whole short (voice atempo, camera and media retimed). `1.15` from the default profile (`pace.speed`), 1 with no style snapshot |\n| `tighten` | breath kept per boundary after cutting the quiet run: `{sentence: .08, comma: .05, word: .03, lead: .03, tail: .8}` from the default profile (`pace.tighten`); with no snapshot `{.10, .06, .03, .03, .9}` |\n| `cut` | `[[a, b]]` source ranges to drop (retakes, dead air) |\n| `hold` | `[[a, b]]` source ranges kept verbatim, silences included: a sung rhythm (\"tu, tu, tu\"), a beat left for an effect |\n| `bleep` | `[[a, b]]` source ranges censored: the voice is fully cut there and replaced by a 1 kHz bleep (about -18 LUFS), and those words never show in captions. Cover the whole word, its tail included: read the voice energy, not just the Scribe end time (`3.10, 3.68` for a word Scribe gave as 3.14 to 3.60). |\n| `thump` | `[[a, b, dB]]` source ranges where a mic plosive (\"p\", \"b\") thumps: the voice is high-passed at 240 Hz there (24 dB/oct, 12 ms crossfades) and moved by `dB` (default -3). For a \"bruitage trop fort\" with no SFX at that time: measure the voice 80-160 Hz band, the burst jumps 15 dB or more over its neighbours. Cover the burst only (about .2 s). |\n| `voices` | `[{at, src: 'assets/x.mp3', gain: 0}]` extra voice clips mixed into the voice bus at `at` WITHOUT freezing the video: an AI answering in the gaps the creator leaves on camera. They play at 1x, so `hold` each gap and size the clip to the gap's short length (gap / `speed`). Pair with a `convo` scene. Example: `shorts/short-opus-55-calin/` (`source/opus-voice.py` makes the clips + word offsets). |\n| `outro` | seconds of end card added after the last kept word (e.g. `3.2`): the voice pads with silence, the camera holds its last frame, the music keeps going and fades out by the end. Author its items with `'o+X'` times (`'o+0'` = the end card starts), usually a layout key `['o+0', 1920]` and a `board` `from: 'o+0'`; end the previous board with `to: 'o+0'`. Typical use: a podcast extract that ends on \"watch the full episode\" + the episode thumbnail as a framed `img` + its short link as a `label`. |\n| `pause` | `{at, voice: 'assets/x.mp3', words: 'assets/x.words.json', tail: .12, gain: 0}` the short STOPS at source `at` for the voice's length: frozen frame drains to grey, the voice (ElevenLabs TTS, word-timed via Scribe) plays, music tape-stops and restarts from bar 1 on the resume. Author times inside it as `'p+X'`. Pair with a `pause` scene. |\n| `inserts` | `[{at, voice: 'assets/x.wav', tail: .12}]` short voice lines dropped between two sentences at SOURCE `at` (a chapter line: « Piège numéro deux »): the short makes room for each one, the camera holds its frame, the music runs on (no grey drain, no tape stop, unlike `pause`). Author times inside insert K as `'iK+X'`; resume the edit at `'iK+\u003cits length>'` (the insert length is in `assets/timeline.json` `inserts`), never at the source `at`, which can fall in a cut and map before the insert. Typical card: layout `['iK+0', 1920]` + a `board` from `'iK+0'`, `captions.hide` over the same span (a caption line now stops where a hide span starts). Voice: ElevenLabs TTS of the creator's clone, x1.2. Example: `shorts/short-vibe-code-facture/`. |\n| `layout.keys` | `[[t, seam]]`: y of the paper/camera seam. `960` = half, `1240` = paper-heavy, `0` = full-bleed camera, `1920` = paper only. A key going to or from `0` (from or to a split) is the window transition: the camera lifts into a rounded card that opens to full screen over the still paper (dim .22, margins and corners bulge mid-move), or closes back into the panel. It starts AT the key time, so put the key on the word. |\n| `cutout` | `{from, to, scale: 1.15, x: 540, bottom: 1920}` the speaker cut out (no background) standing on the paper. Pair it with seam `1920` (paper only) for a hook: a card on top, the creator below. See \"Speaker cutout\" below. Ending on a split seam key hands off smoothly into the camera (see Hook). `{from, to, ride: true, scale: 1.3}`: head in front of the board (aiwithremy): over a split seam (~1000), the camera is bottom-aligned at `scale` (x zoom keys) and he is drawn unclipped, so his head pokes ~250 px up in front of the `stage` board above the seam; in and out on the frame. Put the board on `layer: 'stage'` (an 'over' scene would cover his head). |\n| `zoom.keys` | `[[t, z]]` camera punch-in, `1` to `1.15` max. `[t, z, 400]` = an instant punch-in (third value = spring stiffness), still 1.15 max and about one every 4 s (1.3+ every second was rejected as far too aggressive); `sfx.kit.punch` only fires on a zoom IN away from a cut. `zoom.drift: .008` (usually from the style): a slow push of 0.8 %/s inside each shot, restarting on every seam change and every punch (w ≥ 60), capped at 4 s |\n| `actors` | media cards (below) |\n| `scenes` | stage/over scenes (below) |\n| `captions` | `{replace: [['cent pour cent', '100%']], emph: ['Opus', ...], hide: [[a, b]], size: 84, maxWords: 3, maxChars: 16, paperY, style: 'sticker', weight: 800}`. `style`: one of the `capStyles` looks by name or number (21 = `clean`, the white pill of the card layout, base size 42; 22 = `rise`, 23 = `hard`, 24 = `stack`, 25 = `band`, 26 = `dim`) (profile `captions.style`). `dim` (speedy-devv): the phrase builds word by word on centered lines, spoken words in `color` (grey) at `dimWeight` (500), the current word in `hi` (white) at `weight`, an `emph` word keeps a full-radius dark pill (`pill`, default `#2B2B2B`) once said, each word blurs in; pair with `size` ~60, `yKeys: [[0, 300]]` on a dark flat paper. `rise` (DM Sans 900, the white paper style's default) and `hard` (Bricolage Grotesque 800, hard ink shadow) are block looks: the word being said sits on an accent block (`rise` blurs it in, blur to sharp with a 1.04 to 1 settle in about .15 s, then fades it out in place in .12 s when the next word starts; the line itself blurs in per chunk; `blur: false` (rise only, profile `captions.blur`) drops every blur and settle: the line snaps in and the block jumps word to word in .05 s cuts, for styles whose creator finds the blur hard on the eyes. `block: false` (rise only, profile `captions.block`) drops the accent block: each white word appears only when it is said and blurs in on its own (blur .16em to sharp), the line building up word by word, no background (the small chest captions of benkimball.ai; pair with `size` ~58, `font` 'Poppins' (SemiBold 600 only), `weight` 600, `lower: true`, `yKeys: [[0, 1600]]`). Never grow the block up from the bottom, shrink it or slide it: the creator rejected both). Block captions never straddle the seam: in split the line sits on the paper just above the camera card with ink words, full screen it sits on the video with white words, so keep stage items clear of the band just above the seam. `straddle: true` (rise only, profile `captions.straddle`) is the exception: in split the line's middle sits right on the camera card's top edge, ink words throughout, each word on a soft paper glow (no hard white outline: the creator found it dirty, and an outline on the video half only read as cut in two) so it reads on a light or dark background; move the speaker chips to the bottom of the card, out of the line's way. `straddleDy: 70` (with `straddle`): the line sits that many px below the camera's top edge, fully on the video, white words on a soft dark shadow instead of the paper halo (the creator 2026-10-14: shadows, never a white border); `shadow: 1.6` (rise) strengthens that shadow (alpha and blur) on a light background. `style: 'band'` (profile `captions.style`): the whole line on one flat block (`bg`, default ink), white words (`color`), `emph` words in `hi`, hard in and out; `bg: 'none'` drops the block (white words straight on the video with a soft shadow, `shadow: false` = none: the white Geist Pixel chest captions of aiwithremy). `tracking`: letter spacing in em of `size` (e.g. `-.08` tightens the monospaced Geist Pixel toward a proportional pixel face). `font`: caption family override (default: the style's own family, else `font.family`). `paperY`: caption y on full-paper frames (seam 1920), e.g. 860 to keep them off a hook cutout's face. `color: '#FFFFFF'` (rise only, profile `captions.color`): the words keep this color on every layout, never switching to ink on bare paper (white words on a dark flat paper). `lower: true` (profile `captions.lower`): every spoken word in lower case, `replace` values keep their case. `yKeys: [[t, y]]` force the caption y from `t` on (wins over every other rule), e.g. `[[103.72, 890]]` to sit them on a `camSplit` seam. `scaleKeys: [[t, k]]`: lines starting from `t` on are drawn `k` times bigger and hold `k` times fewer characters (the giant hook: `[[0, 2.1], [4.2, 1]]` = one or two huge words per line until 4.2), grown up from the bottom of the line so they never reach the camera card, capped to the frame width (format youtube: scaled lines are centred on the whole frame, across a split, the podcast clips cold open). `hook: {spans: [[a, b], ...], style: 'stack', ...}` (source time): two looks in one short, the base look (rise) everywhere and the `stack` pile (the creator x Nico: small words + one big key word) only inside the spans, drawn with the base captions overridden by the hook's own keys (size, font, maxWords, big, emph, yKeys...); keep the spans on the hook, the stat or promise that makes people stay, the re-hook and the CTA, about a third of the short (the creator: never the pile on the whole short) |\n| `music` | `{bpm: 112, start, breaks: [[a, b]] (drums drop), end, mute, drive, bed, src, offset}`. `bed: '\u003cid>'`: a library bed (`assets/music/\u003cid>.mp3`, pick it and its `offset` with `references/music.md`). `src`: any other file, e.g. the creator's own track. Both replace the generated bed (read from `offset` s, looped, ducked under the voice; `breaks` and the `pause` tape stop only apply to the generated bed). `drive: true` = more dynamic bed (four-on-the-floor, clap backbeat, 16th hats, pumping bass, a fill every 4 bars); pair with bpm 120 to 124 and `mix.music` about -25. `start`: the bed enters late (after an on-camera count-in or a sung part). `dropOn: 'punch' | 'cut'` (or both in an array) + `drops: [t]`: the bed cuts out for `dropLen` (.3 s) down to `dropDepth` (.06) under every punch-in / every hard cut / each listed time (hip-hop drop-outs, nico-slowdown). Stock track validated by the creator for ads and tension pieces: `bed: 'tension-ad'` (ElevenLabs, 64 s, its break at 45.75 to 48 s; reference: `shorts/short-tella-cmuqep`, offset .95, `mix.music` -25, `mix.sfx` -34) |\n| `layout.frame` | `true` or `{m: 40, gap: 26, bottom: 46, r: 40, border: 9}`: framed camera. In split the camera is a photo pinned on the paper (side margins `m`, `gap` under the seam, `bottom` margin, rounded corners `r`, a white paper `border` and the style's sheet shadow) instead of a full-width panel under the deckle edge. `layout.keys` work as usual: the window transition grows the photo to full screen (margins, corners and border melt to 0) and shrinks it back. Captions stay on the seam. `bleed: true`: the photo runs off the left, right and bottom edges (no margin, no border, square bottom corners), only its top edge keeps the rounded corners, the white border and the shadow (side and bottom margins look bad once platform UIs crop the frame). On in the white paper style. `border: 0` with `bleed`: no white border at all, a soft shadow cast up onto the paper above the camera instead (`shadow`: its height in px, default 40); the creator 2026-10-14 prefers shadows to white borders. |\n| `grade` | `true` = the default camera grade, fitted to the take: `prepare` measures the face (median of 5 frames, box under `cam.eyes`) and logs `grade: face luma N`. A dim room (face luma 80 or less) gets the full lift (brighter face and mids, solid blacks, saturation 0.84 so the wood and lamps stop glowing orange, a slight cool shift). Every normal take (100 or more) gets the natural look: no colour shift at all (cutting the red greyed the skin), saturation 0.96, the black point stays, a gentle mids lift that fades out as the take gets brighter (full at face luma 108 or less, none at 128). 80 to 100 blends the two. Both add a touch of clarity. Never contrasty or saturated, never washed out or grey skin (all rejected). Or your own ffmpeg filter chain as a string. Every grade also starts with an automatic white point: `prepare` samples the near-white pixels under `cam.eyes` (cap, shirt) and pulls a cyan, green or warm cast back to neutral (each channel moved by at most 7 %, nothing done under a 1.2 % cast), logged `grade: white point R,G,B -> neutral`; `cam.wb: false` turns it off. Baked into the camera frames at `prepare` (and so into the cutout matte); rerun `prepare` after changing it. Each extraction writes `stills/grade.jpg`: the same frame raw (left) and graded (right); read it once per short. On in the white paper style. `border: 0` with `bleed`: no white border at all, a soft shadow cast up onto the paper above the camera instead (`shadow`: its height in px, default 40); the creator 2026-10-14 prefers shadows to white borders. |\n| `layout.card` | Not used: the creator rejected this look (fixed camera, header, text slides). `true` or `{x: 66, y: 1082, w: 948, h: 715, r: 44, zoom: 1.08, bias: .27, capY}`: the clean layout (style the creator Clean). The camera sits in a fixed rounded card, the stage is everything above it (clipped at `y - 6`), and captions sit at `capY` (default `y - 114`). `layout.keys`, window transitions, cutouts and the seam are ignored; `zoom.keys` still punch in inside the card. Pair with `slide` scenes and `paper.bg: 'clean'`. See \"Clean layout\" below. |\n| `header` | Not used (rejected with `layout.card`, \"ça fait du bullshit\"). `{brand: 'BRAND', mark: 'X', tagline, x: 62, y: 120, size: 42, tagSize: 27}`: a brand line drawn on every frame of the card layout (brand in ink, mark in blue, tagline right-aligned in ink3). From the style. |\n| `shake` | `{keys: [[t, amp: 24, dur: .5]]}`: an impact shake of the whole frame (slightly zoomed so no edge shows), decaying over `dur`. Put it on the hit of a `shatter` or a reveal boom, never as a pulse. |\n| `glitch` | `{keys: [[t, dur: .4, amp: 1]]}`: a digital glitch of the whole frame centred on `t` (RGB split + torn bands slid sideways, re-rolled every 2 frames, strongest at `t`). Only under a zhhh transition sound (`sfx.files` with `peak: true` on the same `t`), once or twice a short. |\n| `vanish` | `{from, to, backAt, back: .45, cell: 12, plate, grow: 10}`: the camera turns to dust between `from` and `to` (blocks swept left to right drift up, shrink and fade, the paper shows through), then from `backAt` the dust flies back in. `plate: 'assets/images/x.png'` (an image of the empty room at the take's own size, the speaker removed) = only the speaker turns to dust and the room stays: `prepare` mattes the whole span (Apple Vision) and builds `frames/plate.jpg` through the same crop and grade, and his silhouette, dilated by `grow` px, is filled with the plate. Use the plate whenever the camera is on screen during the vanish (the creator: the room must never vanish with him). For \"je disparais\". Keep it clear of a split \u003c-> full window transition; pair with `dust` and `pop-back` in `sfx.files`. |\n| `layout.w` | seam spring stiffness (default 10) for split \u003c-> split and paper \u003c-> split moves. Split \u003c-> full camera ignores it (window card). |\n| `layout.dur` | window card length in seconds (default .62, smootherstep). |\n| `mix` | `{music: -28, sfx: -29.5, duck: .5, ceil: -1.5}` LUFS targets (sfx measured on event hits only, not slides/highlights), duck depth, limiter ceiling |\n| `sfx` | `{mute: true}`, `{swish: .35}` (gain of the paper slides, 0 = none), `{slideGap: 8}` (min s between slides; slides only play when the paper re-enters over full camera), `{marker: 1}` (highlight sound gain, 0 = silent), `{strike: 1}` (gain of the scratch on every hand-drawn strike, `strikeAt`; 0 = silent, the creator's styles: « ce bruit est dégueulasse ») or `{extra: [[t, 'tap' / 'pop' / 'slide' / 'bell' / 'thud', gain?]]}`. `files: [{at | endAt, src, gain: -10, pan: 0, peak, offset, len}]`: designed sound files (`assets/sfx/*.mp3`, project-relative, a missing file throws), each levelled on its own to (-16 + gain) LUFS and kept out of the SFX bus measurement. `at` = where it starts, `peak: true` puts its loudest sample on `at` (impacts, whooshes, glass), `endAt` = where it ends (a riser landing on the payoff), `offset` / `len` trim it. Build the hook and the payoff with them (whoosh + glass + boom on a shatter, riser + boom on the reveal, whip on each board whip, dust + pop on a vanish). `kit: {cut, pop, punch, gain: {cut: -13, pop: -11, punch: -9}, gap: .12}` (usually from the style, files project-relative): automatic hits, `cut` on every seam change, cutout / broll edge and `cut` join, `pop` on every board img / logo / icon `at` and `imgKeys` swap, `punch` on every zoom key with w ≥ 60; a cut within .08 s of a pop or punch is dropped |\n\nCaptions sit on the seam (clamped to 1400) or at 760 to 1180 when the camera is full-bleed. They hide automatically when there is no speech for more than .55 s.\n\n### Speaker cutout, camera mode (magic backgrounds)\n\n`cutout: {mode: 'cam', from, to, bg: [{at, kind: 'paper' | 'image' | 'cam', src, img, hard, origin: [x, y], w: 6, sticker, focus, birds, birdsTo}]}`\n\n`cutout` can also be an ARRAY mixing one `mode: 'cam'` entry and any number of standard ones, e.g. a full-paper hook cutout `{from: 0, to: 5.2}` plus a cam-mode range later. `prepare` mattes the union range once.\n\nThe speaker stays where the camera puts him (any seam, zoom) and only the room changes. Each `bg` layer wipes in as a circle from `origin` (put it on the snapping hand) with a white rim; `paper` gets a white paper-cut sticker outline around him, `image` (project-relative `src`) slowly zooms, and a final `cam` layer at `to` wipes the real room back. `birds: true` sprinkles bird chirps until `birdsTo`. Pair each wipe with a `snap` scene. `hard: true` swaps the layer on a hard cut (no circle). **Text behind the head**: `img` = a full-frame 1080x1920 alpha PNG (a giant magazine word, rendered from an HTML page) drawn over that layer and under the person, so the head overlaps the title; on a `kind: 'cam'` layer the real room stays (e.g. `{at, kind: 'cam', img: 'assets/images/t-huge.png', hard: true}`, then a plain `{at, kind: 'cam', hard: true}` to drop it). At full bleed only about 600 source px around the eyes are visible: for a hand off to the side, drop to seam 960 with `zoom` .85 so the gesture is in frame.\n\n## Full-screen B-roll\n\nB-roll is footage shown over the voice to illustrate what is said: the creator's own clips (the street, the desk, the sunset, the app on the phone), screen recordings, photos. It has no voice of its own. Two ways to show it:\n\n- **Full screen** (`spec.broll`): the clip replaces the whole frame for 2 to 5 s while the sentence plays. For a moment, a place, a feeling: \"I moved to Bali\" over the rice fields, \"I worked every night\" over the desk at night. It is the strongest way to illustrate and resets attention.\n- **As a card** (`actors`): the clip sits on the paper above the camera, with a label. For proof the viewer must read: a screen recording, a result, a UI.\n\n```js\nmedia: {\n sunset: { src: '~/Movies/B-roll/IMG_2507.MOV', from: 0.3, dur: 5, fill: true }, // fill: cropped to 9:16\n desk: { src: '~/Movies/B-roll/desk.mp4', fill: true, focus: [.5, .35] }, // keep the top of the frame\n},\nbroll: [\n { media: 'sunset', from: 12.4, to: 15.8 }, // SOURCE seconds, mapped like every time key\n { media: 'desk', from: 31.0, to: 34.2, offset: 1.5, zoom: 0 },\n],\n```\n\nRules:\n\n- Method: `broll/references/use.md` (the `broll` skill). Find clips with the B-roll database, never by opening files: `node \u003cskill>/../broll/scripts/broll.mjs search \"\u003cenglish words>\" [--portrait] [--min-dur 3] [--json]` (tags are in English; emotions also work in French). Use the hit's `path` as `src` and its `best_start` as `from` (or `offset`). Then `broll.mjs look \u003cids>` and Read the sheet: a clip goes in only if the image fits the sentence. No good hit: the library does not have it, so use motion design or a generated image, never a random clip.\n- Each `broll` entry covers at least the whole sentence it illustrates (2 to 5 s), starts on the word that names it, and leaves at least 5 s of camera or paper between two B-roll stretches unless the beat is a montage.\n- Hide captions only if the clip has text of its own; otherwise captions stay on top (they are drawn after the B-roll).\n- A clip with no people works anywhere; a clip with the creator on screen (`is_self`) fits \"I\", \"my\", \"me\" sentences.\n- After the short is done: `broll.mjs used \u003cslug> \u003cid ...>` so the next shorts prefer fresh clips.\n- `prepare` extracts the B-roll frames (they are `media`), so re-run it after adding or changing one.\n\n## Actors (media cards on the paper stage)\n\n```js\n{ id, media, offset, from, to, pre, enter: 'right', keys: [[t, [x, y, w]]], labelKeys: [[t, 'Label']],\n dot: 'blue' | 'gray', ringAt, badge: { text, at }, dimAt, ar, mark: { at, dur, rect: [dx, dy, w, h] }, marks: [mark, ...] }\n```\n\n- `ar`: card height/width ratio, default `9 / 16`. Use `16 / 9` for phone-format app exports: two 450-wide cards side by side at x 60 and 570 (seam 1240).\n\n- `offset`: seconds into the media clip at `from`. The clip then plays at the short's `speed`.\n- `pre`: start the enter animation this many seconds before `from`, so the card is already settled when the word lands. A negative `pre` delays it, which staggers two cards that share a `from`.\n- `keys`: position targets. The card is 16:9 (`h = w * 9/16`) and moves with a spring on each key. Keep `w` constant (800): a card that changes size reads as random.\n- `labelKeys`: 42 px label above the card (with `dot`), cross-fading with a small slide between labels. Leave 70 px above the card for it.\n- `mark`: blue highlighter that sweeps left to right over a phrase inside the card (multiply blend) when the word is spoken. `rect` is in canvas px from the card's top-left. Measure it on a still. `marks` takes several, each on its own spoken word.\n- `ringAt`: blue selection ring. `badge`: blue check pill at the top-right corner. `dimAt`: desaturate and fade (the loser).\n- Default enter is a rise from below. `enter: 'right'` slides in from the right.\n- When one card waits alone for its rival, centre it first (`x = (1080 - w) / 2`) and add a key that moves it aside as the rival enters. Half an empty stage for several seconds reads as a bug.\n\n### Hook: launch tweet + speaker cutout\n\nFor \"X just came out\" hooks, open on the official announcement with the speaker cut out underneath, from frame 1.\n\n- Capture the tweet crisp: `DEV_BROWSER_ISOLATED=1 dev-browser --browser rec`, CDP `Emulation.setDeviceMetricsOverride` about 390 wide, then `Page.captureScreenshot {clip: {...boundingBox, scale: 3}, captureBeyondViewport: true}`. An element screenshot stays at 2x whatever the DPR. Crop out embedded video thumbnails (they are blurry) and keep the header, text and the views line. A single PNG as `media` gives one frame, which is fine.\n- If the only capture is a desktop screenshot, the body text lands around 25 px on a 960 card: unreadable. Rebuild it with the native `tweet` scene instead (below), which sets the body at 40 px.\n- Spec: `layout.keys: [[0, 1920], [\u003cend>, 1240]]`, a tweet actor at `[60, 190, 960]` with `ar` = h/w of the PNG, `cutout: {from: 0, to: \u003cend>}`, and a `mark` on the product name. Hide captions over the tweet's first seconds.\n- Hand-off (automatic): when the cutout's `to` meets the seam key that leaves 1920 for a split, the camera band does not rise from the bottom. The seam switches at once, the room fades in around the creator, and the cutout glides onto the camera framing (same pixels), so he never leaves the screen. The creator: \"je disparais par le bas et je réapparais par le bas, c'est étrange\". Keep the cutout `to` equal to that seam key time. `cutout.handoff: false` restores the old drop-out exit.\n- The cutout comes from `engine/matte.swift` (Apple Vision person segmentation, accurate, EMA-smoothed mask, soft edge). `prepare` runs it only on the cutout range and writes masks to `frames/matte/`; the runtime composites them over the cam frames. Nothing to install; about 30 fps on the Neural Engine, cached by frame content (`references/performance.md`).\n\n### Media for app demos: phone-format exports, never desktop tool UIs\n\nShorts are watched on phones. Record each generated app directly at a mobile viewport, never through a compare dashboard or desktop UI.\n\n- Use dev-browser headed (`DEV_BROWSER_ISOLATED=1 dev-browser --browser rec`), because WebGL needs a real window. Then send CDP `Emulation.setDeviceMetricsOverride` `{width: 390, height: 693, deviceScaleFactor: 2, mobile: true}`, which gives 780x1386 frames.\n- Run a screenshot loop and log the timestamps. Assemble the frames with the ffmpeg concat demuxer using per-frame durations, then `fps=30`, crf 18. CDP screencast is throttled in a hidden window, so don't use it.\n- Heavy 3D only reaches about 8 fps this way. Slow the app down (for example its 0.25x control), record 4x longer, and divide the timestamps by 4.\n\n## Real logos (always)\n\nAny `logo` (tiles item, pipeline header and rows) or actor `labelLogo` that is a project image path (`assets/logos/\u003cname>.png`) draws the real icon, rounded like an app icon. Every named product, tool or app gets its real logo, never a letter glyph. Glyphs are only for generic ideas (CRM, infinity).\nWhere to find them: App Store icons first (`https://itunes.apple.com/search?term=\u003capp>&entity=software` -> `artworkUrl512`, swap `.jpg` for `.png`), then the product's GitHub `assets/` (icon-master.svg), then the site's `apple-touch-icon` / favicon.svg. Rasterize SVGs to 512 px PNG. Check every icon on a contact sheet before using it (GitHub org avatars are often not the product logo).\n\n## Scenes\n\nStory scenes (`foilHat`, `stampOver`, `cheque`, `crowd`...) and the composable `board` (items: text, chip, stamp, counter, bar, icon, logo, check, any item with `strikeAt`...) are documented in the headers of `engine/lib/scenes-story.js` and `engine/lib/scenes-life.js`. Board item `rot` is in RADIANS (.05 to .1 for a slight tilt; `rot: -4` spins the item half a turn). Motion on any board item: `pop: .6` (enters scaled down and springs to full size), `spin: -.3` (enters rotated, settles to `rot`), `dx` (enter slide), `posKeys`/`scaleKeys` (moves later in the beat; `posW` sets the spring of both, 12 to 14 for a hero-to-slot glide); `morph: true` (the item grows out of the previous scene's `hand()` rect, e.g. the tweet's image, over `morphDur` .62 s); `hold: true` (a counter keeps its authored `at` instead of entering with its count); `shown: true` (no entrance: the item is already on screen at its `at`, e.g. the hook logo on frame 0, which is also the cover); `slot: false` (skips the same-slot handoff wait); scene `gate: false` (enters on its own `from` even right after another scene's exit). Choreography rules behind these: design-system \"Premium choreography\". Board `push` (slow zoom over the board's life around `pushAt`, so a held beat never sits still; default .025 on boards of 3 s or more, `push: 0` turns it off) and `outDx: -700` (the board whips out sideways instead of rising; pair it with the next board's items entering with a positive `dx`). Board `img` takes `frame: 14` for a white print border (a premium photo card). Board item `glass {x, y, w, h, tint}` is a frosted panel (silent, no SFX slot); on a `layer: 'over'` board it puts text and icons on the camera without a layout move (`glass()` helper in `templates/youtube.js`). Board item `screen {img | imgKeys, w, h, viewKeys, rings, arrows}` shows a real UI screenshot (a crop of the long-form's screen recording) in a dark window: `viewKeys` spring-zoom onto a region (image px center + zoom), `rings` dim the rest and outline one element, `arrows` draw on toward a tip in image px; use it whenever the creator asks for \"la vraie interface\" with zooms and arrows. `imgKeys: [[t, img]]` swaps the content in place (a short cross-fade) for a UI that changes state, e.g. the Recorder before / during / after recording, while `viewKeys` keep zooming. Board item `edit {imgs, w: 960, vh: 150, markAt, cutAt, capsAt, fxAt, playFrom, playTo, steps: [{text, at}]}` is an editing timeline that edits itself: real clip frames on a video track, the silences between them boxed in blue (`markAt`) then closed (`cutAt`), \"Aa\" caption pills dropping on each clip (`capsAt`), blue animation blocks with a sparkle (`fxAt`), a playhead over `playFrom..playTo`, and `steps` as blue chips checked one by one under the sheet (sheet 354 px tall + the chip row 60 px below it); for \"l'IA monte tes rushs\": rushes fly into a mascot (`img` items with `posKeys`/`scaleKeys` toward it, drawn before it), then the mascot moves up and the `edit` sheet appears under it. Board item `term {cmd, w: 960, h: 640, size: 52, title, logo, name: 'Claude Code', sub, placeholder, focusAt, typeFrom, typeTo, suggest: [[name, desc]], sendAt, out: [{text, at, dim}]}` is a Claude Code terminal (centered): the slash command types in the prompt box with its autocomplete row, Enter (`sendAt`) moves it to the history and the `out` lines land one by one (cues play a key per character, a tap on Enter and a tick per output line); use it to show the exact skill launched (`/short-editing`, `/publish`) and give it the real Claude logo. Board item `shatter {logo, s: 260, breakAt, hit: [x, y], n: 10, seed, tight}` (`tight: true` on a colored flat paper: only the icon's pieces, no white glass wedges) is a real logo that holds, then breaks into glass shards at `breakAt` (`hit` = impact point, -1..1 of the icon) with a blue shockwave and spark lines: the hook \"X vient de tuer Y\" (another logo flies in with `posKeys` and lands on `breakAt`, plus `shake` and whoosh + glass + boom `sfx.files`). Board `img` items from the house icon set (`assets/icons/\u003cname>.png`) take `shadow: false`. The creator's look avoids `stamp` / `stampOver`: plain text with one blue word. Big numbers and board texts follow the style's `display` look (family, weight, title color, `flat`; the white paper style = Bricolage Grotesque 800 flat ink on every text, the suffix and unit in blue, see style-schema). Board `counter` takes `clock: true` for a stopwatch: `value` (and `fromValue`) in seconds shown as `mm:ss` (1200 = 20:00), every digit in a fixed slot so the running timer never jitters (pair it with a drawn stopwatch: a dial `img` plus a hand `img` on the same center turned by `rotKeys`, never a line icon). Board `text` takes `font` (+ `weight`, default 400) to override the display families for that text, e.g. `font: 'Instrument Serif'` (bundled serif, single weight 400, also usable as `captions.font`) on an end card. Board `text` and `counter` take `depth: 1` for an embossed big number (ignored when `display.flat`): the face stays flat in its own color, lifted off the paper by a white top edge and a soft cast shadow, never extruded or 3D (the creator rejected the extruded look); a higher number = a stronger shadow (pair it with `captions.hide` over the same span so the caption does not repeat it). Drawn characters: `ostrich {s, downFrom, downTo, upAt, mound, dir}` buries its head in a sand mound and pops it back out with blue wake-up ticks (\"arrêtez de faire l'autruche\"); `starship {s, igniteAt, liftFrom, liftTo, liftY}` is a stainless Starship with a blue flame and a pad smoke cloud that lifts off (pair it with the SpaceX wordmark as an `img`). `ferrari {s, roll, rollDur}` is a red side-view sports car (wheels on y) that rolls in from the left by `roll` px; set its `y` to the starship pad line to stand them side by side (Ferrari vs rocket).\n\nBoard `label {text, size 90, bg, color, rot -.035, skew .2, caps, align, border, stretch, ls}` is the slanted thumbnail label: heavy condensed italic caps (Archivo extra-condensed) on a flat color block, tilted a few degrees, wiping in from the left in .26 s with a pop SFX. `bg`: `'blue'` (the accent, default), `'pop'` (`colors.pop`), `'ink'` or a hex; the text follows (`colors.onAccent` on the accent, ink on a light fill, white on a dark one) unless `color` is set. `'\\n'` stacks lines, `caps: false` keeps the case (short links). Box height is about `size * (.98 * lines + .32)`, width about `.5 * size` per character + `.6 * size`. Stack 2 or 3 of them in different colors like a thumbnail, never more than one per idea. Any item's strike takes `strikeColor` (default `'blue'`): use `'pop'` when the accent is a light yellow, a yellow strike disappears on the paper.\n\nRich board items (use them instead of line icons or toggles: the creator calls engine line icons such as hourglass, message, robot or play \"infâme\", and rejects switches for an idea like speed vs quality):\n- `gauge {r: 170, value: .9, sweepFrom, sweepTo, unit}`: a round speedometer sheet (270° groove, 21 ticks); the ink needle sweeps to `value` with a lagging ghost trail and the blue arc fills. For \"rapidité\", \"puissance\", any speed or intensity.\n- `pixelate {img, w: 300, h, r: 26, sharpFrom, sharpTo, block: 44}`: a real image that steps from big pixel blocks to sharp, then a white sheen passes. For \"qualité\", \"netteté\", \"rendu final\".\n- `render {w: 900, h: 420, title, imgs: [...], progFrom, progTo, prog: .96, total, elapsed}`: a dark render window (traffic lights, mono title, spinner) whose film strip develops real frames one by one behind a blue scan line, with a striped progress bar, `Frame n / total` and an m:ss timer counting to `elapsed`. For \"ça rame\", \"le rendu prend X s\". `imgs` paths are preloaded like `img`.\n\nCommon: `{type, from, to, pre, layer: 'stage' (default) | 'over'}`. `pre` (seconds) makes the scene enter early, so a hook scene at `from: 0` is already on screen at frame 1. Leave `to` off the last scene (never a far-future value like 99: it maps to the end and fades the scene out under the CTA). Stage scenes ride with the paper, while `over` scenes float above the camera (CTA). Enter is a spring rise, and exit is a fade with a small lift.\n\n### stat\n`{value: 100, suffix: '%', countFrom, countTo, y, size: 300, chip?: {text, at, y}}`\nAn odometer count-up in tabular digits, with the suffix in blue. Keep `chip` off when the caption already says the number.\n\n### race\n```js\n{ header: {text, at}, footer: {text, at, logo}, countFrom, countTo, winnerAt, chip: {text, at, y}, stack,\n panels: [{name, logo: 'openai' | 'claude' | 'codex', media, offset, cost, costSuffix: '+', time /* seconds */, ar: .625}] }\n```\nModel race, like the \"GPT vs Claude\" comparison videos on X: one paper panel per model, side by side (stacked on 9:16 or with `stack: true`). Each panel header shows the logo, the name, the elapsed time and a live cost meter; the generation plays below. Panel `effort` (e.g. `'Medium effort'`) is a dark pill pinned top-left on the media: always set it, a comparison without the reasoning effort is useless. Meters run from 0 between `countFrom` and each panel's own finish, scaled to its real `time`, so the faster model stops first and gets a check. At `winnerAt` the cheaper panel gets the blue ring and its price turns blue; `chip` (the verdict, e.g. \"1.8× cheaper\") lands under the panels, `footer` sits at the bottom in ink2. SFX are automatic (quiet ticks, a pop per finish, a bell on the winner). Build whole videos from `engine/templates/race.js` instead of writing race scenes by hand.\n\n### bars\n`{y, rows: [{label, value 0..1, at, blue, dark}], gapAt, gapChip?: {text, at}, tags?: [{text, at, blue}]}`\nHorizontal comparison bars (900 px wide, 230 px per row). `gapAt` draws the ghost bar and a bracket showing the difference. `tags` are chips across the top. `dark` fills a non-blue row in ink3, because the default dim grey is too faint on the track. The first row should land within about .3 s of `from`, or the scene shows an empty sheet.\n\n### columns\n```js\n{ header: {text, at}, sub: {text, at}, base: 1080, y0: 470, colW: 170, gapIn: 30, gapOut: 90, cx: 540, max, // max auto\n groups: [{ cols: [{name, sub, value, text, at, blue}], ghost: {at, over: 0, of: 1, chip: {text, at}} }] }\n```\nGrouped vertical columns, like the Artificial Analysis \"Cost per task\" bar chart. The header sits at y 240 and the sub at y 300, with dotted grid lines and an ink baseline at `base`. Each column rises at `at` and shows its `text` value above it (46/800), its `name` (42/800) under the baseline and its `sub` (36/600 ink3) below that. A group's `ghost` draws the `of` column's height as a dashed blue outline over the `over` column, with a blue ratio chip (for example \"5.7x\"). Use seam 1320.\n\n### recap\n`{rect: [60, 210, 960], rh: 98, header: {text, count, at}, rows: [{title, sub, at}]}`\nA numbered list sheet for outros (\"the 9 changes\"). The header text is 52/800 with `count` in blue on the right. Rows are 98 px each, with a blue number disc, the title at 44/800 and the sub at 36/600 right-aligned. Height is `118 + n*rh + 18`, so 9 rows end around y 1230: use seam 1300 and put the CTA over it at y 1420. Cascade the rows about .14 s apart.\n\n### convo\n```js\n{ rect: [40, 200, 1000, 1000], title: 'Claude', model: 'Opus 5.5', size: 42, msgs: [\n { who: 'me', text, from, to }, // blue bubble, words appear on the creator's spoken words\n { who: 'opus', text, at, offs: [0, .19, ...] }, // streamed reply, offs = word starts in its voice clip (1x s)\n { who: 'opus', image: 'assets/media/x.jpg', at, genFrom, sharpAt, size: 560, label: 'Pixel art' } ] }\n```\nA live Claude thread for \"I talk to the AI\" shorts. Every message has its final size from the moment it appears and the thread scrolls up as it grows. The image reply resolves from coarse pixels with a `label · N %` chip. Use seam 1240 and hide captions over the bubble ranges (the bubbles are the captions). Opus voices come from `spec.voices`.\n\n### scatter\n```js\n{ header: {text, at}, sub: {text, at}, plot: {x0: 170, x1: 960, y0: 420, h: 680},\n xRange: [a, b], yRange: [a, b], xTicks, yTicks, xPrefix: '

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…

, xSuffix: 'k', xLabel, yLabel,\n xLog: true, // log cost axis, like Artificial Analysis\n points: [{label, sub, x, y, at, color: 'blue' | 'ink' | 'gray', side: 'left' | 'right', dx, dy}], // no label = small grey context dot\n arrows: [{pair: [i, j], at, card: {text, at, dx, dy, w, sub: {text, at}}}], // arrow from point i to j + fixed-size delta card\n ref, vs, dyAt, dyChip: {text, at}, dxAt, dxChip: {text, at}, ringAt }\n```\n\nAn X/Y chart (for example intelligence vs cost). Axes grow in and points pop at `at`, with gray used for context points. `ref` and `vs` are point indexes. `dyAt` draws the vertical gap bracket and `dxAt` the horizontal one, each with a chip. `ringAt` rings the `ref` winner. Use seam 1320 so the plot has room.\n\nWhen the video shows a real chart (Artificial Analysis, a board screenshot), reproduce **that** chart: the same models, the same effort variants and the same arrows. Take the numbers from the page's embedded data (`curl` the page, parse `intelligenceIndex` and `intelligenceIndexCostPerTask`). Never replace them with the headline defaults. Keep context models as unlabeled dots, and label only the models you talk about. Arrow keys must not be named `from`/`to`, because `mapSpec` would remap them as times.\n\n### tiles\n`{y, header: {text, at}, items: [{glyph | logo, label, at, strikeAt}]}`\n`pre` also shifts the header and items, so a hook tiles scene with `pre: .5` is fully readable at frame 1.\nApp-icon tiles in a row, with a hand-drawn strike through each. Use them for \"no more X, Y, Z\".\n\n### title\n`{lines: [{text, at, emph, emphAt}]}`\nBig centred statement lines. `emph` is the word that turns blue at `emphAt`. Pair it with a `music.breaks` range so the line lands in the air.\n\n### chat\n```js\n{ rect: [x, y, w, h], newChatAt, focusAt, prompt: { text, from, to }, sendAt, workFrom, workTo,\n result: { media, offset, at, label } }\n```\n`result` is optional; `workText` sets the working line (default 'Opus 5.5 crée ta vidéo'). `greeting` (default « Qu'est-ce qu'on crée ? ») and `placeholder` (default « Décris ta vidéo… ») set the empty-chat texts. A Claude-style chat: greeting, \"+ Nouveau chat\" pointer click, focus the composer, word-synced typing over the spoken words between `prompt.from` and `prompt.to`, send, a working bar, then a result card playing `media`. Hide captions over the typing range.\n\n### switch\n```js\n{ y: 420, tiles: [{logo: 'codex' | 'claude', label}, {glyph, label}], headerKeys: [[t, 'text']], sideKeys: [[t, 0 | 1]], w: 13,\n chips: [{text, at, side: 0 | 1, blue}] }\n```\nTwo tool tiles (250 px) with a toggle between them. The blue knob slides to the active side on each `sideKeys` time, and the active tile gets a blue ring. `headerKeys` cross-fade the header line above. Chips stack under their side and dim with it. Use it for \"X vs Y, now I switch\". Built-in logos: `claude` (starburst), `codex` (`>_`), an image path (`assets/logos/x.png`, grayed while inactive), anything else via `glyph`. `y: 330` with the full-paper hook cutout, `y: 420` with seam 1240.\n\n### tree\n`{rect: [x, y, w], size: 46, lines: [{text: '├── CLAUDE.md', at, link: {text: 'AGENTS.md', at}}]}`\nA project folder in JetBrains Mono on a sheet. Tree glyphs render grey, folders (ending in `/`) extra bold, and `link` slides in a blue `→ target` (symlinks). Lines may appear out of order to follow the voice.\n\n### snap (over)\n`{layer: 'over', from, origin: [x, y], r: 1}` a finger-snap burst (rays + ring) with a snap sound. Use it on every \"je claque des doigts\".\n\n### xylo\n`{y, notes: ['E', 'G', 'A', 'C'], midi: [76, 79, 81, 84], hitKeys: [[t, barIndex]], restAt}` a paper xylophone. A mallet springs to each bar, the bar lights blue, a note floats up, and a real mallet note plays (SFX bus). Put `hitKeys` on the sung syllables, and `hold` that range so the rhythm survives the silence cutter.\n\n### sfxtrack\n`{y, header: {text, at}, tag: {text, at}, rows: [{label, at, sound: 'snap', clips: [[a, b]] (0..1)}], playFrom, playTo}` an audio-editor lane per sound; a playhead sweeps and lights each clip. For \"l'IA a créé les bruits\".\n\n### popover (over)\n`{layer: 'over', from, to, w: 700, keys: [[t, [x, y]]], title, message: {text, from, to}, buttons: ['Annuler', 'OK'], clickAt}` a macOS alert that lands over the camera, follows where the speaker points (keys), types the spoken message word-synced, and the pointer clicks OK. Hide captions over the typing.\n\n### overlay (over)\n`{layer: 'over', media, from, to, offset, fade: 0}` a full-frame transparent clip (a `media` with `alpha: true`) drawn straight over the frame, under the captions. For an app UI animated over the full-screen camera: write the UI as an HTML page with a `window.setT(t)` that sets every style for an instant, then `short.mjs capture \u003cproject> source/\u003cname>.html --dur \u003cs> --alpha` (one screenshot per frame, png-in-mov + `stills/\u003cname>-sheet.jpg`). Build these in parallel with sub-agents: `references/sub-agents.md`. Keep it inside a full-screen stretch (seam 0, at least 5 s): a window move draws the camera card over the `over` layer. Pin the captions above the UI with `captions.yKeys`. The media cache follows the file (size and date): a recaptured clip is re-extracted by the next `prepare`. Example: a comment CTA (\"commente skill\"): the Instagram Reels UI over the face, a finger taps the comments, types and posts the word, then the DM banners drop in, so the viewer feels they must do it now.\n\n### pause (over)\n`{layer: 'over', from: 'p+0', to: 'p+9.3', keys: [[t, [x, y, r]]]}` a paper pause badge with a blue ring that fills over the pause, morphing to play at `to`. Keep it off the face.\n\n### Motion-design film (full paper, seam 1920): mdGrid, mdIntro, mdRec, mdWave, mdCut, mdVision, mdDiffuse, mdCode, mdStills, mdRender, mdAssemble\nA narrated \"how it was made\" film, played during a `pause` with layout `[['p+0', 0], ['p+0.75', 1920], ['p+X', 0]]` and the pause badge parked top right (`[964, 296, 52]`) as a progress ring. Each beat slides in from the right and leaves to the left, back to back, so the beats read as one camera move. Every beat takes `step: {n, of: 5, title, sub, logo}` (blue disc + 80 px title at y 430, 5-segment progress rail at y 290, faint giant numeral) and `y: 600` for its content, which then fills about 600 to 1550. A `step.logo` image path draws the real icon right after the title (the giant numeral stays). The beats also work as a whole short with no pause (layout `[[0, 1920]]`). Time each beat on the narrator's words (`p+X` from words.md) and hide captions over the whole pause.\n- `mdGrid {from, to}` drifting dot grid behind the film.\n- `mdIntro {y: 700, title, sub: {text, at, to, emph}}` logo tile on a turning sunburst with rings, 150 px title, sub words landing on the narrator's words.\n- `mdRec {media, offset, clock, stamp: {text, at}}` the raw take playing in a 960x540 card with REC pill and a running timecode up to `clock` s, then a stamp.\n- `mdWave {sweepFrom, sweepTo, markAt, words: [[word, sourceSeconds]]}` waveform, sweeping playhead, each word drops into a timestamp list; `markAt` highlights the hundredths in blue.\n- `mdCut {markAt, closeAt, speedAt, speed: '+15 %', factor, clock: {start, stop, at, endAt}}` waveform with silences: they get a dashed blue box at `markAt`, collapse at `closeAt`, the speech squashes by `1/factor` with the `speed` stamp at `speedAt`, then a big mono clock counts `start` to `stop` seconds (m:ss) from `at` to `endAt`. The clock keys are `start`/`stop`, never `from`/`to` (those would be time-mapped).\n- `mdVision {frameAt, scanFrom, scanTo, flipAt, frames: [a, b]}` one camera frame; a scan line turns the room into a checkerboard with a blue-outlined cutout, then a flip-book counter. `w` (default 940) shrinks the card when captions sit below. `frameAt` (source time) must lie inside a `cutout` range so its matte exists; `frames` = the real matted frame count from the prepare log.\n- `mdDiffuse {src, prompt: {text, from, to}, genFrom, sharpAt}` a typed prompt, then the image resolves from coarse noisy pixels to sharp with a flash.\n- `mdCode {file, lines: [{text, at, dur, fx: 'xylo' | 'snap' | 'birds' | 'bass'}]}` an editor typing syntax-coloured lines; each finished line plays its `fx` (cues) with notes floating up, and an equaliser below grows with every line (`eq: false` hides it, `notes: false` drops the floating notes).\n- `mdStills {enterAt, scanFrom, flagAt, flag, fixAt}` a 4x2 grid of mini shorts (paper card + camera + caption pill); a loupe snakes across them, at `flagAt` thumb `flag` (0 to 7) gets a blue ring and a \"!\" (its caption overflows), the others dim, and at `fixAt` the caption slides into place and the badge turns into a check.\n- `mdRender {total, renderFrom, renderTo, url, bars: [{label, value, frac, at, blue}]}` a browser window rendering a mini short, image counter up to `total` with a progress bar, then comparison bars (e.g. video length vs render time).\n- `mdAssemble {flyFrom, stagger, playFrom, playTo, stamp, tracks: [{label, clips: [[a, b]], cam | text}]}` clips fly in from everywhere and snap onto their tracks (cam thumbnails, waveforms, \"Aa\" caption pills), then a playhead runs.\n\n### pipeline\n`{y, header: {text, at, glyph | logo}, rows: [{glyph | logo, mono, title, sub, at, blue}]}` (`header.glyph` replaces the default Claude logo in the header tile) \"who did what\": a live voice meter in the header pulses while the narrator speaks, rows land on the narrator's words (`'p+X'` from words.md) with a check.\n\n### comment (over)\n`{layer: 'over', from, y: 1480, placeholder, typed, clickAt, typeFrom, typeTo, sendAt, button: 'Publier'}` a comment composer CTA with the camera avatar; the pointer focuses it, types `typed` and hits Publier. Use for \"dis-moi en commentaire\".\n\n### tweet\n```js\n{ rect: [60, 230, 960], avatar: 'avatar', name, handle, verified: true, text, size: 40,\n footer: '2:41 PM · Sep 29, 2026 · **642.1K** Views', marks: [{ at, text, dur: .7 }],\n media: 'clip', offset: 0, mediaKeys: [[at, offset, 'clipB']], mediaAr: 9/16 } // optional attached video/image\n```\nA native X post rebuilt on a paper sheet: 88 px avatar (a `media` key, 176 px PNG), name 40/800, verified badge, handle, X logo, body at `size` 40, hairline and footer. `**bold**` runs work in `text` and `footer`. Each `marks` entry sweeps a blue highlighter over the first occurrence of `text` (line by line) when it is spoken. Height comes from the text, so pick the seam from a still: about 960 for a 4-line post, 1100 to 1240 for 6 to 8 lines. Spread one long thread over several `tweet` scenes (one per point) with a shared base object.\n`media` embeds the tweet's own video under the text, like X does. It runs full width with radius 22, at `mediaAr` = height/width (default 9/16, a 16:9 video). The clip plays from `offset` (clip seconds) when the scene starts. `mediaKeys` re-cues it on spoken words: each `[at, offset, id?]` restarts at `offset` and can swap to another media key. Split one long launch video into several `media` entries (`from`/`dur`) and cue the best shot for each sentence. With a 16/9 video the card is about 966 px for 3 lines of text and 1020 for 4, so use seam 1240 to 1300.\n\n### article\n```js\n{ rect: [60, 0, 960], center: true, logo: 'assets/logos/fortune.png', outlet: 'Fortune', date: '21 juil. 2026',\n kicker: 'AI', headline, headSize: 54, hlMark: { at, dur: .8 }, quote, quoteSize: 38, by: 'Arthur Mensch, CEO de Mistral',\n marks: [{ at, text, dur: .7 }], read: { from, to } }\n```\nA press source rebuilt natively, never a page screenshot. The card holds an outlet row (64 px logo, name, optional kicker chip, date on the right), a hairline, the headline (a highlighter sweeps it at `hlMark.at`), an optional pulled quote behind a blue rule (`marks` highlight phrases as they are spoken, `read` greys the words not yet said), and the attribution. `center: true` centres the card vertically on the stage. Use the real outlet, headline, date and quote. Leave on screen 6 to 8 s, or 7 to 10 s with a quote.\n\n### Audio ceiling\nIf the `final` QC shows a true peak above -1 dBTP (AAC overs on dense music), set `mix: { ceil: -2.5 }` (limiter ceiling in dBFS, default -1.5) and rerun `audio` + `final`.\n\n### usage\n```js\n{ rect: [60, 250, 960], title: 'Plan usage limits · Max (20x)', logo: 'assets/logos/claude.png' /* optional, left of the title */,\n rows: [{ label, sub, value: .03, fill: { value: 1, at, sub: 'Limit reached' }, mark: { at, to } }] }\n```\nA settings popup of usage meters (label 44, sub 34, % right, 16 px bar). `fill` animates a row's bar and % to `fill.value` at `at` and cross-fades the sub to `fill.sub`; a full bar turns ink. `mark` tints and rings the row from `at` to `to`. 168 px per row, so 3 rows fit above seam 1100.\n\n### chip\n`{y, text, size: 44, blue}`: a standalone label. Rarely needed, since captions already do this job.\n\n### cta\n`{layer: 'over', name, sub, button, done, clickAt, y: 1420, avatar}`. Texts left out come from the profile (Style layer above). A long name or sub shrinks to stay clear of the button.\nA profile card with the avatar cropped from the camera (`avatar: 'assets/images/mascot-avatar.jpg'` puts a picture there instead: faceless styles, never the camera face). The pointer flies in, and the button morphs to a check and `done`. Hide captions from `from` to the end.\n\n### capStyles (over)\n`{layer: 'over', from, to, cycleFrom, cycleTo, n: 20, y: 1180, counterY: 300}`. The live caption cycles through 20 caption styles (sticker, pill, karaoke, serif, mono, outline, stacked...) with a `Style 07 / 20` chip and a dot rail. Style 0 before `cycleFrom`; switches cut hard, the fade is per chunk. It draws the captions itself, so hide the normal captions over `[from, to]`.\n\n### camTile (over)\n`{layer: 'over', from, to, pre, w: 460, h: 580, keys: [[t, [cx, cy]]], labelKeys: [[t, 'En haut']]}`. The camera as a framed tile on full paper (seam 1920) that glides to each key; the label chip sits mirrored across the screen centre. For \"put my camera top / bottom / left / right\".\n\n### camSplit (over)\n`{layer: 'over', from, to, h: 820, enterFrom: 1240, dur: .7}`. On full paper (layout key `[from, 1920, 400]`), the camera panel rises from `enterFrom` to the top `0..h` with a torn bottom edge and a soft shadow, keeping the face framed. The paper below is free for an image (the masterclass, a product): put it on a `layer: 'over'` board after this scene, and `captions.yKeys` on the seam. For \"split screen, moi en haut, X en dessous\".\n\n### Faceless (no camera)\nFor an audio-only video, the speaker is a die-cut sticker of their head (`engine/lib/scenes-faceless.js`, full parameters in its header). Layout `[[0, 1920]]`, `captions.paperY` ~1580.\n- `paperFlow {from: 0, to: 99}` first in `scenes`: textured paper that drifts and breathes all video long, a few paper cut-outs floating near the edges.\n- `talkHead {face, emo: [{from, to, img}], posKeys [[t, [x, top, w]]], body, crew}`: the head floats and nods on every word, livelier while words are spoken. No lip-sync: one face at a time, `emo` swaps it on emotional beats. `body {at, armsAt, danceAt, shirt, bpm}` grows a paper-cut body and starts a disco dance; `crew` adds friends' sticker heads that pop in and dance. Put it after the floor so dancers stand on it.\n- `bgPattern {from, to, keys: [[t, 'squares' | 'diamonds' | 'stripes' | 'none']], x 540, y 1500, a 1}`: the paper background changes pattern on the beat (blue-tint checker, harlequin diamonds, vertical stripes, drifting slowly); each new one wipes in as a circle growing from (x, y). Put it right after `paperFlow`. For \"change the background\".\n- `disco {from, at, y, r, spotsAt}` mirror ball lowering on its string, light spots on the paper. `dancefloor {from, at, y0, bpm}` perspective tiles lighting on the beat. Both need a `from`.\n- `mascot {idle, talk: [imgs], poses: [{at, to, img, flip}], posKeys [[t, [x, footY, h]]], jumps: [{at}], headOnlySpans, hueSpans, dance: {at, to, imgs, bpm, beats 2}, crew: [{at, to, imgs, x, footY, h, phase}], sizes: {img: k}}`: a full-body cartoon mascot (preferred over photo heads, which look weird animated). One drawing per beat: `talk` drawings alternate per phrase, `poses` override them on gags, `dance` cycles drawings on the beat until its optional `to` (crew members also take `to` and sink back under the floor). Every swap lands with a squash and stretch, words nod it, `jumps` hop it. `headOnlySpans` cuts the body off at the chin (floating head gag), `hueSpans` cycles its colors. `posKeys` moves and resizes it (springs between keys): keep it alive, walking next to the card it talks about and growing or shrinking with the beat. Pointing poses (`m-point-side`, `m-present`, `m-point-up-side`) point to the right; `flip: true` on a pose mirrors it to point left. `teleport: true` (the Mascotte style default, the creator finds running and leaping weird): every posKeys move wider than 80 px is a teleport instead, he zips thin and vanishes in 0.1 s before the key, a paper poof cloud marks both spots, and he springs back at the new place (size and footY snap with him; smaller moves still spring). Move him only when the beat needs it (a reveal, an element that takes his spot, a hero moment) and let him point from where he stands the rest of the time. Without `teleport`, every move wider than 80 px is an automatic cartoon leap: crouch before, arc, lean into the move, marker speed lines, paper dust on landing; `travelPose` (a running drawing facing right, auto-flipped to the direction) replaces the pose mid-air. `airPose` shows during `jumps` that stay in place, `dust: false` turns the puffs and poofs off. Never put a `jump` just before a move. Pose PNGs are one 3:4 frame, feet at 95%. The `setup-faceless` skill makes them: `scripts/avatar.mjs poses` draws a pose pack from the avatar (engine `images.mjs --raw`, the design as `refs`) and cuts it (`scripts/cutout.mjs --canvas`), `avatar.mjs pose \u003cm-name> \"\u003cacting>\"` adds one pose to a style.\n- `bills {from, at, to, n}` paper dollar bills flutter down (money gag). `confetti {from, at, to, x, y, n, power}` paper confetti burst.\n\n### Clean layout: slide (stage)\nRejected by the creator on 2026-10-07 (camera always at the bottom, too much text): never use it for his shorts; his look is the paper stage with `layout.frame`. Kept only as engine code. With `layout.card` (style the creator Clean, reference `shorts/short-claude-5x-value-clean/`), one `slide` = one idea held 6 to 20 s. Full parameters in the header of `engine/lib/scenes-clean.js`.\n- `{type: 'slide', from, to, pre, eyebrow, title: [l1, l2], hl: ['word'], size: 90, items: [...]}`: blue eyebrow at y 231, a heavy 2-line headline that auto-shrinks to 960 px (words in `hl` turn blue), then white glass cards in the zone y 500 to 890 (the caption pill sits at ~968, keep items above it). The slide blurs out at `to` while the next one blurs in.\n- Items (absolute 1080 px coords), each with `at`, `outAt`, `dimAt`, `ringAt`:\n - `row`: logo, title, sub, pill; `strikeAt` strikes the title and swaps the pill for `pillAlt`.\n - `stat`: logo, name, plan, a big counter (`value`, `countAt`, `until`), sub, `badge` pill bottom right (`badgeAt`, `badgeTop`). `ghost: true` = a pale \"?\" card from `ghostAt` that fills in at `at`.\n - `bars`: one glass row per entry, with a track and a counter (`countAt`, `until`, `win` = blue).\n - Smaller kinds: `tweet` (one line), `logo` (`tile: true` = glass tile, `label`), `img`, `pill`, `text`, `arrow`, `card`.\n- Name count times `countAt` / `until` (mapped). Never give a value field an `At` / `From` / `To` suffix.\n- With `sfx.appear: 'slide'`, each slide and each item gets a soft paper slide and nothing else (no ticks, pops or bells).\n\n## Adding a scene\n\nAdd `const myScene = { draw(R, s, t) {...} }` (optional `needs(s, t, U)` returns `[mediaId, clipTime]` pairs or raw URLs: `'/p/assets/…'`, `U.cam(t)`, `U.cut(t)`; read them back with `R.frame()` / `R.img()`) to `engine/lib/scenes.js` and register it in `SCENES`. Use `env()` for enter/exit, `rise()` for elements, and `S()` / `track()` for motion. Add matching sounds in `engine/lib/cues.js`. Name time params with the `At`/`From`/`To` convention.\n\n### crashWall (scenes-crash.js)\n\nA car rams a real 3D brick wall in perspective: road, wall built course by course (each brick a shaded 3D box with ink edges and a ground shadow), the car sticker arrives, revs, rams at `hitAt` and sinks into the hole; the bricks around the impact burst with rigid-body tumbling and settle on the road, the ones above collapse, dust, blue burst, camera shake + zoom punch. Use it for any \"crashes into a wall\" beat instead of a crash pose image.\n`{ type: 'crashWall', from, to, car: 'assets/mascot/m-car.png' (front facing right, bumper at 0.95/0.9 of the image), carH 470, enterAt, buildAt, ramAt, hitAt, lane .8, rows 8, cols 6, brick [120, 56, 62], color '#C8603E', yaw .62, pitch .3, zoom, cx 560, cy 1250, outDx }`. Put a `board` after it for the BOUM text; a `confetti` at about (560, 1060) lands on the impact.\n\n### masterCard (scenes-master.js)\n\nThe creator's masterclass card (port of the adMaster ads). Every step optional, keyed on a word: arrowAt (link + arrow), btnAt (button + cursor from cursorFrom), clickAt (press, \"Inscription\" bar), unlockAt (\"Accès débloqué\"), playAt..playTo (progress bar, timer, lessons lit then checked). Params: from, to, pre, y 236, eyebrow, title, sub, logo, avatar, badge, badgeAt, lessons [[label, \"N min\"]], chaptersFrom, chapterStep, linkText, btnText, busyText, unlockText, total, outDx. Faceless: avatar = the mascot image. A title too wide for the room left of the avatar shrinks to fit (never under the avatar). Board items also take rotKeys [[t, rad]].\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":64866,"content_sha256":"80a5db28bc1d45d1596e32d39ffe0e2ac5f98222396913490628410b2be78dea"},{"filename":"references/sfx.md","content":"# Sound effects\n\nEvery sound an edit needs ships with the skill: no account, no API key, no download. Two sources, in this order:\n\n1. **The engine's own sounds**, synthesized in code during the mix. Call them by name: `sfx.extra: [[t, 'thud', gain?]]` (SOURCE time).\n2. **The bundled files** in `assets/sfx/` (this skill): the 5 base sounds. Copy the ones a short uses into the project's `assets/sfx/` (or the style's `assets/sfx/`, `init` copies those), then reference them in `sfx.files` or `sfx.kit`.\n\nGenerating a new sound with an AI service (ElevenLabs sound effects, ...) is a last resort: only when the creator asks for a sound none of these covers, after telling them it uses their credits.\n\n## The house rule\n\nHits only on real payoffs. No movement noise on every entrance (whooshes, slides, clicks, tap runs): viewers hate them and so do creators after one listen. A typical short has 2 to 4 sounds besides the music.\n\n## 1. Engine sounds (`sfx.extra`)\n\n| name | sound | when |\n|---|---|---|\n| `thud` | paper set down on a desk, soft and low | a number lands or dies, a strike, the payoff |\n| `pop` | soft woody blip | a real arrival: the logo or the result that matters |\n| `bell` | FM bell | a success, a check |\n| `tap` | trackpad click | a click shown on screen |\n| `tom` | low tom | a heavy beat, a step |\n| `slide`, `swish` | paper slide, layout move | movement noise: off by default (`sfx.swish: 0`) |\n\nThe scenes add their own cues (a `term` types with key clicks, a `counter` ticks); `sfx.accents` (default 0) adds glassy blips on key moments.\n\n## 2. Bundled files (`assets/sfx/`)\n\nLevelled on their own in the mix: `gain` = dB under the voice (default -10). `peak: true` puts the loudest instant on `at` (impacts). `endAt` makes a riser land on the payoff.\n\n| file | length | use it for | spec |\n|---|---|---|---|\n| `impact-boom.mp3` | 1.8 s | THE payoff, a big number, a hook verdict | `{at, src, peak: true, gain: -8}` |\n| `zhhh.mp3` | 1.1 s | the hook → content cut, with a `glitch` on the same `t` (once or twice a short) | `{at, src, peak: true, gain: -6}` |\n| `riser.mp3` | 2.6 s | build-up to a reveal | `{endAt: \u003creveal>, src, gain: -12}` |\n| `glass-shatter.mp3` | 1.6 s | a screen or a logo that breaks (`shatter` item) | `{at: \u003cbreakAt>, src, peak: true}` |\n| `pop-back.mp3` | 1.0 s | an Instagram UI coming back, a vanish ending | |\n\nThe full library (about 20 sounds: coin, unlock, mail ding, stamp, slam, crumple, dust, snip, gears, a hard-cut kit for fast styles...) comes with Editing IA Pro.\n\n## Example\n\n```js\nsfx: {\n swish: 0, accents: 0,\n extra: [[7.7, 'thud', -6]],\n files: [\n { at: 2.0, src: 'assets/sfx/zhhh.mp3', peak: true, gain: -6 },\n { at: 13.8, src: 'assets/sfx/impact-boom.mp3', peak: true, gain: -8 },\n ],\n},\nglitch: { keys: [[2.0, .4, 1]] },\n```\n\nCopy the two files first: `cp \u003cskill>/assets/sfx/{zhhh,impact-boom}.mp3 \u003cproject>/assets/sfx/`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2907,"content_sha256":"5dc0d1170f73ea68e32533be89f112d182d70270eff1b73f4f2ad24389f91831"},{"filename":"references/shorts-codes.md","content":"# Shorts codes (Reels, TikTok, YouTube Shorts)\n\n## Format\n\n- 1080x1920, 9:16, 30 fps, H.264, AAC 48k. Loudness -14 LUFS integrated, true peak at or below -1 dBTP.\n- Length: 30 to 60 s. Under 45 s is best for Reels and TikTok completion rate.\n\n## Safe zones (keep text and key visuals inside)\n\n- Top 220 px: platform header, search and tabs.\n- Bottom 420 px: caption, username, music ticker. The CTA sits at y at most 1420, and captions at y at most 1400.\n- Right 140 px, from y 900 to 1700: like, comment and share buttons. Keep text centred or left.\n- The safe content box is about x 60 to 940, y 220 to 1500.\n\n## Hook (first 1 to 2 s)\n\n- Frame 1 must already show the promise: a card, a number or a bold line. No blank intro, no logo sting, no \"salut\".\n- Start on speech. `tighten.lead` is .03 s.\n- A visual change lands within 1 s.\n\n## Pacing\n\n- Something moves every 2 to 4 s: a layout seam move, a new card, a counter, typing or a punch-in zoom.\n- Alternate proof (paper takes over) with face (full-bleed camera) so the viewer never sits on one layout.\n- SUPER FAST by default. Every micro silence is cut: the cutter finds the real quiet run in the audio at each word boundary and keeps only a tiny breath (`tighten.sentence` .08, `comma` .05, `word` .03). `speed: 1.1` (the creator style, from the profile `pace.speed`) plays the whole short faster (voice via atempo, no pitch shift). Expect a 64 s take to land around 44 s.\n\n## Mobile legibility\n\n- Everything is read on a phone. The minimum on-screen text is 34 px (UI details), labels are 38 to 42 px, and captions are 84 px.\n- If a label would need to be smaller to fit, shorten the label instead.\n\n## Captions\n\n- Always on (80%+ watch muted). They are word-synced, 1 to 3 words per chunk, and emphasis words are blue.\n- Hide captions when a scene shows the same words (title lines, typed prompt, CTA).\n- Numbers are written as digits (`replace: [['cent pour cent', '100%']]`).\n\n## Ending\n\n- A CTA card (follow or subscribe) in the last 2 to 3 s, with a pointer click.\n- The music resolves on a held chord so the loop back to frame 1 feels clean.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":2107,"content_sha256":"88bb28c7d9d940fb2bffd8a18550b465f71f3f21ce9fcc5dfd31bc5c8fc7a5ab"},{"filename":"references/style-schema.md","content":"# Creator profile: style.json\n\nIn this free pack the profile folder is `\u003cproject>/video-agent/` (it plays the role of `.short-editing/` below): `video-agent/styles/\u003cname>/style.json`, edited by hand. `profile.mjs` and `preview.mjs` belong to the paid kit; check a change with `short.mjs stills`.\n\n`\u003cproject>/.short-editing/styles/\u003cname>/style.json`: one per style, the default one included; `\u003cproject>/.short-editing/style.json` only names the default, `{\"default\": \"\u003cname>\"}` (an older profile keeps its default style right there). LOCAL only, the nearest `.short-editing/` from cwd up (home folder excluded, `$SHORT_EDITING_HOME` forces one). Never `~/.short-editing/`. Written only through `scripts/profile.mjs set [--style \u003cname>]` (validates, backs up to the style's `history/`, rewrites its `STYLE.md`). Every key is optional: a missing key keeps the engine default.\n\nLayer order at render: engine defaults \u003c `\u003cproject>/source/style.json` (snapshot copied by `short.mjs init`) \u003c the project's `spec.js`. A spec value always wins, and a short made before a profile change keeps its old snapshot.\n\n## Full example\n\n```json\n{\n \"version\": 1,\n \"name\": \"Craft\",\n \"creator\": {\n \"name\": \"Léa\", \"handle\": \"@lea.ia\", \"lang\": \"fr\",\n \"niche\": \"IA et productivité pour les indépendants, 25-40 ans\",\n \"cta\": { \"sub\": \"Plus de tests IA\", \"button\": \"S'abonner\", \"done\": \"Abonné\" }\n },\n \"colors\": { \"paper\": \"#EFE7DA\", \"ink\": \"#1F1A17\", \"accent\": \"#E8590C\" },\n \"font\": { \"family\": \"Satoshi\", \"src\": \"~/my-project/.short-editing/fonts/Satoshi-Variable.ttf\" },\n \"paper\": { \"grain\": 1 },\n \"captions\": { \"style\": \"pill\", \"size\": 84, \"weight\": 800, \"maxWords\": 3 },\n \"pace\": { \"speed\": 1.15, \"tighten\": { \"sentence\": 0.08, \"comma\": 0.05, \"word\": 0.03, \"lead\": 0.03, \"tail\": 0.8 } },\n \"visual\": \"mix\",\n \"music\": { \"bpm\": 112, \"drive\": false },\n \"mix\": { \"music\": -28, \"sfx\": -28.5, \"duck\": 0.5 },\n \"folders\": { \"aroll\": \"~/Movies/A-roll\", \"broll\": [\"~/Movies/B-roll\", \"~/Pictures/Screens\"], \"shorts\": \"~/Movies/Shorts\" },\n \"camera\": { \"w\": 1920, \"h\": 1080, \"crop\": [360, 0, 1200, 1080], \"eyes\": [600, 380] },\n \"images\": { \"provider\": \"codex\", \"model\": null, \"style\": \"flat editorial illustration, soft paper texture\", \"palette\": true },\n \"notes\": [\"Jamais de B-roll stock générique\", \"Toujours un chiffre clé en grand dans les 5 premières secondes\"]\n}\n```\n\n## Keys\n\n| Key | Values | Used by |\n|---|---|---|\n| `version` | `1` | |\n| `name` | label of the style (\"Craft\", \"Night\") | STYLE.md |\n| `creator.name` | name on the CTA card | `cta` scene (`withStyle`) |\n| `creator.handle` | `@handle`, no spaces | preview hook chip, agent copy |\n| `creator.lang` | ISO code `fr`, `en`, `es`... | `short.mjs words` (transcription language), default CTA words, agent copy |\n| `creator.niche` | one sentence: topic and audience | agent (hooks, titles, examples) |\n| `creator.cta.sub` / `button` / `done` | CTA card texts. Missing ones default by `lang` (fr: \"Plus de vidéos\", \"S'abonner\", \"Abonné\"; en, es, pt, de, it built in) | `cta` scene |\n| `colors.paper` / `ink` / `accent` | hex. Everything else (sheets, grays, lines, accent dark and tint) derives from these. Optional pins: `sheet`, `ink2`, `ink3`, `line`, `dim`, `accent2`, `tint`. `onAccent`: text color on the accent (caption blocks, `blue` chips, accent labels), default ink on a light accent and white otherwise. `pop`: a second label color (board `label` with `bg: 'pop'`, default ink), e.g. the podcast violet next to its yellow accent | `kit.setTheme` |\n| `font.family` | CSS family. Built in: `Inter`. A system font works on this Mac only; for anything else give `src` | every text |\n| `font.src` | `.ttf` / `.otf` / `.woff2`, copied into `$SHORT_EDITING_HOME/fonts/` by `profile.mjs font` | served as `/style-font` |\n| `paper.grain` | 0 (flat) to 1.5, default 1 | paper texture |\n| `paper.tooth` | 0 (none, default) to 1.5: embossed paper relief (fine tooth + soft waves of a real sheet) | background, sheets |\n| `paper.toothCell` | tooth bump size in px, default 2.5; 1 to 1.5 = a crisp fine tooth instead of a soft stucco | background, sheets |\n| `paper.waves` | 0 to 1.5, defaults to `tooth`: the large soft cockle of a real sheet (0 = off, it reads as blur on 16:9) | background |\n| `paper.mottle` | 0 to 1.5, defaults to `grain`: the large soft light/dark blotches (low = crisp grain and fibers only) | background, sheets |\n| `paper.depth` | 0 (flat) to 2, default 1: every shadow scales with it; above 1 adds an ambient shadow, a contact line, sheets lit from above and a key light on the paper | shadows, sheets, captions, seam |\n| `paper.bg` | `paper` (default, textured sheet), `clean` (flat light gradient + a soft accent glow) or `flat` (the paper color alone, no grain, relief or vignette: a solid color background like the nico-slowdown orange) | background |\n| `paper.dots` | `true` or `{pitch, r, alpha, color}` (40 px, 2 px, .07, white), `bg: 'flat'` only: a faint dot grid over the flat color, centered on the frame (a dark dotted UI panel over the camera) | background |\n| `paper.shadow` | `'r,g,b'` tint for every shadow and hairline, e.g. `'16,40,64'` (cool) for the clean look | shadows |\n| `layout.frame` | `true` or `{m, gap, bottom, r, border}`: in split the camera is a framed photo pinned on the paper (margins, rounded corners, white border, deep shadow); the window moves grow it to full screen | layout |\n| `layout.seam` | a number (e.g. `1240`): the camera card keeps ONE size for the whole short. `init` writes `layout.keys: [[0, \u003cseam>]]` in the starter spec; never add a move between two seams or a `zoom.keys` punch-in. Only 1920 (camera hidden) or 0 (full camera) when the style notes allow it | init, layout |\n| `grade` | e.g. nico-slowdown's moody look `\"eq=contrast=1.08:saturation=0.95:gamma=0.97,vignette=angle=PI/3,unsharp=5:5:0.6:5:5:0\"`; `true` (default grade fitted to the take's face exposure: a dim room gets brighter, less saturated, solid blacks; a well lit take keeps its exposure, only less orange and a bit less saturated; a touch of clarity) or an ffmpeg filter chain; baked into the camera at `prepare` | grade |\n| `layout.card` | `true` or `{x, y, w, h, r, zoom, bias, capY}`: the camera in one fixed rounded card at the bottom, stage on top (clean `slide` scenes) | layout |\n| `header` | `{brand, mark, tagline}`: a heavy italic wordmark top left (mark in the accent) and a gray tagline top right, on every frame of a card layout | header |\n| `captions.style` | one of `sticker pill box caps karaoke underline serif chip highlight outline punch light split tiles tilted typing accent stacked brackets bar clean rise hard stack band dim`, or 1 to 26 (board order). `dim` (speedy-devv): the phrase builds word by word on centered lines, spoken words in `captions.color` at `captions.dimWeight` (500), the current word in `captions.hi` at `captions.weight`, an `emph` word keeps a full-radius dark pill (`captions.pill`, default `#2B2B2B`) once said, each word blurs in. `band` (justyn-ai): the whole line on one flat block (`captions.bg`, default ink) in `captions.font` / `weight`, words white (`captions.color`), `emph` words in `captions.hi` (default the tint), hard in and out, no motion. `captions.bg: 'none'` (aiwithremy): no block at all, the white words sit on the video with a soft shadow (`captions.shadow: false` drops it), e.g. small lowercase Geist Pixel on the chest. `rise` and `hard` are block looks: the word being said sits on an accent block (`rise` blurs it in, sharp in ~.15 s, and fades it out in place, `hard` fades it in and out with a hard ink offset shadow); in split the line sits on the paper just above the camera (ink words), full screen on the video (white words). `captions.block: false` (rise only): no accent block, each white word appears when said and blurs in on its own (benkimball.ai). `stack` (nico-slowdown): full camera = a column of small bold words on short staggered lines with the key word huge in condensed Archivo 900 caps, white with a soft shadow, words popping in as said; paper only = the same column centered; split = only the word being said, on a white label straddling the seam (keys below) | live captions |\n| `captions.big` / `stretch` / `smallW` / `stagger` / `fullY` / `paperY` / `margin` / `color` | `stack` only: key word size as a multiple of `size` (3.2), its `font-stretch` (`extra-condensed`), max line width of the small words (380), horizontal stagger of the small lines (70), center y of the column on full camera (760) and on paper (960), side margin (90), word color (`#FFFFFF`) | live captions |\n| `captions.labelFont` / `labelWeight` / `labelSize` | `stack` only: the split label (word being said, accent on a white rect at the seam), default the captions font, 500, `size` | live captions |\n| `captions.size` | 32 to 130 px, default 84 (the style's own base, e.g. 42 for `clean`) | live captions |\n| `captions.weight` | 300 to 1000, default 800 (`rise` 900, `hard` 800) | live captions |\n| `captions.font` | CSS family for the captions only. Default: `font.family`, except `rise` (DM Sans) and `hard` (Bricolage Grotesque). Built in: Inter, DM Sans, Bricolage Grotesque, Archivo, Archivo Black, Poppins (600 only), Bowlby One SC (400 only, heavy wide small caps: lowercase letters draw as small capitals), Geist Pixel (the Square pixel cut of Geist, 400 only, monospaced bitmap look, aiwithremy captions) | live captions |\n| `display.font` / `display.weight` | family and weight of the big numbers: board `counter`, `stat` values, `depth` texts and board texts of 100 px and more, e.g. `Archivo Black` / 400 (a single-weight face). Unset = Inter 800 | `kit.D` |\n| `display.text` | family of the other board texts and counter units, e.g. `Archivo` | `kit.D` |\n| `display.title` | color token for board texts of 64 px and less that set no color, e.g. `ink2` | `kit.D` |\n| `display.flat` | `true` = drops the emboss of `depth` texts and counters (flat ink faces; counter suffix and unit stay in the accent) | `kit.D` |\n| `captions.tracking` | letter spacing in em of `size`, e.g. `-.08` to tighten a monospaced face (Geist Pixel for aiwithremy's proportional pixel captions); default -.012 em for block looks, -1.5 px otherwise | live captions |\n| `captions.space` | word gap as a multiple of the font's own space, e.g. `.6` for a pixel font whose space is too wide (aiwithremy); default 1 | live captions |\n| `captions.bold` | `band` captions: faux bold for a single-weight face, a square stroke in the text colour of `bold` x `size` px, e.g. `.05` to thicken Geist Pixel into aiwithremy's chunky pixel captions | live captions |\n| `captions.upper` | every caption word in capitals (any style), e.g. black Geist Mono chips | live captions |\n| `captions.radius` | `band` captions: corner radius of the block as a multiple of `size` (default `.06`; `.2` = a rounded chip) | live captions |\n| `captions.shadow` | `band` captions: `true` drops a soft shadow under the block (with `bg: 'none'`, `false` removes the shadow under the bare words) | live captions |\n| `captions.maxWords` | 1 to 8 words per chunk, default 3 | caption chunking |\n| `pace.speed` | 0.9 to 1.4, default 1.15 | timeline |\n| `pace.tighten` | breath kept after a `sentence` / `comma` / `word`, and `lead` / `tail` (seconds) | timeline |\n| `visual` | default visual mode: `motion` (motion design), `broll` (own footage full screen), `mix`; `ask` (old value) and absent = `motion`. Never asked: `/short-editing broll \u003csource>` forces B-roll for one short | agent, STYLE.md |\n| `music.bpm` | 60 to 180, default 112 (generated score, only for drum drops or a tape stop; the bed itself comes from the library, `spec.music.bed`) | score |\n| `music.drive` | `true` = four-on-the-floor, punchier | score |\n| `music.src` / `offset` | own track (mp3, wav, m4a) and start point in it (s). Replaces the generated bed, looped, ducked | `audio.mjs` |\n| `music.mute` | `true` = no music | `audio.mjs` |\n| `music.dropOn` / `dropLen` / `dropDepth` | `'punch'` / `'cut'` (or both): the bed drops out .3 s to 6 % under every punch-in / hard cut | `audio.mjs` |\n| `sfx.kit` | `{cut, pop, punch, gain: {cut, pop, punch}, gap}`: sound files (project-relative, ship them in the style's `assets/sfx/`, `init` copies them) played automatically on every cut, sticker / logo entrance and punch-in | `audio.mjs` kitHits |\n| `zoom.drift` | e.g. `.008`: slow push per shot (0.8 %/s, restarts on each cut and punch; 2 %/s read as aggressive) | `runtime.js` |\n| `zoom.wideOnly` | `true`: only the wide shots push (keyed zoom 1.02 or less), the punched-in shots hold still | `runtime.js` |\n| `captions.outline` | `{color, width: .07, until}` (stack captions): the hook's big words get a thick colored outline, until source `until` (default the first chunk's end) | `runtime.js` |\n| `captions.zoomDrop` | px (e.g. 320) per 1x of zoom: the full-camera stack slides down on a punch-in so it stays under the mouth | `runtime.js` |\n| `captions.tall` / `smallDisplay` / `snap` / `paperBig` | `stack` only: vertical stretch of the letters (1.5 = Nico's tall poster caps), small words in the same condensed display face (`true`), each word cuts in whole on its start with no fade (`true`), and on bare paper the words pack into full-width poster lines at `paperBig` x `size` (4.6) instead of one big word per line | `runtime.js` |\n| `cuts` | `'hard'`: every board, cutout and full-screen B-roll appears and leaves in one frame (a straight cut, no .26 s fade out, the next board does not wait); a scene, cutout or B-roll opts out with `hard: false` | `runtime.js`, `scenes.js` |\n| `mix.music` / `mix.sfx` | -45 to -10 LUFS (defaults -28 / -28.5) | mixer |\n| `sfx.accents` | 0 to 1.5 (default 0 = off) | a few glassy blips on key moments (logo or icon landing, blue bar full, speaker tag, strike), at most one every `sfx.accentGap` s (default 1.5) |\n| `mix.duck` | 0 to 1, how much the music dips under the voice (default 0.5) | mixer |\n| `folders.aroll` | folder of talking-head takes | `init --source latest` (newest video there) |\n| `folders.broll` | folder or list of folders of B-roll (own footage, screen recordings, photos), indexed into `broll/broll.db` | `broll.mjs scan`, agent |\n| `folders.shorts` | where new short projects go (`\u003cfolder>/short-\u003cslug>/`) | agent |\n| `camera` | `{w, h, crop: [x, y, w, h], eyes: [x, y]}` for takes of that size, from `profile.mjs camera \u003ctake>` | `init` (other sizes: face detection) |\n| `images.provider` | `auto` (codex, then openai, then gemini, whichever works), `codex`, `openai`, `gemini`, `none` | `engine/images.mjs` |\n| `images.model` | provider model override (openai default `gpt-image-2.5-flare`, gemini `gemini-3.1-flash-image`) | `images.mjs` |\n| `images.style` | style suffix added to every prompt | `images.mjs` |\n| `images.palette` | `true` (default): the profile colors are named in prompts | `images.mjs` |\n| `notes` | list of short rules the agent follows in every short | STYLE.md, agent |\n\nKeys: `ELEVEN_KEY` (transcription), `OPENAI_API_KEY`, `GEMINI_API_KEY` live in `$SHORT_EDITING_HOME/.env` (mode 600), never in style.json.\n\n## Validation\n\n`profile.mjs set` refuses a write with an error (bad hex, out-of-range number, unknown caption style, missing font or music file, bad camera shape, unknown image provider). It saves with warnings for weak contrast (ink on paper under 7, text on the accent under 3, accent on paper under 2.2: fine for a light accent like yellow used only as a fill, never as text on the paper) and folders that do not exist yet.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15547,"content_sha256":"2775113dca0f54a55bb872524eab09f05a57e122bd62ae690fd32c0ab9d40701"},{"filename":"references/style.md","content":"# The edit style\n\nOne take of a person talking, turned into a fast, clean short that holds attention with the sound off. The same rules every time, so a creator can post daily. The colors, fonts, captions, grade and pace come from the creator's style (`video-agent/styles/\u003cname>/style.json`, keys in [style-schema.md](style-schema.md), starting looks in `assets/looks.json`); a rule written in the style's `notes` wins over this page.\n\n## Cuts\n\n| Rule | Value | Why |\n|---|---|---|\n| Silences | every micro silence is cut by `prepare` (`tighten`) | dead air is where people swipe |\n| Retakes | keep the last complete attempt of a line, drop the earlier ones with `cut: [[a, b]]` | creators repeat a line until it sounds right |\n| False starts | drop a half word or \"uh\" right before a restart, the opening one included: a line said twice at the start (`J'ai testé tous les… j'ai testé tous les scripts`) starts the short at the second one | |\n| Start | the first kept word is the first word of the hook | no breath, no \"ok\", no look at the camera before |\n| Hidden retakes | whisper can still merge a retake: a word lasting over ~0.6 s, a repeated phrase, or a word that makes no sense (`clics cloud`) marks one. Listen to that spot (`ffmpeg -ss a -t 4`), or re-run `words` with an ElevenLabs key, and cut it; never patch it with `captions.replace` | the short must start clean |\n| Off-script talk | drop \"wait\", \"let me redo that\", asides to someone in the room | |\n| Speed | 1.15 (pitch kept), `pace.speed` of the style | no slack between sentences |\n\n## The views (`layout.keys`)\n\n| seam | what's on screen | when |\n|---|---|---|\n| **1160** split (default) | the paper on top with one board, the camera card below | most of the short |\n| **1920** paper | the camera leaves, one big board takes the frame | the big moment: the key number, a screenshot, a component |\n| **0** full camera | the face fills the frame | a story, an emotion, a punchline, the CTA; 5 s of short time or more |\n\n- A style with a fixed `layout.seam` keeps the camera card at that size for the whole short.\n- The window card plays on its own between split and full camera; never fake it with a slide.\n\n## Boards\n\n- One every 2 to 4 s while the person explains, each on the exact word it illustrates. Boards **illustrate**, they never paraphrase: a number, a command, a folder, a logo, a before/after, a real screen. 2 to 4 words of text at most.\n- The items (all in [scene-library.md](scene-library.md)): `text` with an `emph` word, `counter`, `term`, `tree`, `logo`, `check`, `chip`, `screen`, `bar`, `img`; whole scenes `tweet`, `stat`, `bars`, `title`, `cta`, `overlay` (a captured HTML component).\n- A named tool always shows its real logo. Never emojis as logos, never stock illustrations.\n- Vary them: never three text boards in a row.\n- No slanted or wobbling text, no text on the face.\n\n## Hook\n\n- The first 2 s, readable on frame 0, never blank paper.\n- 6 words or fewer, one accent word, never the same words as the spoken hook (it adds to it).\n- Something already moving: a terminal typing, a counter running, a component in action.\n\n## Captions\n\n- 1 to 3 words at a time, as spoken, on the seam in split view. The style picks the look (`captions.style`: `rise` = the word said on an accent block that blurs in, `hard` = hard shadow, ...).\n- `captions.emph` = the accent words, `captions.hide` = off while a board shows the same words.\n\n## Sound\n\n- Voice about -14 LUFS, true peak under -1 dBTP (`final` checks it).\n- One calm bed from the library ([music.md](music.md)), under the voice, breathing between sentences.\n- Hits only on real payoffs: a `thud` when a number lands or dies, a quiet `pop` on a real arrival. No whooshes, no clicks, no paper slides (`sfx: { swish: 0, accents: 0 }`). Bigger hits (the hook cut, the payoff) come from the bundled library: [sfx.md](sfx.md).\n\n## CTA\n\n\"Comment GUIDE and I'll send it\": full camera, the Instagram (or TikTok) comment sheet as an HTML overlay with the word typed in, then the DM notification (copy `examples/thinking-orbs/source/ig-cta.html`). The handle and avatar are the creator's.\n\n## Safe zone\n\nText stays between y 170 and y 1500 and clear of the right 220 px from y 1100 down ([shorts-codes.md](shorts-codes.md)).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":4265,"content_sha256":"92e6cfb1f567114450fb7f19e0c66f3ac8daa957aa281922e014b1cef8322eda"},{"filename":"references/sub-agents.md","content":"# Sub-agents: motion components in parallel\n\nThe main session is the supervisor. It runs on the strongest model, plans the beats, owns `spec.js` and runs every engine step. Each sub-agent builds ONE motion component, all of them at the same time, so a short full of custom UI takes the time of its slowest component instead of the sum of all of them.\n\n## Who does what\n\n| | supervisor (main session) | sub-agent (one per component) |\n|---|---|---|\n| reads | words, transcript, style, the whole spec | only its prompt and the files it names |\n| writes | `source/spec.js` (the only writer), `About.md`, `Roadmap.md` | `source/\u003cname>.html`, `assets/\u003cname>.mov` or `.mp4`, `stills/\u003cname>-*` |\n| runs | `prepare`, `stills`, `render`, `audio`, `final`, the studio | `short.mjs capture` on its own page only |\n\nA sub-agent never edits `spec.js`, `style.json`, the engine or another component's files, and never runs `prepare`, `render`, `audio` or `final`. A sub-agent cannot launch its own sub-agents: an agent that edits a whole short (`video-editing-agent`) builds its components itself, one after the other, with this same template.\n\n## What is a component\n\nAnything the scene library cannot draw well, written as an HTML page and turned into a clip by `short.mjs capture`:\n\n- a real app played in its own UI: a terminal typing a command, a chat answering, the Instagram comments of a CTA, a dashboard filling, a phone notification. A finger or a cursor does the actions. Every UI and motion design text (titles, labels, tags, counters included, captions excepted) uses the Apple system font (`\"SF Pro\"` first, `\"SF Mono\"` for monospace, never `-apple-system` alone), in its Text cut (`font-variation-settings: \"opsz\" 17`, letter-spacing 0, only titles of 60 px and up keep the Display cut), see design-system \"Type\";\n- a software idea played in a recreated UI that acts: code that scrolls then blacks out line by line, a lock that closes, a price that drops to 0 under a stamp; a platform named with a number in its real interface (the GitHub repo page, its stars curve, the Star button filling);\n- the hook's mini story: 2 or 3 acts with a reversal, several layers, an impact on each keyword, the payoff as a badge (e.g. the big apps crush the viewer's app under a REPLACED stamp, the story flips on the turning word, the viewer's app becomes the giant);\n- a fun illustration that acts: a mascot, an object or a character that does something, with a new action every 2 to 3 s;\n- a chart or a diagram, drawn and animated in code (a curve that draws, bars that fill), never a still.\n\nA component never pastes a screenshot or a generated image as the beat (the creator 2026-10-21: source code = motion design, never an image). A screenshot is a reference to rebuild in HTML; a generated illustration is a layer that moves and acts inside the page.\n\nThe engine-native beats (cards, tiles, counters, `term`, texts, `board`) stay with the supervisor: writing them in `spec.js` is faster than a round trip.\n\n## The supervisor's loop\n\n1. Read `files/words.md` and plan every beat (SKILL step 3).\n2. List the components. For each one, write down:\n - the beat id;\n - its SHORT start and end;\n - its size: `1080x1920 --alpha` for an `overlay` over the camera, the card's own size for an actor on the paper;\n - the spoken words over it with their τ (short time of the word minus the component's short start);\n - the assets it needs (logos in `assets/logos/`, the avatar, a reference image of a real UI to rebuild: never an image to paste).\n3. Launch one sub-agent per component in ONE message: several Agent calls, each in the background, general-purpose type. Leave out the model so they run on the supervisor's model. Up to about 6 at a time; past that, give one agent 2 small components.\n4. While they work, write the engine-native beats, run `prepare`, then stills on those beats.\n5. When a report lands, wire the clip in:\n - add `media: { \u003cid>: { src: 'assets/\u003cname>.mov', width: 1080, alpha: true } }` plus an `overlay` scene, or an actor with `media` for a card;\n - turn each reported event τ into `sfx.extra` (short time = component start + τ, then back to source time through `words.md`);\n - rerun `prepare` (the media cache follows the file, so a recaptured clip is picked up), then stills on that beat.\n6. To fix a component, send a message to the same agent (it keeps its context and its page). Never start a new agent for a fix.\n7. On a review round, use one agent per comment that needs a new or reworked component. The supervisor applies the cut, text and timing comments itself.\n\n## Prompt template (one per component)\n\nThe agent sees nothing of the conversation: fill every `\u003c...>` with real values and absolute paths.\n\n```text\nYou build ONE motion component for the short \u003cslug> (\u003cabsolute project path>). Skill engine: \u003cabsolute skill path>/engine/short.mjs.\n\nWhere it plays: \u003cover the full-screen camera, transparent, full frame 1080x1920 | as a card on the paper, W x H>, from short time \u003ca> s to \u003cb> s (\u003cdur> s).\nThe beat: \u003cwhat happens on screen, action by action, and what the viewer must feel>.\nVoice over it, τ = seconds since the component starts:\n\u003cτ word, one per line or as \"0.00 Ton, 0.12 monteur, ...\">\nLeave free: \u003cthe caption band, e.g. y 1180 to 1420, and the face if it is over the camera>.\n\nLook: font family \"\u003cstyle.json font.family>\" for every text (display: \"\u003cdisplay.family> \u003cweight>\"), paper \u003ccolors.paper>, ink \u003ccolors.ink>, accent \u003ccolors.accent>. Creator notes that apply, copied word for word from STYLE.md: \u003cthe style's originality and motion-design notes, always, plus the lines that matter here>. Never restate a caption, font or music rule from memory: the style owns them.\nAssets: \u003creal logos and images, paths relative to the HTML>.\n\nBuild:\n1. \u003cabsolute project path>/source/\u003cname>.html, a \u003cW>x\u003cH> page, \u003ctransparent background | its own background>. Every visual state is a pure function of t in window.setT(t) (seconds since the start): no CSS transitions or animations, no timers, no requestAnimationFrame, no Date. Ease with springs that never overshoot: S(x, w) = 1 - (1 + w x) e^(-w x). Fonts by family name (the engine loads Bricolage Grotesque, DM Sans, Inter, JetBrains Mono, Archivo, Instrument Serif); images by a path relative to the HTML.\n2. Capture: node \u003cengine>/short.mjs capture \u003cproject> source/\u003cname>.html --dur \u003cdur> \u003c--alpha | --size WxH> --at \u003cτ of the key moments, comma separated>\n It writes assets/\u003cname>.mov (alpha) or .mp4 and stills/\u003cname>-sheet.jpg.\n3. Read the sheet, then zoom the full frames that matter. Fix and capture again until the bar below passes. At least 2 rounds.\n\nBar:\n- It acts: every second shows an action (typing, scrolling, a curve climbing, a lock closing, a stamp landing), never a still image sitting on the frame. On each sheet ask « is something happening, or is it an image sitting there? ».\n- It looks like the real app (its layout, icons, colors and type), never a generic card. Real logos only, never a letter glyph.\n- A hook component is the most ambitious clip of the short: 2 or 3 acts with a reversal, several layers moving together, an impact on each keyword (its event in the report, a thud), the payoff as a badge. One object that slides is not a hook.\n- Something moves every .5 to 1 s, a new action every 2 to 3 s, each action lands on the word that says it.\n- Entries stagger and ease in; nothing pops, bounces or overshoots; nothing jitters between two frames.\n- Every text is 34 px or more at 1080 wide; important content stays inside x 60 to 940, y 220 to 1500.\n- Over the camera: the face stays visible and keeps its colours, except where the beat covers it on purpose. Never a full-frame dim over the face (8 % black at most, e.g. under an opening sheet: a 26 % veil turned the creator's face brown); a dark scrim only behind text, at an edge.\n- No em dash in any text.\n\nDo not touch any other file (not spec.js, not style.json, not the engine, not another component). Never run prepare, render, audio or final. Delete with trash, never rm -rf.\n\nReport, short: the clip path and its duration; the event list as \"τ what (tap, type, post, notification, impact) sound it wants\"; the sheet path; anything you are unsure about.\n```\n\n## Example\n\nA comment CTA: `source/ig-reel.html` draws the Instagram Reels UI over the full-screen camera. A finger taps the comments, the word is typed and posted, then the DM notifications drop in. `capture --alpha` turns it into `assets/ig-reel.mov`, the supervisor wires it as an `overlay` scene and puts its taps and posts in `sfx.extra`.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":8612,"content_sha256":"825984782018501a9f22d0ab77db32397586aebb65c662ba2bacbd8c84815246"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"Short Editing","type":"text"}]},{"type":"paragraph","content":[{"text":"One raw take in, one finished video out: ","type":"text"},{"text":"video-agent/edits/short-\u003cslug>/output/short-\u003cslug>.mp4","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"The engine ships in this skill folder (","type":"text"},{"text":"\u003cskill>","type":"text","marks":[{"type":"code_inline"}]},{"text":" below): ","type":"text"},{"text":"\u003cskill>/engine/short.mjs","type":"text","marks":[{"type":"code_inline"}]},{"text":". It is a deterministic canvas renderer driven by ONE file per short, ","type":"text"},{"text":"source/spec.js","type":"text","marks":[{"type":"code_inline"}]},{"text":". The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp.","type":"text"}]},{"type":"paragraph","content":[{"text":"Read before writing a spec:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/shorts-codes.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/shorts-codes.md","title":null}}]},{"text":": format, safe zones, hook and pace","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/design-system.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/design-system.md","title":null}}]},{"text":": palette, motion, paper, what is forbidden","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/scene-library.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/scene-library.md","title":null}}]},{"text":": every scene and item, every parameter, the time keys","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/music.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/music.md","title":null}}]},{"text":": the 8 beds, which fits which hook, where to start it","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"references/style.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/style.md","title":null}}]},{"text":": the house rules of the edit","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"examples/","type":"text","marks":[{"type":"link","attrs":{"href":"examples/README.md","title":null}}]},{"text":": real shorts made with this engine (spec, components, contact sheets). Open the one closest to the request before planning: it is the quality bar.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"How to run it","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Talk in the creator's language (","type":"text"},{"text":"creator.lang","type":"text","marks":[{"type":"code_inline"}]},{"text":" of the style, else ","type":"text"},{"text":"video-agent/voice.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", else English). Say in one line what each long step does and how long it takes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":": lowercase, dashes, no dots or spaces.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every command runs from the project root (the folder that holds ","type":"text"},{"text":"video-agent/","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"0. Setup (first time only, then a 1 s check)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/setup.mjs --check || node \u003cskill>/engine/setup.mjs","type":"text"}]},{"type":"paragraph","content":[{"text":"It installs what is missing: the engine packages (15 MB), a Chromium when no Chrome is installed (100 MB), whisper.cpp and its speech model (0.5 GB, once), the 8 music beds and the icons (generated on this computer). Tell them the first run takes 3 to 6 minutes. ffmpeg missing: it prints the command to install it (","type":"text"},{"text":"brew install ffmpeg","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"winget install Gyan.FFmpeg","type":"text","marks":[{"type":"code_inline"}]},{"text":"); run it with their ok, then setup again.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"1. The take","type":"text"}]},{"type":"paragraph","content":[{"text":"Use the file they name, else the newest file in ","type":"text"},{"text":"video-agent/takes/","type":"text","marks":[{"type":"code_inline"}]},{"text":". A URL (YouTube, X, Instagram, Loom, a direct mp4): ","type":"text"},{"text":"yt-dlp -f \"bv*+ba/b\" --merge-output-format mp4 -o \"video-agent/takes/\u003cslug>.%(ext)s\" \"\u003curl>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" first.","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"ffprobe -v error -show_entries format=duration:stream=codec_type,width,height -of compact \"\u003ctake>\"","type":"text"}]},{"type":"paragraph","content":[{"text":"No audio stream: stop and say so. Then create the project:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs init video-agent/edits/short-\u003cslug> --source \"\u003ctake>\" [--style \u003cname>]","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The style: ","type":"text"},{"text":"style \u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the request = ","type":"text"},{"text":"video-agent/styles/\u003cname>/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", passed as ","type":"text"},{"text":"--style \u003cname>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (only when that folder exists: an unknown name stops ","type":"text"},{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":", so list ","type":"text"},{"text":"video-agent/styles/","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead). Without it, ","type":"text"},{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":" takes the default one (","type":"text"},{"text":"video-agent/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" names it). No style at all: first write ","type":"text"},{"text":"video-agent/styles/default/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" = ","type":"text"},{"text":"{\"version\": 1, \"name\": \"default\"}","type":"text","marks":[{"type":"code_inline"}]},{"text":" plus the ","type":"text"},{"text":"style","type":"text","marks":[{"type":"code_inline"}]},{"text":" of the ","type":"text"},{"text":"default","type":"text","marks":[{"type":"code_inline"}]},{"text":" look of ","type":"text"},{"text":"\u003cskill>/assets/looks.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (system font, camera full width, mono caps captions on a black band), and ","type":"text"},{"text":"video-agent/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" = ","type":"text"},{"text":"{\"default\": \"default\"}","type":"text","marks":[{"type":"code_inline"}]},{"text":", then say once that ","type":"text"},{"text":"/studio-setup style","type":"text","marks":[{"type":"code_inline"}]},{"text":" makes it theirs in 2 minutes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":" makes ","type":"text"},{"text":"assets/ files/ source/ output/ stills/","type":"text","marks":[{"type":"code_inline"}]},{"text":", copies the take to ","type":"text"},{"text":"assets/source.mp4","type":"text","marks":[{"type":"code_inline"}]},{"text":", frames ","type":"text"},{"text":"cam","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the face, writes the starter ","type":"text"},{"text":"source/spec.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" and snapshots the style into ","type":"text"},{"text":"source/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (engine defaults \u003c style \u003c spec: the spec only sets what differs for this one short). The style's ","type":"text"},{"text":"assets/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (logos, images) are copied into the project.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Format: ","type":"text"},{"text":"9:16","type":"text","marks":[{"type":"code_inline"}]},{"text":" by default. ","type":"text"},{"text":"1:1","type":"text","marks":[{"type":"code_inline"}]},{"text":" = ","type":"text"},{"text":"format: 'square'","type":"text","marks":[{"type":"code_inline"}]},{"text":" (X feed), ","type":"text"},{"text":"16:9","type":"text","marks":[{"type":"code_inline"}]},{"text":" = ","type":"text"},{"text":"format: 'wide'","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the spec.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"2. Words","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs words video-agent/edits/short-\u003cslug> [lang]","type":"text"}]},{"type":"paragraph","content":[{"text":"whisper.cpp on this computer, about real time (a 60 s take, about a minute), free. With an ElevenLabs key (","type":"text"},{"text":"ELEVENLABS_API_KEY=","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"ELEVEN_KEY=","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"video-agent/.env","type":"text","marks":[{"type":"code_inline"}]},{"text":", the project ","type":"text"},{"text":".env","type":"text","marks":[{"type":"code_inline"}]},{"text":" or the environment), ElevenLabs Scribe instead, on its own: it hears every retake and false start that whisper smooths over, and names; a few cents a take. Recommend it once when a take has retakes. Never ask for a key in chat, never print one.","type":"text"}]},{"type":"paragraph","content":[{"text":"Then ","type":"text"},{"text":"prepare","type":"text","marks":[{"type":"code_inline"}]},{"text":" (step 4) once: it writes ","type":"text"},{"text":"files/words.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", every word with its SOURCE time and the SHORT time it lands on after the cuts. Fix misheard words from the script with ","type":"text"},{"text":"captions.replace: [['clics cloud', 'skills Claude']]","type":"text","marks":[{"type":"code_inline"}]},{"text":", never by editing the times.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"3. The spec","type":"text"}]},{"type":"paragraph","content":[{"text":"Author ","type":"text"},{"text":"source/spec.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"files/words.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". All times are SOURCE seconds; the engine maps them onto the tightened cut. Start from the starter spec ","type":"text"},{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":" wrote, and from the closest example.","type":"text"}]},{"type":"paragraph","content":[{"text":"Plan the beats first, in a comment block at the top of the spec:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hook","type":"text","marks":[{"type":"strong"}]},{"text":" (first 2 s): readable on the first frame, never blank paper at t=0. It adds to the spoken hook, never repeats it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Something changes every 2 to 4 s","type":"text","marks":[{"type":"strong"}]},{"text":": a new board, a counter that counts, a terminal that types, a logo that lands, a line struck. Each on the exact word it illustrates (","type":"text"},{"text":"at","type":"text","marks":[{"type":"code_inline"}]},{"text":" = that word's source time).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Pick the item from what is said","type":"text","marks":[{"type":"strong"}]},{"text":" (all in scene-library): a figure = ","type":"text"},{"text":"counter","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"countFrom","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"countTo","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"strikeAt","type":"text","marks":[{"type":"code_inline"}]},{"text":" when it dies); a command or a prompt = ","type":"text"},{"text":"term","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"typeFrom","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"typeTo","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"out","type":"text","marks":[{"type":"code_inline"}]},{"text":" lines); a folder or a list of parts = ","type":"text"},{"text":"tree","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a tool named = its real ","type":"text"},{"text":"logo","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a result = ","type":"text"},{"text":"check","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a real screen = ","type":"text"},{"text":"screen","type":"text","marks":[{"type":"code_inline"}]},{"text":" (zoom keys + rings on the number they read); a tweet = ","type":"text"},{"text":"tweet","type":"text","marks":[{"type":"code_inline"}]},{"text":"; before / after = two boards or ","type":"text"},{"text":"bars","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the big payoff = ","type":"text"},{"text":"stat","type":"text","marks":[{"type":"code_inline"}]},{"text":" or a board with one huge ","type":"text"},{"text":"text","type":"text","marks":[{"type":"code_inline"}]},{"text":" and an ","type":"text"},{"text":"emph","type":"text","marks":[{"type":"code_inline"}]},{"text":" word.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Camera","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"layout.keys: [[t, seam]]","type":"text","marks":[{"type":"code_inline"}]},{"text":", seam 1160 = split (paper above, camera card below), 1920 = paper alone, 0 = full camera. Full camera only for the direct lines (a story, a reaction, the CTA) and for 5 s or more of SHORT time; a style with a fixed ","type":"text"},{"text":"layout.seam","type":"text","marks":[{"type":"code_inline"}]},{"text":" keeps that one size all along.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every beat is an action","type":"text","marks":[{"type":"strong"}]},{"text":", never an image sitting there. A software idea (source code, privacy, price, speed) is played in a recreated UI: code that scrolls then blacks out line by line, a lock that closes, a price that drops to 0 under a stamp. A platform named with a number is its real interface recreated and animated (the GitHub repo page, the stars curve settling on the real figure). Never a screenshot pasted as an image (even zoomed or circled), a lone logo, a text card or a still illustration: a screenshot is only a reference to rebuild in HTML.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The hook gets the most work","type":"text","marks":[{"type":"strong"}]},{"text":": a 2 or 3 act mini story with a reversal, several layers moving together, an impact (stamp + thud) on each keyword, the payoff as a badge. For example: the big apps crush the viewer's app under a REPLACED stamp; on the turning word (\"now\") it flips, the viewer's app becomes the giant. A hook that places one object and slides it is not enough: rebuild it richer, never tweak it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Motion components","type":"text","marks":[{"type":"strong"}]},{"text":": what the library cannot draw well (a real app in its own UI, an Instagram comment sheet, a notification, a GitHub repo page, a software idea, the hook's mini story, an illustration that acts) becomes an HTML page with ","type":"text"},{"text":"window.setT(t)","type":"text","marks":[{"type":"code_inline"}]},{"text":", captured into a clip (step 3b). The examples hold a dozen of them to copy.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Captions","type":"text","marks":[{"type":"strong"}]},{"text":": on by default, on the seam. ","type":"text"},{"text":"captions.emph","type":"text","marks":[{"type":"code_inline"}]},{"text":" = the words in the accent color, ","type":"text"},{"text":"captions.hide: [[a, b]]","type":"text","marks":[{"type":"code_inline"}]},{"text":" over a board that carries the same words.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Music","type":"text","marks":[{"type":"strong"}]},{"text":": one bed from ","type":"text"},{"text":"music.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/music.md","title":null}}]},{"text":" picked by the hook's emotion, ","type":"text"},{"text":"music: { bed: '\u003cid>', offset }","type":"text","marks":[{"type":"code_inline"}]},{"text":". Never the same bed as the previous short.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sound","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"sfx: { swish: 0, accents: 0, extra: [[t, 'thud', -6]] }","type":"text","marks":[{"type":"code_inline"}]},{"text":": no movement noise, a ","type":"text"},{"text":"thud","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"pop","type":"text","marks":[{"type":"code_inline"}]},{"text":" only on a real payoff. Bigger moments (the hook cut, the payoff, a shatter) take a bundled file from ","type":"text"},{"text":"assets/sfx/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"zhhh","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"impact-boom","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"riser","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"glass-shatter","type":"text","marks":[{"type":"code_inline"}]},{"text":"...), all in ","type":"text"},{"text":"references/sfx.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sfx.md","title":null}}]},{"text":". Never generate a sound with ElevenLabs unless the creator asks for one the library lacks.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Real logos, always","type":"text","marks":[{"type":"strong"}]},{"text":": every tool or app named on screen shows its real icon in ","type":"text"},{"text":"assets/logos/\u003cname>.png","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the App Store icon, the official site or repo), never a letter glyph, never an emoji, never a look-alike. Download it into the project and say from where.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No em-dashes in any on-screen text.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"3b. Motion components","type":"text"}]},{"type":"paragraph","content":[{"text":"One HTML file per component in ","type":"text"},{"text":"source/\u003cname>.html","type":"text","marks":[{"type":"code_inline"}]},{"text":", 1080 wide (or the card size), every animation a pure function of ","type":"text"},{"text":"t","type":"text","marks":[{"type":"code_inline"}]},{"text":" set by ","type":"text"},{"text":"window.setT(t)","type":"text","marks":[{"type":"code_inline"}]},{"text":" (no CSS animation, no timers), transparent background for an overlay. See ","type":"text"},{"text":"references/sub-agents.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/sub-agents.md","title":null}}]},{"text":" for the page contract and the prompt to give a sub-agent (one per component, in parallel, when the harness has sub-agents; else build them one after the other). Capture:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs capture video-agent/edits/short-\u003cslug> source/\u003cname>.html --dur 4.5 --alpha --out assets/\u003cname>.mov","type":"text"}]},{"type":"paragraph","content":[{"text":"Read its ","type":"text"},{"text":"stills/\u003cname>-sheet.jpg","type":"text","marks":[{"type":"code_inline"}]},{"text":", then wire it: ","type":"text"},{"text":"media: { x: { src: 'assets/\u003cname>.mov', width: 1080, alpha: true } }","type":"text","marks":[{"type":"code_inline"}]},{"text":" plus an ","type":"text"},{"text":"overlay","type":"text","marks":[{"type":"code_inline"}]},{"text":" scene (or an actor on the board), see ","type":"text"},{"text":"examples/thinking-orbs/source/spec.js","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"4. Prepare","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs prepare video-agent/edits/short-\u003cslug>","type":"text"}]},{"type":"paragraph","content":[{"text":"Cuts every micro silence, applies the speed (1.15 by default), splices the voice, extracts the camera and media frames (cached), writes ","type":"text"},{"text":"assets/timeline.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"files/words.md","type":"text","marks":[{"type":"code_inline"}]},{"text":". Rerun it after a change to ","type":"text"},{"text":"cut","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cam","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"media","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"cut: [[a, b]]","type":"text","marks":[{"type":"code_inline"}]},{"text":" (source time) drops a retake or a false start: walk the script, keep the last complete attempt of each line.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"5. Stills, then critique (the main loop)","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs stills video-agent/edits/short-\u003cslug> 0.3 2.9s 6.4s 9s 12.9s","type":"text"}]},{"type":"paragraph","content":[{"text":"Plain numbers = SHORT time, an ","type":"text"},{"text":"s","type":"text","marks":[{"type":"code_inline"}]},{"text":" suffix = SOURCE time. Take one still per beat (the moment it is complete), plus the hook and the last second. Read ","type":"text"},{"text":"stills/contact.jpg","type":"text","marks":[{"type":"code_inline"}]},{"text":", then zoom the full-size stills, and check the quality bar below. Fix the spec, stills again. Stills cost seconds, renders cost minutes: iterate here.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"6. Render, sound, final","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node \u003cskill>/engine/short.mjs all video-agent/edits/short-\u003cslug>","type":"text"}]},{"type":"paragraph","content":[{"text":"Render + audio (ducked voice, music bed, SFX, mixed beside the render) + final (mux, poster, QC), all at once. On an Apple Silicon Mac a 36 s short takes about 5 s (the render alone about 4 s, 250 fps, WebCodecs H.264 inside Chrome with 2 workers); slower machines take longer, so run it in the background and say how long. After a sound change, rerun only ","type":"text"},{"text":"audio","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"final","type":"text","marks":[{"type":"code_inline"}]},{"text":". The QC line must read ","type":"text"},{"text":"h264 1080 1920","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or ","type":"text"},{"text":"1080 1080","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"1920 1080","type":"text","marks":[{"type":"code_inline"}]},{"text":"), a duration equal to the timeline, about -14 LUFS and a true peak at or under -1 dBTP (else ","type":"text"},{"text":"mix: { ceil: -2.2 }","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the spec, then ","type":"text"},{"text":"audio","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"final","type":"text","marks":[{"type":"code_inline"}]},{"text":" again). Draft: ","type":"text"},{"text":"render ... --scale .5","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quality bar (every contact sheet)","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The hook reads on the first frame.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every beat acts. On each beat's still, ask « is something happening, or is it an image sitting there? ». A sitting image (a screenshot, a still illustration, a logo next to a counter, a text card) is a component to redo before rendering.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every board fills the paper: big mobile text (nothing under 34 px, captions about 84 px), 1 to 3 lines, nothing cut at the edges, nothing in the safe zones of ","type":"text"},{"text":"shorts-codes.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/shorts-codes.md","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Captions never overlap a card, a CTA or a chat composer (","type":"text"},{"text":"captions.hide","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"One accent color per moment; accent words only from ","type":"text"},{"text":"captions.emph","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"emph","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Cards never change size; the camera card never resizes. A full-camera stretch lasts 5 s or more.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"No ghost of the old beat under the new one, no counter parked on 0, no two values stacked in one slot.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Camera colors natural: bright warm skin, never grey or greenish (","type":"text"},{"text":"stills/grade.jpg","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Every named tool shows its real logo.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Sound: the voice on top, the bed breathing between sentences, hits only on real payoffs, no whooshes.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"7. Deliver","type":"text"}]},{"type":"paragraph","content":[{"text":"In ","type":"text"},{"text":"video-agent/edits/short-\u003cslug>/output/","type":"text","marks":[{"type":"code_inline"}]},{"text":": ","type":"text"},{"text":"short-\u003cslug>.mp4","type":"text","marks":[{"type":"code_inline"}]},{"text":" and its poster. Then write next to them:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"short-\u003cslug>.srt","type":"text","marks":[{"type":"code_inline"}]},{"text":": from ","type":"text"},{"text":"files/words.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the SHORT times), up to 7 words per cue, a new cue after each sentence.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"post.md","type":"text","marks":[{"type":"code_inline"}]},{"text":": the post copy (below).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Open the video so they watch it right away: ","type":"text"},{"text":"open \u003cmp4>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (macOS), ","type":"text"},{"text":"start \"\" \"\u003cmp4>\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Windows), ","type":"text"},{"text":"xdg-open \u003cmp4>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (Linux). Say the length, what was cut, the bed and why, and offer one change (\"another hook? a board on another word? full camera on the story?\"). A change = edit ","type":"text"},{"text":"spec.js","type":"text","marks":[{"type":"code_inline"}]},{"text":", stills, ","type":"text"},{"text":"all","type":"text","marks":[{"type":"code_inline"}]},{"text":" again. A change they want on every video (accent, font, captions, pace) goes in their style (","type":"text"},{"text":"video-agent/styles/\u003cname>/style.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", schema in ","type":"text"},{"text":"style-schema.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/style-schema.md","title":null}}]},{"text":"), not only in this spec. Next: ","type":"text"},{"text":"/publish short-\u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"On their first finished video only (the only folder in ","type":"text"},{"text":"video-agent/edits/","type":"text","marks":[{"type":"code_inline"}]},{"text":"), end with one line in their language: a one-minute review of the free pack helps make it better, https://codelynx.dev/editing/feedbacks. Never again after that.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Post copy","type":"text"}]},{"type":"paragraph","content":[{"text":"post.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", from the script and voice.md, in the language of the video:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Title","type":"text","marks":[{"type":"strong"}]},{"text":" (YouTube Shorts): under 60 characters, the topic in the first 3 words.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Caption","type":"text","marks":[{"type":"strong"}]},{"text":": the first 125 characters work on their own. Then 1 or 2 short lines and one ask (the CTA of the style).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Search words","type":"text","marks":[{"type":"strong"}]},{"text":": the 2 or 3 words their viewer would type, written plainly in the title or the first line.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hashtags","type":"text","marks":[{"type":"strong"}]},{"text":": 3 to 5 about the topic. No #fyp or #viral.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Nothing the video doesn't say.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Run the humanize pass of ","type":"text"},{"text":"studio-script","type":"text","marks":[{"type":"code_inline"}]},{"text":" (its ","type":"text"},{"text":"references/humanize.md","type":"text","marks":[{"type":"code_inline"}]},{"text":") on it when that skill is installed.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"CapCut or Premiere","type":"text"}]},{"type":"paragraph","content":[{"text":"Some creators prefer their editor. Do steps 0 to 4 (the spec with no scenes), then give them ","type":"text"},{"text":"files/words.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (the cut, word by word) and the plain cut: ","type":"text"},{"text":"node \u003cskill>/engine/short.mjs rough video-agent/edits/short-\u003cslug>","type":"text","marks":[{"type":"code_inline"}]},{"text":" writes ","type":"text"},{"text":"output/short-\u003cslug>-rough.mp4","type":"text","marks":[{"type":"code_inline"}]},{"text":" (every silence cut, full frame, no motion design) to import, with the ","type":"text"},{"text":".srt","type":"text","marks":[{"type":"code_inline"}]},{"text":" of step 7.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Paper-only (no take)","type":"text"}]},{"type":"paragraph","content":[{"text":"silent: true","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"duration: \u003cs>","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the spec, ","type":"text"},{"text":"init","type":"text","marks":[{"type":"code_inline"}]},{"text":" without ","type":"text"},{"text":"--source","type":"text","marks":[{"type":"code_inline"}]},{"text":", skip step 2. No voice, camera or captions; the music and the on-screen text carry it, same pace rule.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Honest limits","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"whisper.cpp mishears names and jargon: fix them with ","type":"text"},{"text":"captions.replace","type":"text","marks":[{"type":"code_inline"}]},{"text":", check numbers by eye.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"A cut can clip a breath or a soft last syllable: watch the result once before posting.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Claude cannot hear the take: it trusts the timings. Bad audio (music, echo, two voices) makes them worse.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Face framing is exact on macOS (Apple Vision); on Windows and Linux it uses MediaPipe in Chrome, check ","type":"text"},{"text":"cam","type":"text","marks":[{"type":"code_inline"}]},{"text":" on the first still.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"The review studio, the Recorder, the B-roll library, the faceless mascot and the thumbnails come with the paid kit, Editing IA Pro.","type":"text"}]}]}]}]},"metadata":{"date":"2026-11-02","name":"short-editing","author":"@skillopedia","source":{"stars":88,"repo_name":"video-agent-skills","origin_url":"https://github.com/Melvynx/video-agent-skills/blob/HEAD/skills/short-editing/SKILL.md","repo_owner":"Melvynx","body_sha256":"28f01905fbe4bc249368bd7550b0e621d0e3726219306e65112ae6bff523d03f","cluster_key":"c26a5fab920e433cf3137f48e990a232a126cbb4446f3197c6fef14bfbaca1d5","clean_bundle":{"format":"clean-skill-bundle-v1","source":"Melvynx/video-agent-skills/skills/short-editing/SKILL.md","text_bytes":1046965,"source_commit":"4774fa4f2601baa822fd513387202c26e4587902","skills_sh_rank":16942,"attachment_count":64,"text_attachments":64,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"skills/short-editing/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"media-content","category_label":"Media"},"exact_dupes_collapsed_into_this":0},"version":"v1","category":"media-content","import_tag":"skills-sh-top20k-2026-11","description":"Edits a talking-head take end to end into a paper-style motion design short (1080x1920, also 1:1 and 16:9) with its own render engine (Node + Chrome + ffmpeg, free, offline). Use when the user says edit, cut, captions, subtitles, motion design, montage, \"make it a short\", render, or gives a recording from video-agent/takes/. Transcribes locally with word timings (whisper.cpp, or ElevenLabs when a key is set), cuts every silence, lays the boards on a textured paper above the camera card (counters, terminals, logos, trees, real screens, HTML motion components), word-by-word captions, a calm music bed, a few designed hits, -14 LUFS, and writes the mp4, an .srt and the post copy.","allowed-tools":"Bash(node *) Bash(ffmpeg *) Bash(ffprobe *) Bash(open video-agent/edits/*)","argument-hint":"\u003ctake file or slug> [9:16 | 1:1 | 16:9] [style \u003cname>] [brief]"}},"renderedAt":1793676488980}

Short Editing One raw take in, one finished video out: . The engine ships in this skill folder ( below): . It is a deterministic canvas renderer driven by ONE file per short, . The design is a paper stage on top and the camera below, one accent color, springs that never overshoot, no pulse, no bounce. Everything runs on the creator's computer, macOS, Windows or Linux: Node 20+, ffmpeg, a Chrome (or the headless Chromium setup installs), whisper.cpp. Read before writing a spec: - references/shorts-codes.md: format, safe zones, hook and pace - references/design-system.md: palette, motion, paper…