{"id":"pinepaper-in-a-minute","title":"PinePaper in a Minute","category":"Drawn film","prompt":"PinePaper in a Minute (v2) — a 72-second, 120 BPM landing film written directly as a draw contract: describe, relate, edit by hand, then a Vox-style explainer, a motion UI mockup, illustrated scenes, a map, diagrams and data, every format, and a note from the team; a bright C-major score with a whoosh on every cut.","designedBy":{"provider":"anthropic","model":"claude-opus-5-5 (written directly)","inference":{"measured":false,"inputTokens":2487,"outputTokens":12764,"costUsd":0.6631,"inPerMTokUsd":10,"outPerMTokUsd":50,"estimated":true,"basis":"estimated as one chat turn — the draw-contract prompt and brief in, the 47,224-character program out, at ~3.7 characters per token; the model is not in the rate catalogue, so it is priced at the catalogue ceiling"}},"render":{"credits":72,"priceUsd":1.08,"format":"mp4","renderMs":364218,"frameCount":2160,"bytes":8826088,"seed":11,"width":1920,"height":1080,"fps":30,"durationSeconds":72},"howToReproduce":{"renderApi":"POST /v1/renders with `renderRequest` below as the body (Authorization: Bearer <your pp_sa_… key>).","chat":"Paste the prompt into /chat: an illustrated film brief is answered with a program like this one, and plays at once.","note":"This is a draw contract — a Canvas 2D program. It has no editor ops; it opens in the Cloud Studio as a film (/studio?gallery-film=pinepaper-in-a-minute), and it exports as MP4 or GIF, not as a still."},"renderRequest":{"drawContract":"const meta = { duration: 72, fps: 30, size: [1920, 1080], seed: 11 };\n\n// ─────────────────────────────────────────────────────────────────────────────────────────────\n// PINEPAPER IN A MINUTE (v2) — describe it, relate it, edit it by hand, then everything else it\n// makes: a Vox-style explainer, a video edit, a graded photo, a map, diagrams and data, every\n// format — and a note from the team. 120 BPM: one bar is 2 s, and every cut lands on a bar line.\n// ─────────────────────────────────────────────────────────────────────────────────────────────\n\nconst W = 1920, H = 1080, BAR = 2;\nconst INK = { ground: '#07090f', ground2: '#141a3a', paper: '#f6f1e6', paper2: '#efe7d6', edge: '#d9cfbb', ink: '#161a28',\n  violet: '#7c6cff', violetSoft: '#a99dff', cyan: '#38bdf8', amber: '#fbbf24', coral: '#fb7185', red: '#e11d48',\n  pink: '#f472b6', mint: '#34d399', lime: '#a3e635', highlight: 'rgba(250,204,21,0.55)', muted: '#8b93a7', dim: '#5b6378' };\n\n// Scene windows (seconds). CUTS are where the paper swipe and the whoosh land.\nconst S = { title: [0, 6], describe: [6, 14], edit: [14, 20], vox: [20, 28], video: [28, 36], image: [36, 44],\n  map: [44, 52], data: [52, 58], ship: [58, 64], end: [64, 72] };\nconst CUTS = [6, 20, 28, 36, 44, 52, 58, 64];\nconst CHIPS = [['01', 'Describe it', 6, 11], ['02', 'Relate it', 11, 14], ['03', 'Edit it by hand', 14, 20], ['04', 'Explain anything', 20, 28],\n  ['05', 'Motion UI & editors', 28, 36], ['06', 'Illustrated scenes', 36, 44], ['07', 'Map a journey', 44, 52], ['08', 'Diagrams & data', 52, 58], ['09', 'Ship everywhere', 58, 64]];\n\nconst clamp = (v, a = 0, b = 1) => Math.max(a, Math.min(b, v));\nconst ease = (u) => { u = clamp(u); return u * u * (3 - 2 * u); };\nconst easeOut = (u) => 1 - Math.pow(1 - clamp(u), 3);\nconst back = (u) => { u = clamp(u); const c = 1.9; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); };\nconst lerp = (a, b, u) => a + (b - a) * u;\nconst win = (t, from, to, fin = 0.4, fout = 0.4) => Math.min(ease((t - from) / fin), ease((to - t) / fout));\nconst inScene = (t, k) => t >= S[k][0] - 0.05 && t <= S[k][1] + 0.4;\nconst FONT = (w, px) => `${w} ${px}px Roboto, \"Helvetica Neue\", Arial, sans-serif`;\nconst MONO = (px) => `500 ${px}px \"Roboto Mono\", Menlo, \"DejaVu Sans Mono\", monospace`;\n// The render container has Liberation Serif and DejaVu Serif (the render container), not Georgia — name them.\nconst HAND = (px) => `italic 600 ${px}px \"Liberation Serif\", \"DejaVu Serif\", Georgia, serif`;\n\nfunction roundRect(ctx, x, y, w, h, r) {\n  ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r);\n  ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath();\n}\nfunction label(ctx, text, x, y, font, color, align = 'center') { ctx.font = font; ctx.fillStyle = color; ctx.textAlign = align; ctx.fillText(text, x, y); ctx.textAlign = 'left'; }\n// A stroke that draws itself: pts is a polyline, prog 0..1 of its length.\nfunction drawPartial(ctx, pts, prog) {\n  let total = 0; const seg = [];\n  for (let i = 1; i < pts.length; i++) { const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); seg.push(d); total += d; }\n  let left = total * clamp(prog); ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]);\n  for (let i = 1; i < pts.length && left > 0; i++) {\n    const u = Math.min(1, left / seg[i - 1]); ctx.lineTo(lerp(pts[i - 1][0], pts[i][0], u), lerp(pts[i - 1][1], pts[i][1], u)); left -= seg[i - 1];\n  }\n  ctx.stroke();\n}\nfunction pointAt(pts, prog) {\n  let total = 0; const seg = [];\n  for (let i = 1; i < pts.length; i++) { const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]); seg.push(d); total += d; }\n  let left = total * clamp(prog);\n  for (let i = 1; i < pts.length; i++) { if (left <= seg[i - 1]) { const u = left / seg[i - 1]; return [lerp(pts[i - 1][0], pts[i][0], u), lerp(pts[i - 1][1], pts[i][1], u)]; } left -= seg[i - 1]; }\n  return pts[pts.length - 1];\n}\nfunction bezierPts(a, c, b, n = 40) { const out = []; for (let i = 0; i <= n; i++) { const u = i / n; out.push([(1 - u) * (1 - u) * a[0] + 2 * (1 - u) * u * c[0] + u * u * b[0], (1 - u) * (1 - u) * a[1] + 2 * (1 - u) * u * c[1] + u * u * b[1]]); } return out; }\n\nfunction drawSprig(ctx, x, y, s, prog, color) {\n  ctx.save(); ctx.translate(x, y); ctx.scale(s, s);\n  ctx.strokeStyle = color; ctx.lineCap = 'round'; ctx.lineJoin = 'round';\n  const stem = clamp(prog * 1.4); ctx.lineWidth = 7; ctx.beginPath(); ctx.moveTo(0, 0);\n  for (let i = 1; i <= 24 * stem; i++) { const u = i / 24; ctx.lineTo(Math.sin(u * 1.4) * 18, -u * 230); }\n  ctx.stroke();\n  for (let i = 0; i < 13; i++) {\n    const u = (i + 1) / 14, grow = clamp((prog - 0.12 - i * 0.055) * 5); if (grow <= 0) continue;\n    const bx = Math.sin(u * 1.4) * 18, by = -u * 230, len = (78 - u * 46) * grow; ctx.lineWidth = 4.5;\n    for (const side of [-1, 1]) { ctx.beginPath(); ctx.moveTo(bx, by); ctx.lineTo(bx + side * len * 0.86, by - len * 0.5); ctx.stroke(); }\n  }\n  ctx.restore();\n}\nfunction setWord(ctx, word, cx, cy, font, color, prog, spacing) {\n  ctx.font = font; ctx.textBaseline = 'alphabetic';\n  const widths = [...word].map((c) => ctx.measureText(c).width + spacing);\n  const total = widths.reduce((a, b) => a + b, 0) - spacing; let x = cx - total / 2;\n  [...word].forEach((c, i) => { const u = ease((prog * (word.length + 3) - i) / 3); if (u > 0) { ctx.globalAlpha *= 1; ctx.save(); ctx.globalAlpha = ctx.globalAlpha * u; ctx.fillStyle = color; ctx.fillText(c, x, cy + (1 - u) * 26); ctx.restore(); } x += widths[i]; });\n  return total;\n}\nfunction heart(ctx, x, y, s, color) {\n  ctx.save(); ctx.translate(x, y); ctx.scale(s, s); ctx.fillStyle = color; ctx.beginPath();\n  ctx.moveTo(0, 6); ctx.bezierCurveTo(-14, -6, -8, -18, 0, -10); ctx.bezierCurveTo(8, -18, 14, -6, 0, 6); ctx.fill(); ctx.restore();\n}\nfunction beatPulse(t) { return Math.pow(1 - ((t % 0.5) / 0.5), 3); }   // 1 on each beat, decaying\n\n// ── The solar system is ONE shared state, so dragging the sun drags everything related to it. ──\nconst SYSTEM = { sunHomeX: 860, sunHomeY: 560, dragToX: 1120, dragToY: 520, planetR: 210, moonR: 62, planetSpeed: 0.9, moonSpeed: 3.0,\n  typeStart: 6.5, typeEnd: 8.6, enter: 8.8, pops: [9.1, 9.45, 9.8], edges: [10.2, 10.8], orbit: 11.4, grab: 15.2, release: 18.0 };\nfunction sunAt(t, p) { const u = ease((t - p.grab - 0.25) / (p.release - p.grab - 0.25)); return { x: lerp(p.sunHomeX, p.dragToX, u), y: lerp(p.sunHomeY, p.dragToY, u) }; }\nfunction planetAt(t, p) { const s = sunAt(t, p), a = Math.max(0, t - p.orbit) * p.planetSpeed - 0.35; return { x: s.x + Math.cos(a) * p.planetR, y: s.y + Math.sin(a) * p.planetR, a }; }\nfunction moonAt(t, p) { const pl = planetAt(t, p), a = Math.max(0, t - p.orbit) * p.moonSpeed - 1.2; return { x: pl.x + Math.cos(a) * p.moonR, y: pl.y + Math.sin(a) * p.moonR }; }\nfunction miniOrbit(ctx, cx, cy, s, a) {\n  ctx.save(); ctx.translate(cx, cy); ctx.scale(s, s);\n  ctx.strokeStyle = 'rgba(124,108,255,0.5)'; ctx.lineWidth = 2; ctx.beginPath(); ctx.arc(0, 0, 60, 0, Math.PI * 2); ctx.stroke();\n  ctx.fillStyle = INK.amber; ctx.beginPath(); ctx.arc(0, 0, 18, 0, Math.PI * 2); ctx.fill();\n  ctx.fillStyle = INK.cyan; ctx.beginPath(); ctx.arc(Math.cos(a) * 60, Math.sin(a) * 60, 8, 0, Math.PI * 2); ctx.fill(); ctx.restore();\n}\n\n// A drawn landscape \"photograph\" — the image the grading scene works on. grade 0 = flat, 1 = graded.\nfunction landscape(ctx, x, y, w, h, grade, t) {\n  ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();\n  const sky = ctx.createLinearGradient(0, y, 0, y + h * 0.62);\n  sky.addColorStop(0, grade ? '#3b2d7a' : '#6b7280'); sky.addColorStop(0.55, grade ? '#f472b6' : '#9ca3af'); sky.addColorStop(1, grade ? '#fbbf24' : '#b8bcc4');\n  ctx.fillStyle = sky; ctx.fillRect(x, y, w, h * 0.62);\n  ctx.fillStyle = grade ? '#fde68a' : '#d1d5db'; ctx.beginPath(); ctx.arc(x + w * 0.66, y + h * 0.5, h * 0.09, 0, Math.PI * 2); ctx.fill();\n  const ridge = (base, amp, col, k) => { ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x, y + h);\n    for (let i = 0; i <= 24; i++) { const u = i / 24; ctx.lineTo(x + u * w, y + h * base - Math.abs(Math.sin(u * k + base * 7)) * h * amp); } ctx.lineTo(x + w, y + h); ctx.fill(); };\n  ridge(0.62, 0.2, grade ? '#5b21b6' : '#4b5563', 5.1); ridge(0.66, 0.13, grade ? '#312e81' : '#374151', 8.3);\n  const lake = ctx.createLinearGradient(0, y + h * 0.66, 0, y + h); lake.addColorStop(0, grade ? '#f59e0b' : '#9ca3af'); lake.addColorStop(1, grade ? '#1e1b4b' : '#374151');\n  ctx.fillStyle = lake; ctx.fillRect(x, y + h * 0.66, w, h * 0.34);\n  ctx.strokeStyle = grade ? 'rgba(253,230,138,0.7)' : 'rgba(229,231,235,0.5)'; ctx.lineWidth = 3;\n  for (let i = 0; i < 6; i++) { const yy = y + h * (0.7 + i * 0.045), ww = w * (0.12 - i * 0.015); ctx.beginPath(); ctx.moveTo(x + w * 0.66 - ww + Math.sin(t * 2 + i) * 6, yy); ctx.lineTo(x + w * 0.66 + ww + Math.sin(t * 2 + i) * 6, yy); ctx.stroke(); }\n  ctx.restore();\n}\n\n// Where the circled word \"whole\" sits on screen: measured in the clipping's own font, then carried\n// through the clipping's transform, so the marker circle and the sticky note's arrow agree on one spot.\nconst CLIP = { x: 820, y: 540, tilt: -2.5 };\nconst CLIP_HEAD = `700 76px \"Liberation Serif\", \"DejaVu Serif\", Georgia, serif`;\nfunction wholeOnScreen(ctx) {\n  ctx.save(); ctx.font = CLIP_HEAD; const mw = (s) => ctx.measureText(s).width; const wX = -470 + mw('moves a '), wW = mw('whole'); ctx.restore();\n  const lx = wX + wW / 2, ly = -150 + 82 - 26, a = CLIP.tilt * Math.PI / 180;\n  return { x: CLIP.x + lx * Math.cos(a) - ly * Math.sin(a), y: CLIP.y + lx * Math.sin(a) + ly * Math.cos(a), rx: wW / 2 + 16 };\n}\nconst MAP = { cities: [['Lisbon', 520, 700], ['Paris', 760, 470], ['Vienna', 1080, 440], ['Athens', 1260, 760]], pins: [44.7, 45.2, 45.7, 46.2], routeFrom: 46.5, routeTo: 49.6 };\nfunction routePts(p) {\n  const c = p.cities.map((q) => [q[1], q[2]]); let pts = [];\n  for (let i = 0; i < c.length - 1; i++) { const a = c[i], b = c[i + 1], mid = [(a[0] + b[0]) / 2, Math.min(a[1], b[1]) - 120]; pts = pts.concat(bezierPts(a, mid, b, 30).slice(i ? 1 : 0)); }\n  return pts;\n}\nconst FLOW = [['Brief', 230, 470], ['Design', 470, 470], ['Review?', 710, 470], ['Ship', 950, 470]];\n\nconst entities = [\n  // ── GROUNDS ──────────────────────────────────────────────────────────────────────────────\n  { id: 'ground', kind: 'background', from: 0, to: 72,\n    params: { top: INK.ground2, bottom: INK.ground, flecks: 60 },\n    draw(ctx, t, p, rng) {\n      const g = ctx.createRadialGradient(W / 2, H * 0.42, 60, W / 2, H * 0.5, W * 0.75);\n      g.addColorStop(0, p.top); g.addColorStop(1, p.bottom); ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);\n      for (let i = 0; i < p.flecks; i++) {\n        const bx = rng() * W, by = rng() * H, sz = 2 + rng() * 5, ph = rng() * 6.28, sp = 0.2 + rng() * 0.3;\n        ctx.globalAlpha = 0.14 + 0.12 * Math.sin(t * sp * 4 + ph);\n        ctx.fillStyle = [INK.violetSoft, INK.cyan, INK.pink, INK.amber, INK.paper][i % 5];\n        ctx.save(); ctx.translate(bx + Math.sin(t * sp + ph) * 22, (by - t * 14 * sp + H * 4) % H); ctx.rotate(ph + t * sp); ctx.fillRect(-sz, -sz * 0.6, sz * 2, sz * 1.2); ctx.restore();\n      }\n      ctx.globalAlpha = 1;\n    } },\n  { id: 'paper-stage', kind: 'background', from: 6, to: 64, params: { color: INK.paper, fiber: 'rgba(120,100,70,0.06)' },\n    draw(ctx, t, p, rng) {\n      ctx.fillStyle = p.color; ctx.fillRect(0, 0, W, H);\n      ctx.fillStyle = p.fiber; for (let i = 0; i < 260; i++) { const x = rng() * W, y = rng() * H, l = 4 + rng() * 14; ctx.fillRect(x, y, l, 1.5); }\n      const v = ctx.createRadialGradient(W / 2, H / 2, H * 0.4, W / 2, H / 2, W * 0.72); v.addColorStop(0, 'rgba(0,0,0,0)'); v.addColorStop(1, 'rgba(90,70,40,0.16)');\n      ctx.fillStyle = v; ctx.fillRect(0, 0, W, H);\n    } },\n  { id: 'chip', kind: 'title', from: 6, to: 64, params: { x: 96, y: 108, accent: INK.violet, color: INK.ink },\n    draw(ctx, t, p) {\n      for (const [n, text, a, b] of CHIPS) {\n        const o = win(t, a + 0.15, b, 0.3, 0.25); if (o <= 0) continue;\n        const slide = easeOut((t - a - 0.15) / 0.4);\n        ctx.globalAlpha = o; ctx.font = FONT(700, 44); const w = ctx.measureText(text).width;\n        ctx.fillStyle = p.accent; roundRect(ctx, p.x - 20 + (1 - slide) * -60, p.y - 52, 92, 72, 14); ctx.fill();\n        label(ctx, n, p.x + 26 + (1 - slide) * -60, p.y - 2, MONO(32), '#fff');\n        ctx.fillStyle = p.color; ctx.font = FONT(700, 44); ctx.fillText(text, p.x + 96 + (1 - slide) * -30, p.y);\n        ctx.fillStyle = INK.highlight; ctx.fillRect(p.x + 90, p.y - 16, (w + 16) * easeOut((t - a - 0.5) / 0.5), 22);\n      }\n    } },\n\n  // ── TITLE (0–6) ────────────────────────────────────────────────────────────────────────────\n  { id: 'title-sprig', kind: 'prop', from: 0.2, to: 6, params: { x: 560, y: 640, scale: 1.3, color: INK.paper },\n    draw(ctx, t, p) { ctx.globalAlpha = win(t, 0.2, 6, 0.3, 0.4); drawSprig(ctx, p.x, p.y, p.scale * (1 + 0.03 * beatPulse(t) * (t > 4 ? 1 : 0)), (t - 0.3) / 1.8, p.color); },\n    bounds(t, p) { return [p.x - 110 * p.scale, p.y - 240 * p.scale, 220 * p.scale, 250 * p.scale]; } },\n  { id: 'title-wordmark', kind: 'title', from: 0.8, to: 6, params: { x: 1060, y: 600, color: INK.paper, accent: INK.violetSoft },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 0.8, 6, 0.2, 0.4);\n      setWord(ctx, 'PinePaper', p.x, p.y, FONT(800, 164), p.color, (t - 0.9) / 1.4, 4);\n      ctx.globalAlpha = win(t, 2.4, 6, 0.4, 0.4);\n      const words = ['Describe.', 'Relate.', 'Edit.', 'Ship.'];\n      words.forEach((w, i) => { const u = back((t - 2.4 - i * 0.4) / 0.35); if (u <= 0) return; ctx.save(); ctx.translate(p.x - 330 + i * 220, p.y + 96); ctx.scale(u, u); label(ctx, w, 0, 0, FONT(700, 44), [INK.violetSoft, INK.cyan, INK.pink, INK.amber][i]); ctx.restore(); });\n    },\n    bounds(t, p) { return [p.x - 420, p.y - 140, 840, 260]; } },\n\n  // ── DESCRIBE + RELATE (6–14) and EDIT BY HAND (14–20) ──────────────────────────────────────\n  { id: 'prompt', kind: 'ui', from: 6.2, to: 11.2, params: { x: 960, y: 880, w: 1120, text: 'a sun, a planet and a moon — make them orbit', color: INK.ink, accent: INK.violet },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 6.2, 11.2, 0.3, 0.4);\n      const flash = t >= SYSTEM.enter ? Math.max(0, 1 - (t - SYSTEM.enter) / 0.4) : 0;\n      ctx.shadowColor = 'rgba(0,0,0,0.18)'; ctx.shadowBlur = 24; ctx.shadowOffsetY = 8;\n      ctx.fillStyle = flash > 0 ? `rgba(124,108,255,${0.15 + 0.35 * flash})` : '#ffffff'; roundRect(ctx, p.x - p.w / 2, p.y - 40, p.w, 80, 40); ctx.fill(); ctx.shadowColor = 'transparent';\n      ctx.strokeStyle = p.accent; ctx.lineWidth = 3; ctx.stroke();\n      const n = Math.floor(clamp((t - SYSTEM.typeStart) / (SYSTEM.typeEnd - SYSTEM.typeStart)) * p.text.length);\n      ctx.font = FONT(500, 36); ctx.fillStyle = p.color; ctx.fillText(p.text.slice(0, n), p.x - p.w / 2 + 48, p.y + 12);\n      if (t < SYSTEM.enter && Math.floor(t * 4) % 2 === 0) { const cw = ctx.measureText(p.text.slice(0, n)).width; ctx.fillStyle = p.accent; ctx.fillRect(p.x - p.w / 2 + 52 + cw, p.y - 20, 3, 40); }\n      ctx.fillStyle = p.accent; ctx.beginPath(); ctx.arc(p.x + p.w / 2 - 44, p.y, 28, 0, Math.PI * 2); ctx.fill();\n      ctx.strokeStyle = '#fff'; ctx.lineWidth = 4; ctx.lineCap = 'round'; ctx.beginPath(); ctx.moveTo(p.x + p.w / 2 - 44, p.y + 12); ctx.lineTo(p.x + p.w / 2 - 44, p.y - 12); ctx.moveTo(p.x + p.w / 2 - 54, p.y - 2); ctx.lineTo(p.x + p.w / 2 - 44, p.y - 12); ctx.lineTo(p.x + p.w / 2 - 34, p.y - 2); ctx.stroke();\n    },\n    bounds(t, p) { return [p.x - p.w / 2, p.y - 40, p.w, 80]; } },\n  { id: 'relations', kind: 'graph', from: 9, to: 20.2, params: SYSTEM,\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 9, 20.2, 0.3, 0.3);\n      const s = sunAt(t, p), pl = planetAt(t, p), m = moonAt(t, p), live = ease((t - p.orbit) / 0.8);\n      ctx.globalAlpha *= live; ctx.setLineDash([8, 12]); ctx.strokeStyle = INK.violet; ctx.lineWidth = 2.5;\n      ctx.beginPath(); ctx.arc(s.x, s.y, p.planetR, 0, Math.PI * 2); ctx.stroke(); ctx.beginPath(); ctx.arc(pl.x, pl.y, p.moonR, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]);\n      ctx.globalAlpha = win(t, 9, 20.2, 0.3, 0.3);\n      [[pl, s, p.edges[0]], [m, pl, p.edges[1]]].forEach(([a, b, at]) => {\n        const g = easeOut((t - at) / 0.5) * (1 - live); if (g <= 0.01) return;\n        ctx.strokeStyle = INK.violet; ctx.lineWidth = 5; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(lerp(a.x, b.x, g), lerp(a.y, b.y, g)); ctx.stroke();\n        const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2 - 24, u = back((t - at - 0.3) / 0.3) * (1 - live);\n        if (u > 0) { ctx.save(); ctx.translate(mx, my); ctx.scale(u, u); ctx.fillStyle = INK.violet; roundRect(ctx, -66, -24, 132, 44, 22); ctx.fill(); label(ctx, 'orbits', 0, 8, MONO(24), '#fff'); ctx.restore(); }\n      });\n      if (live > 0) for (let i = 0; i < 18; i++) { const a = pl.a - i * 0.06; ctx.fillStyle = `rgba(56,189,248,${0.35 * (1 - i / 18)})`; ctx.beginPath(); ctx.arc(s.x + Math.cos(a) * p.planetR, s.y + Math.sin(a) * p.planetR, 13 - i * 0.55, 0, Math.PI * 2); ctx.fill(); }\n    } },\n  { id: 'sun', kind: 'character', from: 9, to: 20.2, params: SYSTEM,\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 9, 20.2, 0.2, 0.3); const s = sunAt(t, p), pop = back((t - p.pops[0]) / 0.35), held = t > p.grab && t < p.release + 0.2;\n      if (pop <= 0) return;\n      const glow = ctx.createRadialGradient(s.x, s.y, 10, s.x, s.y, 130); glow.addColorStop(0, 'rgba(251,191,36,0.5)'); glow.addColorStop(1, 'rgba(251,191,36,0)');\n      ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(s.x, s.y, 130, 0, Math.PI * 2); ctx.fill();\n      ctx.fillStyle = INK.amber; ctx.beginPath(); ctx.arc(s.x, s.y, 60 * pop * (1 + 0.04 * beatPulse(t)), 0, Math.PI * 2); ctx.fill();\n      if (held) { ctx.strokeStyle = INK.violet; ctx.lineWidth = 4; ctx.setLineDash([10, 8]); ctx.beginPath(); ctx.arc(s.x, s.y, 80, 0, Math.PI * 2); ctx.stroke(); ctx.setLineDash([]); }\n    },\n    bounds(t, p) { const s = sunAt(t, p); return [s.x - 60, s.y - 60, 120, 120]; } },\n  { id: 'planet', kind: 'character', from: 9, to: 20.2, params: SYSTEM,\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 9, 20.2, 0.2, 0.3); const pl = planetAt(t, p), pop = back((t - p.pops[1]) / 0.35); if (pop <= 0) return;\n      ctx.fillStyle = INK.cyan; ctx.beginPath(); ctx.arc(pl.x, pl.y, 26 * pop, 0, Math.PI * 2); ctx.fill();\n      ctx.fillStyle = 'rgba(255,255,255,0.55)'; ctx.beginPath(); ctx.arc(pl.x - 8, pl.y - 8, 7 * pop, 0, Math.PI * 2); ctx.fill();\n    },\n    bounds(t, p) { const pl = planetAt(t, p); return [pl.x - 26, pl.y - 26, 52, 52]; } },\n  { id: 'moon', kind: 'character', from: 9, to: 20.2, params: SYSTEM,\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 9, 20.2, 0.2, 0.3); const m = moonAt(t, p), pop = back((t - p.pops[2]) / 0.35); if (pop <= 0) return;\n      ctx.fillStyle = '#cbd5e1'; ctx.beginPath(); ctx.arc(m.x, m.y, 13 * pop, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = INK.dim; ctx.lineWidth = 1.5; ctx.stroke();\n    },\n    bounds(t, p) { const m = moonAt(t, p); return [m.x - 14, m.y - 14, 28, 28]; } },\n  { id: 'pointer', kind: 'ui', from: 14.2, to: 19.2, params: { startX: 1400, startY: 300, color: INK.ink },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 14.2, 19.2, 0.3, 0.4);\n      const s = sunAt(t, SYSTEM), reach = ease((t - 14.3) / (SYSTEM.grab - 14.3));\n      const x = t < SYSTEM.grab ? lerp(p.startX, s.x + 20, reach) : s.x + 20, y = t < SYSTEM.grab ? lerp(p.startY, s.y + 18, reach) : s.y + 18;\n      const press = t > SYSTEM.grab && t < SYSTEM.release ? 0.88 : 1;\n      ctx.translate(x, y); ctx.scale(press * 1.7, press * 1.7); ctx.fillStyle = '#fff'; ctx.strokeStyle = p.color; ctx.lineWidth = 1.6;\n      ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, 26); ctx.lineTo(7, 20); ctx.lineTo(12, 31); ctx.lineTo(16, 29); ctx.lineTo(11, 18); ctx.lineTo(20, 18); ctx.closePath(); ctx.fill(); ctx.stroke();\n    } },\n  { id: 'caption-drag', kind: 'title', from: 15.4, to: 20, params: { x: 960, y: 960, color: INK.ink },\n    draw(ctx, t, p) { ctx.globalAlpha = win(t, 15.6, 20, 0.3, 0.3); label(ctx, 'Move the sun — every relation holds.', p.x, p.y, FONT(600, 40), p.color); } },\n\n  // ── VOX-STYLE EXPLAINER (20–28) ────────────────────────────────────────────────────────────\n  { id: 'clipping', kind: 'prop', from: 20.2, to: 28.3, params: { x: CLIP.x, y: CLIP.y, tilt: CLIP.tilt, headline: ['Why one rule', 'moves a whole', 'drawing'] },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 20.2, 28.3, 0.2, 0.3); const inn = easeOut((t - 20.2) / 0.5), punch = 1 + 0.06 * ease((t - 26.5) / 0.6);\n      ctx.translate(p.x + (1 - inn) * -900, p.y); ctx.rotate(p.tilt * Math.PI / 180); ctx.scale(punch, punch);\n      ctx.shadowColor = 'rgba(40,30,10,0.35)'; ctx.shadowBlur = 30; ctx.shadowOffsetY = 12;\n      ctx.fillStyle = '#fffdf7'; ctx.beginPath(); ctx.moveTo(-520, -300);\n      for (let i = 0; i <= 20; i++) ctx.lineTo(-520 + i * 52, -300 + (i % 2 ? 7 : -4)); for (let i = 0; i <= 12; i++) ctx.lineTo(520 + (i % 2 ? -6 : 4), -300 + i * 50);\n      for (let i = 20; i >= 0; i--) ctx.lineTo(-520 + i * 52, 300 + (i % 2 ? -6 : 5)); ctx.closePath(); ctx.fill(); ctx.shadowColor = 'transparent';\n      label(ctx, 'THE PINEPAPER TIMES · EXPLAINED', -470, -240, MONO(22), INK.dim, 'left');\n      ctx.fillStyle = INK.ink; ctx.fillRect(-470, -222, 940, 3);\n      const HEAD = CLIP_HEAD;\n      p.headline.forEach((line, i) => label(ctx, line, -470, -150 + i * 82, HEAD, INK.ink, 'left'));\n      // MEASURED, not placed by eye: the marks land on the words in whatever serif the renderer has.\n      ctx.font = HEAD; const mw = (s) => ctx.measureText(s).width;\n      const hlX = -470 + mw('Why '), hlW = mw('one rule');\n      // highlighter swipe across \"one rule\"\n      ctx.globalCompositeOperation = 'multiply'; ctx.fillStyle = INK.highlight; ctx.fillRect(hlX - 8, -205, (hlW + 16) * easeOut((t - 21.4) / 0.5), 70); ctx.globalCompositeOperation = 'source-over';\n      for (let i = 0; i < 4; i++) { ctx.fillStyle = '#d6d0c2'; ctx.fillRect(-470, 110 + i * 34, 940 - (i % 2) * 180, 12); }\n      // the red marker circle around \"whole\"\n      const wX = -470 + mw('moves a '), wW = mw('whole'), cx = wX + wW / 2, cy = -150 + 82 - 26;\n      const cp = easeOut((t - 22.6) / 0.6); if (cp > 0) { ctx.strokeStyle = INK.red; ctx.lineWidth = 7; ctx.lineCap = 'round'; const pts = []; for (let i = 0; i <= 40; i++) { const a = -2.6 + i / 40 * 6.9; pts.push([cx + Math.cos(a) * (wW / 2 + 16 + i * 0.3), cy + Math.sin(a) * 52]); } drawPartial(ctx, pts, cp); }\n    },\n    bounds(t, p) { return [p.x - 520, p.y - 300, 1040, 600]; } },\n  { id: 'sticky-arrow', kind: 'prop', from: 23.4, to: 28.3, params: { x: 1560, y: 330, color: '#fde047', text: ['one relation', '= many moves'] },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 23.4, 28.3, 0.2, 0.3); const u = back((t - 23.4) / 0.4); if (u <= 0) return;\n      ctx.save(); ctx.translate(p.x, p.y); ctx.rotate(0.06); ctx.scale(u, u); ctx.shadowColor = 'rgba(0,0,0,0.2)'; ctx.shadowBlur = 16; ctx.shadowOffsetY = 6;\n      ctx.fillStyle = p.color; ctx.fillRect(-170, -110, 340, 220); ctx.shadowColor = 'transparent';\n      p.text.forEach((l, i) => label(ctx, l, 0, -14 + i * 56, HAND(44), INK.ink)); ctx.restore();\n      ctx.strokeStyle = INK.red; ctx.lineWidth = 6; ctx.lineCap = 'round';\n      const w = wholeOnScreen(ctx), end = [w.x + w.rx + 26, w.y + 6], start = [p.x - 190, p.y + 70];\n      const pts = bezierPts(start, [(start[0] + end[0]) / 2, Math.min(start[1], end[1]) - 90], end, 30); const g = easeOut((t - 24) / 0.5); drawPartial(ctx, pts, g);\n      if (g >= 1) { const [ex, ey] = end, [px, py] = pts[pts.length - 3], ang = Math.atan2(ey - py, ex - px);\n        ctx.beginPath(); ctx.moveTo(ex - 26 * Math.cos(ang - 0.45), ey - 26 * Math.sin(ang - 0.45)); ctx.lineTo(ex, ey); ctx.lineTo(ex - 26 * Math.cos(ang + 0.45), ey - 26 * Math.sin(ang + 0.45)); ctx.stroke(); }\n    } },\n  { id: 'cutout-orbit', kind: 'prop', from: 25, to: 28.3, params: { x: 1480, y: 790 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 25, 28.3, 0.2, 0.3); const u = back((t - 25) / 0.4); if (u <= 0) return;\n      ctx.translate(p.x, p.y); ctx.rotate(-0.08); ctx.scale(u, u); ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 18; ctx.shadowOffsetY = 8;\n      ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(0, 0, 170, 0, Math.PI * 2); ctx.fill(); ctx.shadowColor = 'transparent';\n      ctx.fillStyle = INK.ground2; ctx.beginPath(); ctx.arc(0, 0, 150, 0, Math.PI * 2); ctx.fill(); miniOrbit(ctx, 0, 0, 1.6, t * 2.2);\n      label(ctx, 'fig. 1', 0, 215, HAND(34), INK.dim);\n    } },\n\n  // ── VIDEO EDIT (28–36) ─────────────────────────────────────────────────────────────────────\n  { id: 'video-editor', kind: 'ui', from: 28.1, to: 36.3, params: { x: 360, y: 190, w: 1200, cut: 30.4, trim: 31.8, close: 32.9 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 28.1, 36.3, 0.2, 0.3); const inn = easeOut((t - 28.1) / 0.5); ctx.translate(0, (1 - inn) * 120);\n      const head = clamp((t - 28.8) / 6.6);          // the playhead's position across the timeline\n      // monitor\n      ctx.fillStyle = '#0b0f1c'; roundRect(ctx, p.x, p.y, p.w, 470, 18); ctx.fill();\n      const clipIdx = head < 0.34 ? 0 : head < 0.68 ? 1 : 2, pal = [['#0ea5e9', '#f0abfc'], ['#f97316', '#fde68a'], ['#22c55e', '#a7f3d0']][clipIdx];\n      const g = ctx.createLinearGradient(0, p.y + 20, 0, p.y + 450); g.addColorStop(0, pal[0]); g.addColorStop(1, pal[1]);\n      ctx.fillStyle = g; ctx.fillRect(p.x + 20, p.y + 20, p.w - 40, 430);\n      const bx = p.x + 200 + ((t * 260) % (p.w - 400)), by = p.y + 380 - Math.abs(Math.sin(t * 5)) * 220;\n      ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(bx, p.y + 420, 50, 12, 0, 0, Math.PI * 2); ctx.fill();\n      ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(bx, by, 44, 0, Math.PI * 2); ctx.fill();\n      label(ctx, ['SHOT A', 'SHOT B', 'SHOT C'][clipIdx], p.x + 60, p.y + 70, MONO(26), '#fff', 'left');\n      label(ctx, `00:${String(Math.floor(head * 24)).padStart(2, '0')}:${String(Math.floor((head * 24 * 30) % 30)).padStart(2, '0')}`, p.x + p.w - 60, p.y + 70, MONO(26), '#fff', 'right');\n      // timeline\n      const ty = p.y + 510; ctx.fillStyle = '#e8e1d1'; roundRect(ctx, p.x, ty, p.w, 300, 16); ctx.fill();\n      for (let i = 0; i <= 24; i++) { ctx.fillStyle = INK.dim; ctx.fillRect(p.x + 40 + i * ((p.w - 80) / 24), ty + 14, 2, i % 4 ? 10 : 20); }\n      const lx = p.x + 40, lw = p.w - 80, gap = t < p.cut ? 0 : 36 * (1 - ease((t - p.close) / 0.6));\n      const trimW = t < p.trim ? 0 : 90 * easeOut((t - p.trim) / 0.4);\n      const clips = [[0, 0.34, '#38bdf8'], [0.34, 0.68, '#fb923c'], [0.68, 1, '#4ade80']];\n      clips.forEach(([a, b, col], i) => {\n        let x0 = lx + a * lw, x1 = lx + b * lw; if (i >= 1) x0 += gap / 2 + (i === 2 ? gap / 2 : 0); if (i === 1) { x1 -= trimW; x1 += gap / 2; } if (i === 2) { x0 -= trimW * (1 - ease((t - p.close) / 0.6) * 0) ; x1 += gap; }\n        ctx.fillStyle = col; roundRect(ctx, x0 + 3, ty + 50, x1 - x0 - 6, 84, 10); ctx.fill();\n        for (let k = 0; k < 3; k++) { ctx.fillStyle = 'rgba(255,255,255,0.35)'; ctx.fillRect(x0 + 16 + k * 70, ty + 64, 56, 56); }\n      });\n      // waveform track\n      for (let i = 0; i < 140; i++) { const x = lx + i * (lw / 140), a = (0.3 + 0.7 * Math.abs(Math.sin(i * 0.37) * Math.cos(i * 0.11))) * 40; ctx.fillStyle = INK.violet; ctx.fillRect(x, ty + 200 - a / 2, lw / 140 - 2, a); }\n      // razor flash\n      if (t > p.cut - 0.1 && t < p.cut + 0.5) { ctx.globalAlpha *= 1 - (t - p.cut + 0.1) / 0.6; ctx.fillStyle = INK.red; ctx.fillRect(lx + 0.34 * lw - 2, ty + 36, 5, 240); ctx.globalAlpha = win(t, 28.1, 36.3, 0.2, 0.3); }\n      // playhead\n      const hx = lx + head * lw; ctx.fillStyle = INK.red; ctx.fillRect(hx - 2, ty + 30, 4, 250); ctx.beginPath(); ctx.moveTo(hx - 14, ty + 26); ctx.lineTo(hx + 14, ty + 26); ctx.lineTo(hx, ty + 44); ctx.fill();\n      const tag = (at, text) => { const o = win(t, at, at + 1.6, 0.15, 0.3); if (o <= 0) return; ctx.save(); ctx.globalAlpha *= o; ctx.fillStyle = INK.ink; roundRect(ctx, p.x + p.w + 30, ty + 20, 250, 64, 32); ctx.fill(); label(ctx, text, p.x + p.w + 155, ty + 63, FONT(700, 32), '#fff'); ctx.restore(); };\n      tag(p.cut, 'Cut'); tag(p.trim, 'Trim'); tag(p.close, 'Ripple');\n    },\n    bounds(t, p) { return [p.x, p.y, p.w, 810]; } },\n\n  // ── IMAGES (36–44) ─────────────────────────────────────────────────────────────────────────\n  { id: 'photo-grade', kind: 'prop', from: 36.1, to: 41.8, params: { x: 360, y: 190, w: 1200, h: 700, wipeFrom: 37, wipeTo: 39.4 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 36.1, 41.8, 0.2, 0.3); const kb = 1 + 0.1 * ease((t - 36.1) / 5.5);\n      ctx.shadowColor = 'rgba(0,0,0,0.3)'; ctx.shadowBlur = 30; ctx.shadowOffsetY = 12; ctx.fillStyle = '#fff'; ctx.fillRect(p.x - 16, p.y - 16, p.w + 32, p.h + 32); ctx.shadowColor = 'transparent';\n      ctx.save(); ctx.beginPath(); ctx.rect(p.x, p.y, p.w, p.h); ctx.clip(); ctx.translate(p.x + p.w / 2, p.y + p.h / 2); ctx.scale(kb, kb); ctx.translate(-p.x - p.w / 2, -p.y - p.h / 2);\n      landscape(ctx, p.x, p.y, p.w, p.h, 0, t);\n      const split = p.x + p.w * easeOut((t - p.wipeFrom) / (p.wipeTo - p.wipeFrom));\n      ctx.save(); ctx.beginPath(); ctx.rect(p.x, p.y, split - p.x, p.h); ctx.clip(); landscape(ctx, p.x, p.y, p.w, p.h, 1, t); ctx.restore(); ctx.restore();\n      if (t > p.wipeFrom && t < p.wipeTo + 0.6) { ctx.fillStyle = '#fff'; ctx.fillRect(split - 3, p.y, 6, p.h); ctx.beginPath(); ctx.arc(split, p.y + p.h / 2, 26, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = INK.ink; for (const d of [-1, 1]) { ctx.beginPath(); ctx.moveTo(split + d * 16, p.y + p.h / 2); ctx.lineTo(split + d * 6, p.y + p.h / 2 - 8); ctx.lineTo(split + d * 6, p.y + p.h / 2 + 8); ctx.closePath(); ctx.fill(); } }\n      const chip = (x, text, col) => { ctx.font = FONT(700, 28); const w = ctx.measureText(text).width + 48; const x0 = x + 160 > p.x + p.w ? p.x + p.w - 24 - w : x; ctx.fillStyle = col; roundRect(ctx, x0, p.y + 24, w, 52, 26); ctx.fill(); label(ctx, text, x0 + w / 2, p.y + 60, FONT(700, 28), '#fff'); };\n      if (t > p.wipeFrom) { chip(p.x + 24, 'Golden hour', INK.violet); chip(p.x + p.w - 184, 'Day', INK.dim); }\n    },\n    bounds(t, p) { return [p.x, p.y, p.w, p.h]; } },\n  { id: 'collage', kind: 'prop', from: 41.4, to: 44.3, params: { y: 560 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 41.4, 44.3, 0.15, 0.3);\n      [[560, -0.1, 41.5], [960, 0.05, 41.9], [1360, -0.04, 42.3]].forEach(([x, rot, at], i) => {\n        const u = easeOut((t - at) / 0.35); if (u <= 0) return;\n        ctx.save(); ctx.translate(x, p.y - (1 - u) * 700); ctx.rotate(rot + (1 - u) * 0.4);\n        ctx.shadowColor = 'rgba(0,0,0,0.3)'; ctx.shadowBlur = 24; ctx.shadowOffsetY = 10; ctx.fillStyle = '#fff'; ctx.fillRect(-200, -230, 400, 470); ctx.shadowColor = 'transparent';\n        ctx.save(); ctx.beginPath(); ctx.rect(-180, -210, 360, 360); ctx.clip(); ctx.translate(-180, -210); landscape(ctx, -i * 90, 0, 360 + i * 180, 360, 1, t + i); ctx.restore();\n        label(ctx, ['golden hour', 'the lake', 'mountains'][i], 0, 205, HAND(34), INK.ink); ctx.restore();\n      });\n    } },\n\n  // ── MAP (44–52) ────────────────────────────────────────────────────────────────────────────\n  { id: 'map', kind: 'prop', from: 44.1, to: 52.3, params: MAP,\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 44.1, 52.3, 0.2, 0.3);\n      const z = 1 + 0.9 * ease((t - 49.8) / 1.8), last = p.cities[p.cities.length - 1];\n      const k = (z - 1) / 0.9;                                           // 0 → 1 over the zoom\n      ctx.translate(lerp(last[1], 960, k), lerp(last[2], 600, k)); ctx.scale(z, z); ctx.translate(-last[1], -last[2]);\n      ctx.fillStyle = '#bfe3f5'; ctx.fillRect(-600, -400, W + 1200, H + 800);\n      ctx.strokeStyle = 'rgba(30,64,175,0.12)'; ctx.lineWidth = 2; for (let x = -600; x < W + 600; x += 120) { ctx.beginPath(); ctx.moveTo(x, -400); ctx.lineTo(x, H + 400); ctx.stroke(); } for (let y = -400; y < H + 400; y += 120) { ctx.beginPath(); ctx.moveTo(-600, y); ctx.lineTo(W + 600, y); ctx.stroke(); }\n      const land = [[380, 820], [360, 620], [470, 540], [600, 380], [760, 300], [980, 280], [1200, 330], [1420, 380], [1560, 520], [1500, 700], [1360, 900], [1220, 820], [1120, 700], [960, 640], [840, 720], [700, 820], [560, 900]];\n      ctx.fillStyle = '#f3e9cf'; ctx.strokeStyle = '#d9c9a0'; ctx.lineWidth = 4; ctx.beginPath(); land.forEach(([x, y], i) => { const nx = land[(i + 1) % land.length]; if (!i) ctx.moveTo(x, y); ctx.quadraticCurveTo(x, y, (x + nx[0]) / 2, (y + nx[1]) / 2); }); ctx.closePath(); ctx.fill(); ctx.stroke();\n      const pts = routePts(p), prog = ease((t - p.routeFrom) / (p.routeTo - p.routeFrom));\n      ctx.strokeStyle = INK.violet; ctx.lineWidth = 6; ctx.setLineDash([16, 12]); ctx.lineDashOffset = -t * 40; drawPartial(ctx, pts, prog); ctx.setLineDash([]);\n      p.cities.forEach(([name, x, y], i) => {\n        const u = back((t - p.pins[i]) / 0.35); if (u <= 0) return;\n        ctx.save(); ctx.translate(x, y - (1 - clamp(u)) * 120); ctx.fillStyle = INK.red; ctx.beginPath(); ctx.arc(0, -34, 20, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.moveTo(-17, -24); ctx.lineTo(0, 0); ctx.lineTo(17, -24); ctx.fill();\n        ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(0, -34, 8, 0, Math.PI * 2); ctx.fill(); ctx.restore();\n        label(ctx, name, x, y + 42, FONT(700, 30), INK.ink);\n      });\n      if (prog > 0 && prog < 1) { const [px, py] = pointAt(pts, prog), [qx, qy] = pointAt(pts, Math.min(1, prog + 0.01)); ctx.save(); ctx.translate(px, py); ctx.rotate(Math.atan2(qy - py, qx - px)); ctx.fillStyle = INK.ink; ctx.beginPath(); ctx.moveTo(26, 0); ctx.lineTo(-18, -16); ctx.lineTo(-10, 0); ctx.lineTo(-18, 16); ctx.closePath(); ctx.fill(); ctx.restore(); }\n    },\n    bounds(t, p) { return [0, 0, W, H]; } },\n  { id: 'map-card', kind: 'ui', from: 46.4, to: 52.3, params: { x: 1500, y: 180 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 46.4, 52.3, 0.3, 0.3); ctx.shadowColor = 'rgba(0,0,0,0.2)'; ctx.shadowBlur = 20; ctx.fillStyle = '#fff'; roundRect(ctx, p.x, p.y, 330, 170, 20); ctx.fill(); ctx.shadowColor = 'transparent';\n      const km = Math.round(2940 * ease((t - MAP.routeFrom) / (MAP.routeTo - MAP.routeFrom)));\n      label(ctx, 'ROUTE', p.x + 30, p.y + 50, MONO(22), INK.dim, 'left'); label(ctx, km.toLocaleString('en-US') + ' km', p.x + 30, p.y + 112, FONT(800, 54), INK.ink, 'left');\n      label(ctx, '4 cities · 3 legs', p.x + 30, p.y + 150, FONT(500, 24), INK.dim, 'left');\n    } },\n\n  // ── DIAGRAMS & DATA (52–58) ────────────────────────────────────────────────────────────────\n  { id: 'flowchart', kind: 'graph', from: 52.1, to: 58.3, params: { nodes: FLOW, color: INK.violet },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 52.1, 58.3, 0.2, 0.3);\n      const head = (x, y, ang) => { ctx.fillStyle = INK.ink; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - 16 * Math.cos(ang - 0.5), y - 16 * Math.sin(ang - 0.5)); ctx.lineTo(x - 16 * Math.cos(ang + 0.5), y - 16 * Math.sin(ang + 0.5)); ctx.closePath(); ctx.fill(); };\n      const halfW = (i) => (i === 2 ? 95 : 90);                       // the diamond is wider than a box\n      for (let i = 0; i < p.nodes.length - 1; i++) {\n        const [, x0, y0] = p.nodes[i], [, x1] = p.nodes[i + 1], g = easeOut((t - 52.6 - i * 0.35) / 0.3);\n        ctx.strokeStyle = INK.ink; ctx.lineWidth = 4; drawPartial(ctx, [[x0 + halfW(i), y0], [x1 - halfW(i + 1) - 4, y0]], g);\n        if (g >= 1) head(x1 - halfW(i + 1), y0, 0);\n      }\n      const lg = easeOut((t - 53.8) / 0.5); ctx.strokeStyle = INK.ink; drawPartial(ctx, [[710, 540], [710, 640], [470, 640], [470, 524]], lg);\n      if (lg >= 1) head(470, 520, -Math.PI / 2);\n      label(ctx, 'no', 590, 675, HAND(30), INK.red);\n      if (t > 54.4) { const k = ((t - 54.4) / 1.5) % 1, [x, y] = pointAt([[230, 470], [950, 470]], k); ctx.fillStyle = INK.violet; ctx.beginPath(); ctx.arc(x, y, 14 + 4 * beatPulse(t), 0, Math.PI * 2); ctx.fill(); }\n      p.nodes.forEach(([name, x, y], i) => {\n        const u = back((t - 52.3 - i * 0.3) / 0.35); if (u <= 0) return;\n        ctx.save(); ctx.translate(x, y); ctx.scale(u, u); ctx.fillStyle = i === 2 ? INK.amber : i === 3 ? INK.mint : '#fff'; ctx.strokeStyle = INK.ink; ctx.lineWidth = 4;\n        if (i === 2) { ctx.beginPath(); ctx.moveTo(0, -70); ctx.lineTo(95, 0); ctx.lineTo(0, 70); ctx.lineTo(-95, 0); ctx.closePath(); } else roundRect(ctx, -90, -50, 180, 100, i === 0 || i === 3 ? 50 : 14);\n        ctx.fill(); ctx.stroke(); label(ctx, name, 0, 10, FONT(700, i === 2 ? 24 : 30), INK.ink); ctx.restore();\n      });\n    } },\n  { id: 'charts', kind: 'prop', from: 52.4, to: 58.3, params: { x: 1150, y: 300, values: [34, 52, 47, 71, 92], colors: [INK.cyan, INK.violet, INK.pink, INK.amber, INK.mint], share: 0.68 },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 52.4, 58.3, 0.2, 0.3);\n      ctx.strokeStyle = INK.ink; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(p.x, p.y); ctx.lineTo(p.x, p.y + 360); ctx.lineTo(p.x + 440, p.y + 360); ctx.stroke();\n      p.values.forEach((v, i) => { const u = back((t - 53 - i * 0.2) / 0.5), h = v * 3.4 * Math.max(0, u); ctx.fillStyle = p.colors[i]; roundRect(ctx, p.x + 24 + i * 84, p.y + 360 - h, 62, Math.max(1, h), 8); ctx.fill(); if (u > 0.9) label(ctx, String(v), p.x + 55 + i * 84, p.y + 344 - h, FONT(700, 26), INK.ink); });\n      const cx = p.x + 640, cy = p.y + 180, sweep = easeOut((t - 54.8) / 1.2) * p.share;\n      ctx.lineWidth = 60; ctx.strokeStyle = '#e5dccb'; ctx.beginPath(); ctx.arc(cx, cy, 120, 0, Math.PI * 2); ctx.stroke();\n      ctx.strokeStyle = INK.violet; ctx.lineCap = 'butt'; ctx.beginPath(); ctx.arc(cx, cy, 120, -Math.PI / 2, -Math.PI / 2 + sweep * Math.PI * 2); ctx.stroke();\n      label(ctx, Math.round(sweep * 100) + '%', cx, cy + 20, FONT(800, 56), INK.ink); label(ctx, 'on time', cx, cy + 210, HAND(34), INK.dim);\n    } },\n\n  // ── SHIP IT (58–64) ────────────────────────────────────────────────────────────────────────\n  { id: 'ship-cards', kind: 'prop', from: 58.1, to: 64.3, params: { y: 560, cards: [['MP4', 'with its score', 470, 58.5], ['A4 PDF', 'print-true', 960, 59.5], ['Interactive widget', 'embed it anywhere', 1450, 60.5]] },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 58.1, 64.3, 0.2, 0.3);\n      p.cards.forEach(([title, sub, x, at], i) => {\n        const u = back((t - at) / 0.4); if (u <= 0) return; const bob = Math.sin((t - at) * 3 + i) * 6;\n        ctx.save(); ctx.translate(x, p.y + bob); ctx.scale(u, u); ctx.rotate((i - 1) * 0.03);\n        ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 30; ctx.shadowOffsetY = 12; ctx.fillStyle = i === 1 ? '#fff' : INK.ground2; roundRect(ctx, -200, -240, 400, 480, 22); ctx.fill(); ctx.shadowColor = 'transparent';\n        if (i === 0) { for (let k = 0; k < 8; k++) { ctx.fillStyle = '#2a3160'; roundRect(ctx, -180 + k * 46, -222, 26, 16, 4); ctx.fill(); roundRect(ctx, -180 + k * 46, 206, 26, 16, 4); ctx.fill(); } miniOrbit(ctx, 0, -30, 1.4, t * 2); }\n        if (i === 1) { ctx.fillStyle = INK.ink; ctx.fillRect(-150, -200, 300, 18); for (let k = 0; k < 3; k++) { ctx.fillStyle = '#d7dbe6'; ctx.fillRect(-150, -166 + k * 22, 300 - (k % 2) * 80, 9); } landscape(ctx, -150, -90, 300, 180, 1, t); }\n        if (i === 2) { ctx.fillStyle = '#e2e8f0'; ctx.fillRect(-180, -220, 360, 40); ['#f87171', '#fbbf24', '#34d399'].forEach((c, k) => { ctx.fillStyle = c; ctx.beginPath(); ctx.arc(-156 + k * 22, -200, 7, 0, Math.PI * 2); ctx.fill(); }); miniOrbit(ctx, 0, -30, 1.3, t * 2.4); ctx.fillStyle = INK.violet; roundRect(ctx, 80, 60, 80, 44, 10); ctx.fill(); label(ctx, '</>', 120, 91, MONO(24), '#fff'); }\n        label(ctx, title, 0, 150, FONT(800, title.length > 12 ? 38 : 44), i === 1 ? INK.ink : '#fff'); label(ctx, sub, 0, 196, FONT(500, 28), i === 1 ? INK.dim : INK.violetSoft); ctx.restore();\n      });\n      ['GIF', 'PNG', 'SVG', 'WebM'].forEach((b, i) => { const u = back((t - 61.4 - i * 0.25) / 0.3); if (u <= 0) return; ctx.save(); ctx.translate(660 + i * 200, 900); ctx.scale(u, u); ctx.fillStyle = INK.ink; roundRect(ctx, -80, -34, 160, 68, 34); ctx.fill(); label(ctx, b, 0, 12, FONT(800, 32), '#fff'); ctx.restore(); });\n    } },\n\n  // ── END CARD + A NOTE FROM THE TEAM (64–72) ────────────────────────────────────────────────\n  { id: 'end-sprig', kind: 'prop', from: 64, to: 72, params: { x: 520, y: 560, scale: 1.05, color: INK.paper },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 64, 72.5, 0.4, 0.4); drawSprig(ctx, p.x, p.y, p.scale, (t - 64.1) / 1.6, p.color);\n      const a = (t - 64) * 1.6; ctx.fillStyle = INK.cyan; ctx.beginPath(); ctx.arc(p.x + 8 + Math.cos(a) * 150, p.y - 120 + Math.sin(a) * 150, 10, 0, Math.PI * 2); ctx.fill();\n    },\n    bounds(t, p) { return [p.x - 150, p.y - 270, 300, 300]; } },\n  { id: 'end-wordmark', kind: 'title', from: 64.3, to: 72, params: { x: 1090, y: 470, color: INK.paper, accent: INK.violetSoft, url: 'cloud.pinepaper.studio' },\n    draw(ctx, t, p) {\n      ctx.globalAlpha = win(t, 64.3, 72.5, 0.2, 0.4);\n      setWord(ctx, 'PinePaper', p.x, p.y, FONT(800, 150), p.color, (t - 64.4) / 1.3, 4);\n      ctx.globalAlpha = win(t, 65.6, 72.5, 0.5, 0.4); label(ctx, 'Describe it. Relate it. Edit it by hand. Ship it.', p.x, p.y + 80, FONT(500, 38), p.accent);\n      ctx.globalAlpha = win(t, 66.2, 72.5, 0.5, 0.4); label(ctx, p.url, p.x, p.y + 140, MONO(32), INK.muted);\n    },\n    bounds(t, p) { return [p.x - 420, p.y - 130, 840, 300]; } },\n  { id: 'team-note', kind: 'title', from: 67, to: 72, params: { x: 1090, y: 800, color: '#ffe4ec', heart: INK.coral, text: 'Made with', team: 'by the PinePaper team' },\n    draw(ctx, t, p, rng) {\n      ctx.globalAlpha = win(t, 67, 72.5, 0.5, 0.4);\n      const u = easeOut((t - 67) / 0.8);\n      ctx.save(); ctx.translate(p.x, p.y + (1 - u) * 30); ctx.rotate(-0.03);\n      ctx.font = HAND(58); const w1 = ctx.measureText(p.text).width, w2 = ctx.measureText(p.team).width, total = w1 + 70 + w2;\n      label(ctx, p.text, -total / 2, 0, HAND(58), p.color, 'left');\n      heart(ctx, -total / 2 + w1 + 35, -18, 2.2 * (1 + 0.18 * beatPulse(t)), p.heart);\n      label(ctx, p.team, -total / 2 + w1 + 70, 0, HAND(58), p.color, 'left');\n      ctx.strokeStyle = p.heart; ctx.lineWidth = 4; ctx.lineCap = 'round'; const sw = []; for (let i = 0; i <= 30; i++) sw.push([-total / 2 + (i / 30) * total, 26 + Math.sin(i / 30 * Math.PI * 2) * 6]); drawPartial(ctx, sw, easeOut((t - 67.6) / 0.9));\n      ctx.restore();\n      for (let i = 0; i < 14; i++) { const side = i % 2 ? 1 : -1, x0 = p.x + side * (560 + rng() * 180), sp = 30 + rng() * 40, ph = rng() * 6, born = 67.4 + rng() * 2.5; if (t < born) continue; const age = t - born, y = p.y + 10 - age * sp; ctx.globalAlpha = win(t, 67, 72.5, 0.5, 0.4) * clamp(1 - age / 3.2) * 0.8; heart(ctx, x0 + Math.sin(age * 2 + ph) * 18, y, 0.9 + rng() * 0.7, [INK.coral, INK.pink, '#fda4af'][i % 3]); }\n    },\n    bounds(t, p) { return [p.x - 520, p.y - 70, 1040, 110]; } },\n\n  // ── THE PAPER SWIPE on every cut — drawn last, over everything ─────────────────────────────\n  { id: 'swipe', kind: 'effect', from: 5.4, to: 64.8, params: { cuts: CUTS, a: INK.violet, b: INK.paper2 },\n    draw(ctx, t, p) {\n      for (const c of p.cuts) {\n        const u = (t - (c - 0.4)) / 0.8; if (u <= 0 || u >= 1) continue;\n        // one band wide enough to cover the whole frame at the cut, so the scene change happens under it\n        const x = lerp(-2000, W + 2000, ease(u));\n        const band = (x0, x1, col) => { ctx.fillStyle = col; ctx.beginPath(); ctx.moveTo(x0 + 300, -40); ctx.lineTo(x1 + 300, -40); ctx.lineTo(x1 - 300, H + 40); ctx.lineTo(x0 - 300, H + 40); ctx.closePath(); ctx.fill(); };\n        band(x - 1800, x + 1800, p.a); band(x + 1680, x + 1800, p.b); band(x - 1800, x - 1680, p.b);\n      }\n    } },\n];\n\nfunction draw(ctx, t, params, rng) {\n  for (const e of entities) {\n    if (t < e.from || t > e.to) continue;\n    ctx.save(); e.draw(ctx, t, e.params, rng.for(e.id)); ctx.restore();\n  }\n}\n\n// ── THE SCORE — 120 BPM, bright C major (C–G–Am–F). Written to a BUDGET: the engine synthesises every\n// note from scratch (no reuse of identical notes yet), and a 892-note first draft mixed so slowly the\n// render's watchdog gave up before frame 1. So: half-time drums (kick on 1, clap on 3), one bass note\n// per bar, a two-note plucked figure per bar, no sustained pad bed — and a whoosh into every cut and a\n// hit on every reveal, which is what makes it feel fast. The end card warms up with strings. ────────\nfunction compose(params) {\n  const n = [];\n  const prog = [[60, 64, 67], [55, 59, 62], [57, 60, 64], [53, 57, 60]];   // C G Am F\n  const roots = [36, 31, 33, 29];\n  // intro sparkle while the sprig inks (0–4), then the drop\n  [72, 76, 79, 84, 88].forEach((m, i) => n.push({ inst: 'bell', t: 0.3 + i * 0.3, midi: m, dur: 0.6, vel: 0.24 }));\n  n.push({ inst: 'whoosh', t: 3.3, dur: 0.7, vel: 0.35 }, { inst: 'kick', t: 4, dur: 0.3, vel: 0.6 });\n  for (let bar = 2; bar < 32; bar++) {\n    const t0 = bar * BAR, ch = prog[bar % 4];\n    n.push({ inst: 'kick', t: t0, dur: 0.25, vel: 0.52 });\n    n.push({ inst: 'clap', t: t0 + 1, dur: 0.2, vel: 0.3 });\n    n.push({ inst: 'pluck', t: t0, midi: roots[bar % 4], dur: 1.2, vel: 0.36 });\n    n.push({ inst: 'pluck', t: t0 + 0.5, midi: ch[bar % 3] + 12, dur: 0.3, vel: 0.2 }, { inst: 'pluck', t: t0 + 1.5, midi: ch[(bar + 1) % 3] + 12, dur: 0.3, vel: 0.18 });\n  }\n  // a whoosh into every cut and a bright hit on the cut itself\n  for (const c of CUTS) n.push({ inst: 'whoosh', t: c - 0.45, dur: 0.5, vel: 0.32 }, { inst: 'bell', t: c, midi: 84, dur: 0.5, vel: 0.22 });\n  // reveals\n  n.push({ inst: 'pop', t: SYSTEM.enter, dur: 0.1, vel: 0.35 });\n  SYSTEM.pops.forEach((t, i) => n.push({ inst: 'bell', t, midi: [79, 83, 86][i], dur: 0.4, vel: 0.2 }));\n  n.push({ inst: 'pop', t: SYSTEM.grab, dur: 0.1, vel: 0.35 }, { inst: 'tom', t: SYSTEM.release, dur: 0.25, vel: 0.35 });\n  n.push({ inst: 'pop', t: 21.4, dur: 0.1, vel: 0.25 }, { inst: 'pop', t: 23.4, dur: 0.1, vel: 0.25 }, { inst: 'pop', t: 25, dur: 0.1, vel: 0.25 });\n  n.push({ inst: 'snare', t: 30.4, dur: 0.2, vel: 0.3 }, { inst: 'beep', t: 31.8, midi: 84, dur: 0.1, vel: 0.14 });\n  [41.5, 41.9, 42.3].forEach((t, i) => n.push({ inst: 'bell', t, midi: [76, 79, 84][i], dur: 0.35, vel: 0.16 }));\n  MAP.pins.forEach((t, i) => n.push({ inst: 'bell', t, midi: [72, 76, 79, 84][i], dur: 0.35, vel: 0.18 }));\n  [53, 53.4, 53.8].forEach((t, i) => n.push({ inst: 'pluck', t, midi: 72 + i * 4, dur: 0.25, vel: 0.2 }));\n  [58.5, 59.5, 60.5].forEach((t) => n.push({ inst: 'tom', t, dur: 0.25, vel: 0.32 }));\n  [61.4, 61.9].forEach((t, i) => n.push({ inst: 'bell', t, midi: 84 + i * 3, dur: 0.3, vel: 0.14 }));\n  // the end card: warm strings, a heartbeat under the team's note, one last bright chord\n  for (const [t0, ch] of [[64, [60, 64, 67]], [68, [55, 59, 62]]]) for (const m of ch) n.push({ inst: 'strings', t: t0, midi: m, dur: 3.6, vel: 0.14 });\n  for (let t = 67; t < 71; t += 1) n.push({ inst: 'kick', t, dur: 0.2, vel: 0.22 });\n  n.push({ inst: 'bell', t: 70, midi: 84, dur: 1.6, vel: 0.22 }, { inst: 'bell', t: 70, midi: 91, dur: 1.6, vel: 0.14 });\n  return n;\n}\n","exportProfile":{"format":"mp4","resolution":"720p","fps":30,"durationSeconds":72},"source":{"prompt":"PinePaper in a Minute (v2) — a 72-second, 120 BPM landing film written directly as a draw contract: describe, relate, edit by hand, then a Vox-style explainer, a motion UI mockup, illustrated scenes, a map, diagrams and data, every format, and a note from the team; a bright C-major score with a whoosh on every cut.","provider":"anthropic","model":"claude-opus-5-5 (written directly)","app":"gallery-reproduction"}},"license":"CC0-1.0"}