{"id":"big-blue","title":"Big Blue","category":"Drawn film","prompt":"A 15 second animated film on a deep blue twilight sea: a huge friendly humpback whale, filling most of the frame, rises slowly out of the water, blows a tall fountain of glittering spray, rolls on its side and waves one long fin, then dives with a big splash of its tail as the sun sets gold behind it and small fish leap in arcs around it. It ends with the handwritten title 'Big Blue' writing itself on across the sky. Music scored to each beat.","designedBy":{"provider":"anthropic","model":"claude-opus-5-5","inference":{"inputTokens":48363,"outputTokens":64146,"costUsd":1.4764}},"render":{"credits":8,"priceUsd":0.12,"format":"mp4","renderMs":23583,"frameCount":450,"bytes":5659607,"seed":3845657537,"width":1280,"height":720,"fps":30,"durationSeconds":15},"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=big-blue), and it exports as MP4 or GIF, not as a still."},"renderRequest":{"drawContract":"const meta = { duration: 15, fps: 24, size: [1920, 1080], seed: 11 };\n// ── design graph kit: linked by the server from what this film calls — do not edit ──\nvar kit = {};\n// pp:MotionBasics — linked from the design graph\nkit.clamp = function (v, a, b) { return v < a ? a : v > b ? b : v; };\n// Numbers, or COLOURS: models reach for lerp('#4a284e', '#321c38', u) to blend a sky, and got '#4a284eNaN'.\nkit.rgba = function (c) {\n  var s = String(c).trim(), m;\n  if ((m = /^#([0-9a-f]{3,8})$/i.exec(s))) { var h = m[1]; if (h.length <= 4) h = h.replace(/./g, '$&$&'); return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16), h.length >= 8 ? parseInt(h.slice(6, 8), 16) / 255 : 1]; }\n  if ((m = /^rgba?\\(([^)]+)\\)$/i.exec(s))) { var p = m[1].split(','); return [+p[0], +p[1], +p[2], p.length > 3 ? +p[3] : 1]; }\n  return null;\n};\nkit.lerp = function (a, b, u) {\n  if (typeof a === 'string' || typeof b === 'string') {\n    var ca = kit.rgba(a), cb = kit.rgba(b); if (!ca || !cb) return u < 0.5 ? a : b;\n    var k = function (i) { return ca[i] + (cb[i] - ca[i]) * u; };\n    return 'rgba(' + Math.round(k(0)) + ',' + Math.round(k(1)) + ',' + Math.round(k(2)) + ',' + Math.round(k(3) * 1000) / 1000 + ')';\n  }\n  return a + (b - a) * u;\n};\nkit.seg = function (t, a, b) { return b === a ? (t >= b ? 1 : 0) : kit.clamp((t - a) / (b - a), 0, 1); };\nkit.hash = function (n) { var s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };\nkit.ease = {\n  linear: function (u) { return u; },\n  \"in\": function (u) { return u * u * u; },\n  out: function (u) { return 1 - Math.pow(1 - u, 3); },\n  inOut: function (u) { return u < 0.5 ? 4 * u * u * u : 1 - Math.pow(-2 * u + 2, 3) / 2; },\n  back: function (u) { var c = 1.7; return 1 + (c + 1) * Math.pow(u - 1, 3) + c * Math.pow(u - 1, 2); },\n  elastic: function (u) { return u === 0 || u === 1 ? u : Math.pow(2, -10 * u) * Math.sin((u * 10 - 0.75) * (Math.PI * 2 / 3)) + 1; }\n};\n// The film's own frame (meta.size), so a technique can size itself as a share of it — a composition rule, kept.\nkit.frame = function () { var m = typeof meta !== 'undefined' && meta && meta.size ? meta.size : [1280, 720]; return { w: Number(m[0]) || 1280, h: Number(m[1]) || 720 }; };\n// LETTERBOX BARS AT AN EXACT ASPECT (marketing review, 2026-10-15: bars drawn by eye left a 4.24:1 picture where the plan\n// said 2.39:1 — 58% of the frame black). Bars top and bottom so the picture between them is ratio:1 of the frame's width.\n// Returns the picture's band { y0, y1 } so a scene keeps its subject inside it.\n// A PLACEHOLDER FOR A CHARACTER NOT BUILT YET (story films): a flat grey shape with a dashed outline and a label PLATE\n// that always carries its text — dark ink on a light plate, whatever the film's style colours (alley round 4b: a writer\n// set the label in S.ink, cream on a cream plate, and every plate read blank). size >= 5% of the frame's height.\n// o: { x, y (centre), w, h, label, fill?, plate: 'above' | 'below' (default above), alpha?, plateX? (the plate's centre, default x), part?: 'shape' | 'plate' (one\n// half only, so several placeholders' plates can go on top of all their shapes) }. Returns the plate box.\n// A PUFF OF SNOW (or dust) WHERE SOMETHING LANDS (alley round 5: a writer's puff was ten outlined circles rolling 600 px\n// away from where nobody landed). Irregular low clumps spraying out both ways from the touch point, no outlines,\n// rising a little and settling back over ~1.4 s; a few grains thrown higher. o: { x, y (the ground point), at (the\n// landing second), w (the lander's width, px; default 160), color (default snow #F4F1EA) }.\nkit.puff = function (ctx, t, o) {\n  o = o || {}; var age = t - (o.at || 0); if (age < 0 || age > 1.6) return;\n  var w = o.w || 160, x = o.x, y = o.y, u = kit.ease.out(kit.clamp(age / 0.45, 0, 1)), fade = 1 - kit.clamp((age - 0.7) / 0.9, 0, 1), col = o.color || '#F4F1EA';\n  ctx.save(); ctx.fillStyle = col;\n  for (var k = 0; k < 14; k++) {\n    var side = k % 2 ? 1 : -1, h1 = kit.hash(k * 3 + 11), h2 = kit.hash(k * 3 + 12), h3 = kit.hash(k * 3 + 13);\n    var reach = (0.05 + 0.8 * h1) * w * 0.9 * u, cx = x + side * reach, cy = y - (6 + 22 * h2) * Math.sin(Math.PI * kit.clamp(age / 1.1, 0, 1)) * (1 - h1 * 0.5);\n    var r = (w * (0.05 + 0.07 * h3)) * (0.5 + 0.5 * u) * fade; if (r < 1) continue;\n    ctx.globalAlpha = 0.85 * fade; ctx.beginPath();\n    for (var j = 0; j <= 7; j++) { var a = j / 7 * Math.PI * 2, rr = r * (0.75 + 0.5 * kit.hash(k * 17 + j)); var px = cx + Math.cos(a) * rr * 1.3, py = cy + Math.sin(a) * rr * 0.7; if (j) ctx.lineTo(px, py); else ctx.moveTo(px, py); }\n    ctx.closePath(); ctx.fill();\n  }\n  for (var g = 0; g < 10; g++) {\n    var gs = g % 2 ? 1 : -1, gh = kit.hash(g + 70), gx = x + gs * (0.2 + 0.7 * gh) * w * 0.7 * u, gy = y - (40 + 50 * kit.hash(g + 90)) * Math.sin(Math.PI * kit.clamp(age / 0.9, 0, 1));\n    if (age > 0.9) continue; ctx.globalAlpha = 0.9 * fade; ctx.fillRect(gx, gy, 3, 3);\n  }\n  ctx.restore();\n};\n// A PILE OF FALLEN ROCK (alley round 8: a \"fallen boulder wall\" drawn as neat courses of like blocks, each snow-capped,\n// read as dry-stone masonry — the user's \"bricks\" again). Mixed sizes, wedged at angles, gaps between, no rows: each\n// stone an irregular polygon, the big ones low, lit from above, snow on a few top faces only.\n// o: { x, y (the pile's box: left, top), w, h, seed?, color?, ink?, snow? (0..1, default 0.35) }.\nkit.rockPile = function (ctx, o) {\n  o = o || {}; var X = o.x, Y = o.y, w = o.w, h = o.h, seed = o.seed || 1, col = o.color || '#5E6163', ink = o.ink || '#1A1A1A', snowy = o.snow == null ? 0.35 : o.snow;\n  // its profile: a mound, high in the middle, sloping to the sides (a pile has no vertical edges)\n  var crest = function (px) { var u = kit.clamp((px - X) / w, 0, 1), e = Math.abs(u - 0.45) / 0.55; return Y + h * (0.05 + 0.12 * kit.hash(seed * 53 + Math.round(u * 10)) * (1 - e * e) + 0.95 * e * e); };\n  var stones = [], n = Math.max(8, Math.round(w * h / 3200));\n  for (var i = 0; i < n * 3 && stones.length < n; i++) {\n    var hx = kit.hash(seed * 131 + i * 3 + 1), hy = kit.hash(seed * 131 + i * 3 + 2), hs = kit.hash(seed * 131 + i * 3 + 3);\n    var ty = Math.sqrt(hy), r = (0.35 + 0.65 * Math.pow(hs, 1.8)) * Math.min(w, h) * (0.08 + 0.12 * ty);\n    var cx = X + r * 0.6 + hx * (w - r * 1.2), cy = Y + r * 0.5 + ty * (h - r * 0.9);\n    if (cy - r * 0.4 < crest(cx)) continue; var crowd = stones.some(function (q) { return Math.hypot(q.cx - cx, q.cy - cy) < (q.r + r) * 0.45; }); if (crowd) continue;\n    stones.push({ cx: cx, cy: cy, r: r, k: i, a: (kit.hash(seed * 7 + i) - 0.5) * 1.2 });\n  }\n  stones.sort(function (p, q) { return p.cy - q.cy; });\n  ctx.save(); ctx.lineJoin = 'round';\n  // the pile's body behind the stones: the gaps between them read as shadow inside the pile, never as the sky\n  ctx.beginPath(); ctx.moveTo(X, Y + h); for (var b = 0; b <= 20; b++) { var bx = X + w * b / 20; ctx.lineTo(bx, crest(bx) + h * 0.04); } ctx.lineTo(X + w, Y + h); ctx.closePath();\n  ctx.fillStyle = '#2B2E30'; ctx.fill();\n  stones.forEach(function (st) {\n    var m = 5 + Math.floor(kit.hash(st.k * 13 + seed) * 4), pts = [];\n    for (var j = 0; j < m; j++) { var ang = st.a + j / m * Math.PI * 2 + (kit.hash(st.k * 29 + j) - 0.5) * 0.7, rr = st.r * (0.7 + 0.45 * kit.hash(st.k * 31 + j)); pts.push([st.cx + Math.cos(ang) * rr * 1.15, st.cy + Math.sin(ang) * rr * 0.85]); }\n    var shade = 0.82 + 0.3 * kit.hash(st.k * 37 + seed);\n    ctx.beginPath(); pts.forEach(function (q, j) { if (j) ctx.lineTo(q[0], q[1]); else ctx.moveTo(q[0], q[1]); }); ctx.closePath();\n    ctx.fillStyle = col; ctx.globalAlpha = 1; ctx.fill(); ctx.fillStyle = shade > 1 ? 'rgba(255,255,255,' + (shade - 1) + ')' : 'rgba(0,0,0,' + (1 - shade) + ')'; ctx.fill();\n    // the lit top facet: the upper vertices pulled toward the centre\n    ctx.beginPath(); var top = pts.filter(function (q) { return q[1] < st.cy; }); if (top.length >= 2) { ctx.moveTo(top[0][0], top[0][1]); top.forEach(function (q) { ctx.lineTo(q[0], q[1]); }); ctx.lineTo(st.cx, st.cy - st.r * 0.15); ctx.closePath(); ctx.fillStyle = 'rgba(255,255,255,0.12)'; ctx.fill(); }\n    if (kit.hash(st.k * 41 + seed) < snowy && top.length >= 2) { ctx.beginPath(); ctx.moveTo(top[0][0], top[0][1]); top.forEach(function (q) { ctx.lineTo(q[0], q[1] - 2); }); ctx.quadraticCurveTo(st.cx, st.cy - st.r * (0.25 + 0.3 * kit.hash(st.k * 43)), top[0][0], top[0][1]); ctx.fillStyle = '#F4F1EA'; ctx.fill(); }\n    ctx.beginPath(); pts.forEach(function (q, j) { if (j) ctx.lineTo(q[0], q[1]); else ctx.moveTo(q[0], q[1]); }); ctx.closePath(); ctx.lineWidth = Math.max(2, st.r * 0.05); ctx.strokeStyle = ink; ctx.stroke();\n  });\n  ctx.restore();\n};\n\n// A NATURAL ROCK FACE (alley round 9: a cliff drawn with evenly spaced full-width bands read as planks or courses).\n// An irregular outline, strata that wander, vary in spacing, break off and overlap — never across the whole width,\n// never evenly spaced — a few cracks that fork, darker hollows, and snow lodged on some ledges only.\n// o: { x, y, w, h (its box), seed?, color?, ink?, snow? (0..1) , edge?: 'left' | 'right' | 'none' (a jagged open side) }.\nkit.rockFace = function (ctx, o) {\n  o = o || {}; var X = o.x, Y = o.y, w = o.w, h = o.h, seed = o.seed || 2, col = o.color || '#6E7173', ink = o.ink || '#1A1A1A', snowy = o.snow == null ? 0.3 : o.snow, edge = o.edge || 'none';\n  var H = function (i) { return kit.hash(seed * 97 + i); };\n  ctx.save(); ctx.lineJoin = 'round'; ctx.lineCap = 'round';\n  // the outline: a jagged top, and a jagged open side when it ends inside the frame\n  ctx.beginPath(); ctx.moveTo(X, Y + h);\n  if (edge === 'left') { for (var a = 10; a >= 1; a--) ctx.lineTo(X + w * (0.02 + 0.07 * H(a + 300)) , Y + h * a / 10); } else ctx.lineTo(X, Y + h * 0.04 * H(1));\n  for (var b = edge === 'left' ? 1 : 0; b <= 14; b++) ctx.lineTo(X + w * b / 14, Y + h * (0.02 + 0.07 * H(b + 10)));\n  if (edge === 'right') { for (var c = 0; c <= 10; c++) ctx.lineTo(X + w * (0.91 + 0.07 * H(c + 330)), Y + h * c / 10); } else ctx.lineTo(X + w, Y + h);\n  ctx.closePath(); ctx.fillStyle = col; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = ink; ctx.stroke(); ctx.save(); ctx.clip();\n  // hollows: soft darker irregular patches\n  for (var k = 0; k < 7; k++) { var hx = X + w * H(k + 40), hy = Y + h * (0.15 + 0.75 * H(k + 50)), hr = Math.min(w, h) * (0.05 + 0.08 * H(k + 60));\n    ctx.beginPath(); for (var j = 0; j < 7; j++) { var an = j / 7 * Math.PI * 2, rr = hr * (0.6 + 0.6 * H(k * 11 + j + 70)); if (j) ctx.lineTo(hx + Math.cos(an) * rr * 1.6, hy + Math.sin(an) * rr * 0.7); else ctx.moveTo(hx + Math.cos(an) * rr * 1.6, hy + Math.sin(an) * rr * 0.7); }\n    ctx.closePath(); ctx.fillStyle = 'rgba(0,0,0,0.14)'; ctx.fill(); }\n  // strata: short wandering segments at uneven heights, each a fraction of the width, a ledge of snow on a few\n  var n = Math.max(6, Math.round(h / 45));\n  for (var i = 0; i < n * 2; i++) {\n    var sy = Y + h * (0.08 + 0.88 * H(i + 100)), sx = X + w * (H(i + 110) * 0.85 - 0.05), len = w * (0.12 + 0.3 * H(i + 120)), tilt = (H(i + 130) - 0.5) * 0.18, segs = 5;\n    ctx.beginPath(); for (var q = 0; q <= segs; q++) { var px = sx + len * q / segs, py = sy + Math.sin(q * 1.3 + i) * h * 0.008 + tilt * len * q / segs + (H(i * 7 + q + 140) - 0.5) * h * 0.012; if (q) ctx.lineTo(px, py); else ctx.moveTo(px, py); }\n    ctx.lineWidth = 1.5 + 2 * H(i + 150); ctx.strokeStyle = 'rgba(26,26,26,' + (0.35 + 0.35 * H(i + 160)) + ')'; ctx.stroke();\n    if (H(i + 170) < snowy) { ctx.beginPath(); for (var r2 = 0; r2 <= segs; r2++) { var qx = sx + len * r2 / segs * 0.8 + len * 0.1, qy = sy - 2 + tilt * len * r2 / segs * 0.8 - Math.sin(Math.PI * r2 / segs) * h * 0.01; if (r2) ctx.lineTo(qx, qy); else ctx.moveTo(qx, qy); } ctx.lineWidth = 3 + 3 * H(i + 180); ctx.strokeStyle = '#F4F1EA'; ctx.stroke(); }\n  }\n  // cracks that fork, running mostly downward\n  for (var cr = 0; cr < 4; cr++) { var cx = X + w * (0.1 + 0.8 * H(cr + 200)), cy = Y + h * (0.1 + 0.3 * H(cr + 210)); ctx.beginPath(); ctx.moveTo(cx, cy);\n    for (var st = 0; st < 6; st++) { cx += (H(cr * 13 + st + 220) - 0.5) * w * 0.05; cy += h * (0.04 + 0.05 * H(cr * 17 + st + 230)); ctx.lineTo(cx, cy); if (st === 2) { ctx.moveTo(cx, cy); ctx.lineTo(cx + w * 0.04, cy + h * 0.06); ctx.moveTo(cx, cy); } }\n    ctx.lineWidth = 2; ctx.strokeStyle = 'rgba(26,26,26,0.6)'; ctx.stroke(); }\n  ctx.restore(); ctx.restore();\n};\n\n// A CAVE MOUTH IN A ROCK FACE (alley round 9: a black bow-tie with straight vertical sides and a tidy row of rubble\n// read as a cut opening). A dark irregular crack, wider low, under an overhanging lip that casts its own shadow, with a\n// few fallen stones scattered (never in a row) at its foot. o: { x, y (centre), w, h, seed?, color?, ink? }.\nkit.caveMouth = function (ctx, o) {\n  o = o || {}; var x = o.x, y = o.y, w = o.w, h = o.h, seed = o.seed || 5, dark = o.color || '#0B0D0C', ink = o.ink || '#1A1A1A';\n  var H = function (i) { return kit.hash(seed * 59 + i); }, pts = [], m = 24;\n  for (var i = 0; i < m; i++) { var a = Math.PI + i / m * Math.PI * 2, top = Math.sin(a) < 0, rx = w / 2 * (0.75 + 0.35 * H(i)) * (top ? 0.8 : 1.05), ry = h / 2 * (0.7 + 0.4 * H(i + 20)) * (top ? 0.9 : 0.75);\n    pts.push([x + Math.cos(a) * rx + (H(i + 40) - 0.5) * w * 0.16, y + Math.sin(a) * ry + h * 0.06 + (H(i + 45) - 0.5) * h * 0.1]); }\n  ctx.save(); ctx.lineJoin = 'round';\n  // the overhang's shadow on the face above and around the mouth\n  ctx.beginPath(); ctx.ellipse(x, y - h * 0.08, w * 0.68, h * 0.62, 0, 0, Math.PI * 2); ctx.fillStyle = 'rgba(0,0,0,0.18)'; ctx.fill();\n  ctx.beginPath(); pts.forEach(function (q, j) { if (j) ctx.lineTo(q[0], q[1]); else ctx.moveTo(q[0], q[1]); }); ctx.closePath(); ctx.fillStyle = dark; ctx.fill(); ctx.lineWidth = 2.5; ctx.strokeStyle = ink; ctx.stroke();\n  // the overhanging lip: a jagged slab of the face over the top of the mouth\n  // its underside is jagged and follows the mouth; it thins into the face at both ends and has no top edge of its own\n  var lip = [], L = 10; for (var l = 0; l <= L; l++) { var u = l / L, ux = x - w * 0.6 + w * 1.2 * u, thick = Math.sin(Math.PI * u); lip.push([ux, y - h * (0.24 + 0.1 * H(l + 60)) - thick * h * 0.06 + (1 - thick) * -h * 0.12]); }\n  ctx.beginPath(); ctx.moveTo(lip[0][0], lip[0][1]); lip.forEach(function (q) { ctx.lineTo(q[0], q[1]); });\n  for (var l2 = L; l2 >= 0; l2--) { var u2 = l2 / L; ctx.lineTo(lip[l2][0] + (H(l2 + 75) - 0.5) * w * 0.05, lip[l2][1] - Math.sin(Math.PI * u2) * h * (0.16 + 0.1 * H(l2 + 70))); }\n  ctx.closePath(); ctx.fillStyle = 'rgba(122,125,127,0.9)'; ctx.fill();\n  ctx.beginPath(); ctx.moveTo(lip[0][0], lip[0][1]); lip.forEach(function (q) { ctx.lineTo(q[0], q[1]); }); ctx.lineWidth = 2.5; ctx.strokeStyle = ink; ctx.stroke();\n  // fallen stones at its foot, scattered\n  for (var s = 0; s < 5; s++) { var sx = x + (H(s + 80) - 0.5) * w * 1.5, sy = y + h * (0.38 + 0.5 * H(s + 90)), sr = w * (0.02 + 0.07 * Math.pow(H(s + 100), 1.5));\n    ctx.beginPath(); for (var k = 0; k < 6; k++) { var an = k / 6 * Math.PI * 2 + H(s + k), rr = sr * (0.7 + 0.5 * H(s * 7 + k + 110)); if (k) ctx.lineTo(sx + Math.cos(an) * rr * 1.2, sy + Math.sin(an) * rr * 0.8); else ctx.moveTo(sx + Math.cos(an) * rr * 1.2, sy + Math.sin(an) * rr * 0.8); }\n    ctx.closePath(); ctx.fillStyle = '#5E6163'; ctx.fill(); ctx.lineWidth = 2; ctx.stroke(); }\n  ctx.restore();\n};\n\n// EYE-LIGHTS IN THE DARK (alley round 8: one faint pair where the story opens one, then many, that turn to the hero).\n// n pairs inside a box, each a glow with a bright pupil, readable at 720p; they look toward (lx, ly) when given.\n// o: { x, y (centre), w, h, n (pairs, may be fractional: the last one opening), lx?, ly?, color?, seed? }.\nkit.eyeLights = function (ctx, t, o) {\n  o = o || {}; var n = Math.max(0, o.n || 0); if (n <= 0) return; var f = kit.frame(), r = Math.max(5, f.h * 0.008), col = o.color || '#F2E27A', seed = o.seed || 3;\n  ctx.save();\n  for (var i = 0; i < Math.ceil(n); i++) {\n    var open = kit.clamp(n - i, 0, 1), ang = kit.hash(seed * 17 + i) * Math.PI * 2, rad = Math.sqrt(kit.hash(seed * 19 + i)) * 0.4;\n    var ex = o.x + Math.cos(ang) * rad * o.w, ey = o.y + Math.sin(ang) * rad * o.h, blink = (Math.sin(t * 1.3 + i * 2.1) > 0.97) ? 0.15 : 1, gap = r * 2.6;\n    var gx = 0, gy = 0; if (o.lx != null) { var dx = o.lx - ex, dy = o.ly - ey, dd = Math.hypot(dx, dy) || 1; gx = dx / dd * r * 0.45; gy = dy / dd * r * 0.45; }\n    for (var s = -1; s <= 1; s += 2) {\n      var px = ex + s * gap / 2, g = ctx.createRadialGradient(px, ey, 0, px, ey, r * 3);\n      g.addColorStop(0, col); g.addColorStop(1, 'rgba(0,0,0,0)'); ctx.globalAlpha = 0.45 * open; ctx.fillStyle = g; ctx.beginPath(); ctx.arc(px, ey, r * 3, 0, Math.PI * 2); ctx.fill();\n      ctx.globalAlpha = open; ctx.fillStyle = col; ctx.beginPath(); ctx.ellipse(px, ey, r, r * open * blink, 0, 0, Math.PI * 2); ctx.fill();\n      ctx.fillStyle = '#0B0D0C'; ctx.beginPath(); ctx.arc(px + gx, ey + gy, r * 0.42 * open * blink, 0, Math.PI * 2); ctx.fill();\n    }\n  }\n  ctx.restore();\n};\n\nkit.placeholder = function (ctx, o) {\n  o = o || {}; var f = kit.frame(), px = Math.max(36, Math.round(f.h * 0.05)), w = o.w || 200, h = o.h || 120, x = o.x, y = o.y;\n  ctx.save(); if (o.alpha != null) ctx.globalAlpha = kit.clamp(o.alpha, 0, 1);\n  if (o.part !== 'plate') { ctx.beginPath(); ctx.ellipse(x, y, w / 2, h / 2, 0, 0, Math.PI * 2); ctx.fillStyle = o.fill || 'rgba(138,141,143,0.55)'; ctx.fill();\n  ctx.setLineDash([Math.max(8, w * 0.04), Math.max(6, w * 0.03)]); ctx.lineWidth = Math.max(2, f.h * 0.005); ctx.strokeStyle = '#1A1A1A'; ctx.stroke(); ctx.setLineDash([]); }\n  var label = String(o.label || 'NOT BUILT YET'); ctx.font = '700 ' + px + 'px Arial, sans-serif';\n  var tw = ctx.measureText(label).width, pw = tw + px, ph = Math.round(px * 1.5), bx = kit.clamp((o.plateX != null ? o.plateX : x) - pw / 2, 4, f.w - pw - 4);\n  var by = o.plate === 'below' ? y + h / 2 + px * 0.4 : y - h / 2 - ph - px * 0.4; by = kit.clamp(by, 4, f.h - ph - 4);\n  if (o.part !== 'shape') { ctx.fillStyle = '#F4F1EA'; ctx.fillRect(bx, by, pw, ph); ctx.lineWidth = 3; ctx.strokeStyle = '#1A1A1A'; ctx.strokeRect(bx, by, pw, ph);\n  ctx.fillStyle = '#1A1A1A'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(label, bx + pw / 2, by + ph / 2 + 1); }\n  ctx.restore();\n  return { x: bx, y: by, w: pw, h: ph };\n};\nkit.letterbox = function (ctx, ratio, color) {\n  var f = kit.frame(), r = ratio || 2.39, h = Math.min(f.h, Math.round(f.w / r)), bar = Math.max(0, Math.round((f.h - h) / 2));\n  if (bar > 0) { ctx.save(); ctx.fillStyle = color || '#000'; ctx.fillRect(0, 0, f.w, bar); ctx.fillRect(0, f.h - bar, f.w, bar); ctx.restore(); }\n  return { y0: bar, y1: f.h - bar };\n};\nkit.settle = function (u, freq, damp) { return Math.exp(-(damp || 5) * u) * Math.cos((freq || 3) * Math.PI * u); };\nkit.line = function (ctx, pts, w, col) {\n  ctx.beginPath();\n  for (var i = 0; i < pts.length; i++) { if (i) ctx.lineTo(pts[i][0], pts[i][1]); else ctx.moveTo(pts[i][0], pts[i][1]); }\n  ctx.lineWidth = w; ctx.strokeStyle = col; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; ctx.stroke();\n};\n// pp:SquashOnImpact — linked from the design graph\nkit.squash = function (t, tImpact, o) {\n  o = o || {}; var amt = o.amount == null ? 0.4 : o.amount, dur = o.dur || 0.8;\n  if (t < tImpact) return { sx: 1, sy: 1 };\n  var w = kit.settle(kit.seg(t, tImpact, tImpact + dur), 3, 4.5) * amt;\n  return { sx: 1 + w, sy: 1 / (1 + w) };\n};\nkit.stretch = function (speed, amt) { var s = 1 + kit.clamp(speed, 0, 1) * (amt == null ? 0.3 : amt); return { sx: 1 / s, sy: s }; };\n// pp:PaperGround — linked from the design graph\nkit.paper = function (ctx, w, h, o) {\n  // overlay: true — fibre and vignette only, over a finished frame. Without it, paper is an opaque GROUND: a film that\n  // drew it last as \"paper_overlay\" covered its whole picture in cream (gemini-3.8-flash, 2026-10-04).\n  o = o || {}; var y0 = o.y || 0;\n  ctx.save(); if (!o.overlay) { ctx.fillStyle = o.color || '#f4ecd8'; ctx.fillRect(0, y0, w, h); }\n  var n = o.grain == null ? Math.round(w * h / 3500) : o.grain;\n  for (var i = 0; i < n; i++) { ctx.fillStyle = 'rgba(120,90,60,' + (0.05 + kit.hash(i + 77) * 0.08).toFixed(3) + ')'; ctx.fillRect(kit.hash(i) * w, y0 + kit.hash(i + 999) * h, 1.5 + kit.hash(i + 5) * 2, 1); }\n  if (o.vignette !== false) { var g = ctx.createRadialGradient(w / 2, y0 + h / 2, Math.min(w, h) * 0.3, w / 2, y0 + h / 2, Math.max(w, h) * 0.75); g.addColorStop(0, 'rgba(0,0,0,0)'); g.addColorStop(1, 'rgba(90,60,30,' + (o.vignette == null ? 0.16 : o.vignette) + ')'); ctx.fillStyle = g; ctx.fillRect(0, y0, w, h); }\n  ctx.restore();\n};\nkit.sky = function (ctx, w, h, colors, y0) {\n  y0 = y0 || 0; var g = ctx.createLinearGradient(0, y0, 0, y0 + h);\n  for (var i = 0; i < colors.length; i++) g.addColorStop(colors.length === 1 ? 0 : i / (colors.length - 1), colors[i]);\n  ctx.fillStyle = g; ctx.fillRect(0, y0, w, h); return g;\n};\n// pp:InkedLayers — linked from the design graph\nkit.hills = function (ctx, o) {\n  var w = o.w, h = o.h, y = o.y, amp = o.amp == null ? 30 : o.amp, f = o.freq || 0.005, ph = o.phase || 0, sc = o.scroll || 0, oy = o.offsetY || 0;\n  function crest(x) { var wx = x + sc; return oy + y + amp * Math.sin(wx * f + ph) + amp * 0.35 * Math.sin(wx * f * 2.7 + ph * 1.3); }\n  kit._ground = crest;   // the last (nearest) ground drawn: a hero's contact shadow lands on it, not under it in mid-air\n  var pts = [];\n  for (var x = -20; x <= w + 20; x += 16) pts.push([x, crest(x) + (kit.hash(Math.floor((x + sc) / 16)) - 0.5) * 1.6]);\n  ctx.save();\n  if (o.shadow !== false) { ctx.shadowColor = 'rgba(60,40,20,0.22)'; ctx.shadowBlur = 14; ctx.shadowOffsetY = -4; }\n  ctx.beginPath(); ctx.moveTo(-20, oy + h + 40); for (var i = 0; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.lineTo(w + 20, oy + h + 40); ctx.closePath();\n  ctx.fillStyle = o.fill || '#93c47d'; ctx.fill(); ctx.restore();\n  if (o.hatch) {\n    ctx.save(); ctx.strokeStyle = o.hatch; ctx.lineWidth = 2;\n    for (var k = 0; k < (o.hatchCount || 26); k++) {\n      var hx = ((kit.hash(k * 3 + ph) * w * 1.6 - sc) % (w + 80) + w + 80) % (w + 80) - 40, hy = crest(hx) + 18 + kit.hash(k * 7 + ph) * 50;\n      ctx.beginPath(); ctx.moveTo(hx, hy); ctx.lineTo(hx + 10, hy - 7); ctx.stroke();\n    }\n    ctx.restore();\n  }\n  if (o.ink !== null) kit.line(ctx, pts, o.inkWidth || 2.6, o.ink || '#3a2c28');\n  return crest;\n};\n// pp:SkyElements — linked from the design graph\nkit.cloud = function (ctx, x, y, s, o) {\n  o = o || {}; var P = [[0, 0, 34], [32, -14, 40], [70, 0, 32], [36, 12, 30], [-26, 8, 24], [96, 10, 22]], i;\n  ctx.save();\n  // A faded cloud has no ink: outlined puffs cannot fade as one shape without their insides showing.\n  if (o.ink !== null && !(o.opacity != null && o.opacity < 1)) { ctx.lineWidth = 6; ctx.strokeStyle = o.ink || '#3a2c28'; for (i = 0; i < P.length; i++) { ctx.beginPath(); ctx.arc(x + P[i][0] * s, y + P[i][1] * s, P[i][2] * s, 0, Math.PI * 2); ctx.stroke(); } }\n  // ONE path for every puff, filled once: a translucent fill stays one even shape instead of showing every overlap.\n  ctx.beginPath(); for (i = 0; i < P.length; i++) { ctx.moveTo(x + (P[i][0] + P[i][2]) * s, y + P[i][1] * s); ctx.arc(x + P[i][0] * s, y + P[i][1] * s, P[i][2] * s, 0, Math.PI * 2); }\n  // The fill is drawn OPAQUE over the outline strokes, or every inner puff edge shows through it; a translucent\n  // rgba() fill keeps its colour and loses its alpha. Use opacity for a soft cloud: it fades the whole cloud as one.\n  if (o.opacity != null) ctx.globalAlpha = o.opacity;\n  ctx.fillStyle = String(o.fill || '#ffffff').replace(/^rgba\\(([^,]+),([^,]+),([^,]+),[^)]*\\)$/, 'rgb($1,$2,$3)'); ctx.fill('nonzero');\n  ctx.restore();\n};\nkit.sun = function (ctx, x, y, r, t, o) {\n  o = o || {}; t = t || 0; ctx.save(); ctx.translate(x, y); ctx.rotate(t * 0.5);\n  for (var i = 0; i < 12; i++) { ctx.rotate(Math.PI / 6); kit.line(ctx, [[r * 1.25, 0], [r * 1.7 + r * 0.12 * Math.sin(t * 4 + i), 0]], Math.max(2, r * 0.08), o.ray || '#e8a317'); }\n  ctx.restore();\n  ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = o.fill || '#ffcd4a'; ctx.fill();\n  if (o.ink !== null) { ctx.lineWidth = 3; ctx.strokeStyle = o.ink || '#3a2c28'; ctx.stroke(); }\n};\nkit.moon = function (ctx, x, y, r, o) {\n  o = o || {}; ctx.save();\n  var g = ctx.createRadialGradient(x, y, r * 0.5, x, y, r * 2.8); g.addColorStop(0, 'rgba(255,240,190,0.45)'); g.addColorStop(1, 'rgba(255,240,190,0)');\n  ctx.fillStyle = g; ctx.fillRect(x - r * 3, y - r * 3, r * 6, r * 6);\n  ctx.beginPath(); ctx.rect(x - r * 2, y - r * 2, r * 4, r * 4); ctx.arc(x + r * 0.48, y - r * 0.26, r * 0.9, 0, Math.PI * 2, true); ctx.clip('evenodd');\n  ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = o.fill || '#fff3c4'; ctx.fill();\n  ctx.lineWidth = 3; ctx.strokeStyle = o.edge || '#e8d38a'; ctx.stroke(); ctx.restore();\n};\nkit.stars = function (ctx, w, h, t, o) {\n  o = o || {}; var n = o.count || 140, seed = o.seed || 500, y0 = o.y || 0;\n  for (var i = 0; i < n; i++) {\n    var x = kit.hash(i + seed) * w, y = y0 + kit.hash(i + seed + 400) * h, tw = 0.5 + 0.5 * Math.sin(t * (2 + kit.hash(i) * 4) + i), r = 0.8 + kit.hash(i + 1) * 1.8;\n    ctx.fillStyle = 'rgba(255,248,220,' + (0.35 + 0.65 * tw).toFixed(3) + ')'; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fill();\n    if (i % 17 === 0) { var c = 'rgba(255,248,220,' + tw.toFixed(3) + ')'; kit.line(ctx, [[x - 8 * tw, y], [x + 8 * tw, y]], 1.5, c); kit.line(ctx, [[x, y - 8 * tw], [x, y + 8 * tw]], 1.5, c); }\n  }\n};\nkit.rain = function (ctx, w, h, t, o) {\n  o = o || {}; var n = o.count || 120, sp = o.speed || 900, y0 = o.y || 0, slant = o.slant == null ? 0.2 : o.slant;\n  ctx.save(); ctx.lineCap = 'round'; ctx.lineWidth = o.width || 2;\n  for (var i = 0; i < n; i++) {\n    var x = kit.hash(i + 200) * (w + 200) - 100, len = 30 + kit.hash(i + 300) * 50;\n    var y = y0 + ((kit.hash(i + 400) * (h + 200) + t * sp * (0.8 + kit.hash(i) * 0.6)) % (h + 200)) - 100;\n    ctx.strokeStyle = o.color || ('rgba(255,255,255,' + (0.25 + kit.hash(i + 50) * 0.4).toFixed(3) + ')');\n    ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x - len * slant, y + len); ctx.stroke();\n  }\n  ctx.restore();\n};\n// pp:CharacterWithEyes — linked from the design graph\n// A CONTACT SHADOW LIES ON THE GROUND (owner, 2026-10-09: Pip flew with its shadow flying under it, in the sky). Under\n// the feet when the hero stands; on the ground below, smaller and fainter, as it rises. The ground is o.ground (a y, or\n// a function of x), else the nearest kit.hills drawn; with neither, under the feet as before.\n// A SOFT contact shadow: a radial falloff with no edge (PIP-8 review: \"a hard grey puck\"). color: an 'r,g,b' triple —\n// warm by default, cool blue-grey on snow.\nkit.contactShadow = function (ctx, x, feetY, rx, flat, ground, color) {\n  var g = typeof ground === 'function' ? ground(x) : (typeof ground === 'number' ? ground : (kit._ground ? kit._ground(x) : null));\n  var y = feetY, k = 1, H = kit.frame().h, rgb = color || '60,30,20';\n  if (g != null && isFinite(g) && feetY < g - 2) { k = kit.clamp(1 - (g - feetY) / (H * 0.45), 0, 1); y = g; }\n  if (k < 0.05) return;\n  ctx.save(); ctx.translate(x, y); ctx.scale(0.45 + 0.55 * k, flat);\n  var gr = ctx.createRadialGradient(0, 0, 0, 0, 0, rx * 1.15);\n  gr.addColorStop(0, 'rgba(' + rgb + ',' + (0.18 * k).toFixed(3) + ')'); gr.addColorStop(0.6, 'rgba(' + rgb + ',' + (0.14 * k).toFixed(3) + ')'); gr.addColorStop(1, 'rgba(' + rgb + ',0)');   // the old strength at its heart, fading to nothing\n  ctx.beginPath(); ctx.arc(0, 0, rx * 1.15, 0, Math.PI * 2); ctx.fillStyle = gr; ctx.fill(); ctx.restore();\n};\nkit.character = function (ctx, x, y, o) {\n  // A hero is never a dot: every model asked for 15-30% chose 5-8% (flash, pro, sonnet, 2026-10-04). Below 14% of the\n  // frame's height (20% since the references keep heroes at 20-30%) only when the film says it is a wide shot (far: true).\n  o = o || {}; var R = o.size ? o.size * kit.frame().h / 2 : (o.r || kit.frame().h * 0.11); if (!o.far) R = Math.max(R, kit.frame().h * 0.1); var sx = o.sx || 1, sy = o.sy || 1, ink = o.ink || '#3a2c28', eyes = o.eyes == null ? 1 : o.eyes, look = o.look || [0, 0];\n  if (o.shadow !== false) kit.contactShadow(ctx, x, y, R * 0.95 * sx, 0.22, o.ground);\n  ctx.save(); ctx.translate(x, y);\n  ctx.scale(sx, sy); ctx.translate(0, -R); if (o.rot) ctx.rotate(o.rot);\n  var g = ctx.createRadialGradient(-R * 0.35, -R * 0.4, R * 0.1, 0, 0, R);\n  g.addColorStop(0, o.light || '#ff8a6a'); g.addColorStop(0.55, o.color || '#ec5b3c'); g.addColorStop(1, o.dark || '#c43f24');\n  ctx.beginPath(); ctx.arc(0, 0, R, 0, Math.PI * 2); ctx.fillStyle = g; ctx.fill();\n  ctx.lineWidth = 4 / Math.max(0.3, (sx + sy) / 2); ctx.strokeStyle = ink; ctx.stroke();\n  ctx.beginPath(); ctx.ellipse(-R * 0.38, -R * 0.45, R * 0.2, R * 0.11, -0.6, 0, Math.PI * 2); ctx.fillStyle = 'rgba(255,255,255,0.7)'; ctx.fill();\n  if (eyes > 0.01) {\n    for (var side = -1; side <= 1; side += 2) {\n      var er = R * 0.24 * eyes;\n      ctx.save(); ctx.translate(side * R * 0.3, -R * 0.12); ctx.scale(1, o.blink ? 0.12 : 1);\n      ctx.beginPath(); ctx.arc(0, 0, er, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.lineWidth = 3; ctx.strokeStyle = ink; ctx.stroke();\n      ctx.beginPath(); ctx.arc(look[0] * er * 0.4, look[1] * er * 0.4, er * 0.48, 0, Math.PI * 2); ctx.fillStyle = ink; ctx.fill();\n      ctx.beginPath(); ctx.arc(look[0] * er * 0.4 - er * 0.15, look[1] * er * 0.4 - er * 0.18, er * 0.14, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill();\n      ctx.restore();\n    }\n  }\n  if (o.mouth === 'smile') { ctx.beginPath(); ctx.arc(0, R * 0.22, R * 0.22, 0.15 * Math.PI, 0.85 * Math.PI); ctx.lineWidth = 3; ctx.strokeStyle = ink; ctx.stroke(); }\n  else if (o.mouth === 'o') { ctx.beginPath(); ctx.ellipse(0, R * 0.32, R * 0.09, R * 0.12, 0, 0, Math.PI * 2); ctx.fillStyle = ink; ctx.fill(); }\n  ctx.restore();\n};\n// pp:FaceOnAnything — linked from the design graph\n// THE HERO OF ANY SHAPE, at hero size. body(ctx, px) draws the body in a UNIT box (x and y from -1 to 1, feet at y = 1;\n// px = one screen pixel in those units, for line widths). The graph sizes it as a share of the frame (never a dot),\n// squashes it about its feet, puts a contact shadow under it and a face on it.\nkit.hero = function (ctx, x, feetY, o, body) {\n  o = o || {}; var H = kit.frame().h, size = Math.max(o.far ? 0 : 0.22, o.size || 0.28), R = size * H / 2, sx = o.sx || 1, sy = o.sy || 1;\n  if (o.shadow !== false) kit.contactShadow(ctx, x, feetY, R * 0.9 * sx, 0.2, o.ground, o.shadowColor);\n  ctx.save(); ctx.translate(x, feetY);\n  ctx.scale(sx, sy); ctx.translate(0, -R); if (o.rot) ctx.rotate(o.rot); ctx.scale(R, R);\n  ctx.lineJoin = 'round'; ctx.lineCap = 'round';\n  if (typeof body === 'function') body(ctx, 1 / R);\n  ctx.restore();\n  if (o.face !== false) {\n    var f = o.faceAt || [0, -0.1], fx = x + f[0] * R * sx, fy = feetY - R * sy + f[1] * R * sy;\n    kit.face(ctx, fx, fy, { size: (o.faceSize || 0.5) * size, eyes: o.eyes, look: o.look, blink: o.blink, mouth: o.mouth, blush: o.blush, ink: o.ink });\n  }\n};\nkit.face = function (ctx, cx, cy, o) {\n  o = o || {}; var W = (o.size || 0.12) * kit.frame().h, ink = o.ink || '#3a2c28', eyes = o.eyes == null ? 1 : o.eyes, look = o.look || [0, 0], er = W * 0.2 * eyes;\n  ctx.save(); ctx.translate(cx, cy);\n  if (er > 0.5) {\n    for (var side = -1; side <= 1; side += 2) {\n      ctx.save(); ctx.translate(side * W * 0.27, -W * 0.08); ctx.scale(1, o.blink ? 0.12 : 1);\n      ctx.beginPath(); ctx.arc(0, 0, er, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill(); ctx.lineWidth = Math.max(2, W * 0.03); ctx.strokeStyle = ink; ctx.stroke();\n      ctx.beginPath(); ctx.arc(look[0] * er * 0.4, look[1] * er * 0.4, er * 0.5, 0, Math.PI * 2); ctx.fillStyle = ink; ctx.fill();\n      ctx.beginPath(); ctx.arc(look[0] * er * 0.4 - er * 0.16, look[1] * er * 0.4 - er * 0.18, er * 0.15, 0, Math.PI * 2); ctx.fillStyle = '#fff'; ctx.fill();\n      ctx.restore();\n    }\n  }\n  if (o.blush) { ctx.fillStyle = 'rgba(255,120,120,0.35)'; ctx.beginPath(); ctx.ellipse(-W * 0.42, W * 0.14, W * 0.1, W * 0.06, 0, 0, Math.PI * 2); ctx.ellipse(W * 0.42, W * 0.14, W * 0.1, W * 0.06, 0, 0, Math.PI * 2); ctx.fill(); }\n  ctx.lineWidth = Math.max(2, W * 0.035); ctx.strokeStyle = ink; ctx.lineCap = 'round';\n  if (o.mouth === 'smile') { ctx.beginPath(); ctx.arc(0, W * 0.12, W * 0.16, 0.15 * Math.PI, 0.85 * Math.PI); ctx.stroke(); }\n  else if (o.mouth === 'o') { ctx.beginPath(); ctx.ellipse(0, W * 0.22, W * 0.06, W * 0.08, 0, 0, Math.PI * 2); ctx.fillStyle = ink; ctx.fill(); }\n  else if (o.mouth === 'flat') { ctx.beginPath(); ctx.moveTo(-W * 0.1, W * 0.22); ctx.lineTo(W * 0.1, W * 0.22); ctx.stroke(); }\n  ctx.restore();\n};\n// pp:CameraMove — linked from the design graph\n// THE CAMERA (pp:CameraMove, PIP-8). keys: [{ t, x, y, zoom, at: [sx, sy] }] — at time t the world point (x, y) is shown at\n// screen point at (default: (x, y) itself, a zoom that holds its anchor still) with that zoom. Between keys every value\n// eases in and out, so a move starts and lands without a jolt. A transform only: it draws nothing.\nkit.camera = function (ctx, t, keys) {\n  if (!keys || !keys.length) return;\n  var a = keys[0], b = keys[keys.length - 1], u = 0;\n  for (var i = 0; i < keys.length - 1; i++) { if (t <= keys[i + 1].t) { a = keys[i]; b = keys[i + 1]; u = kit.ease.inOut(kit.seg(t, a.t, b.t)); break; } a = b = keys[i + 1]; u = 0; }\n  var at = function (k) { return k.at || [k.x, k.y]; }, A = at(a), B = at(b);\n  // the zoom eases on its logarithm, so a 2x push grows by the same share every frame\n  var z = Math.exp(kit.lerp(Math.log(a.zoom || 1), Math.log(b.zoom || 1), u));\n  var x = kit.lerp(a.x, b.x, u), y = kit.lerp(a.y, b.y, u), sx = kit.lerp(A[0], B[0], u), sy = kit.lerp(A[1], B[1], u);\n  ctx.translate(sx, sy); ctx.scale(z, z); ctx.translate(-x, -y);\n};\n// pp:WriteOnTitle — linked from the design graph\nkit.writeOn = function (ctx, text, x, y, o) {\n  o = o || {}; ctx.save(); ctx.textBaseline = 'alphabetic';\n  // Script capitals in a row cannot be read (\"MAYA IS TURNING 7\" in Great Vibes, 2026-10-12): set an all-caps line in title case.\n  text = String(text); if (/[A-Z]{2}/.test(text) && !/[a-z]/.test(text)) text = text.toLowerCase().replace(/(^|[\\s\\-\\/&+])([a-z])/g, function (m, a, b) { return a + b.toUpperCase(); });\n  if (o.width) { ctx.font = '100px ' + (o.family || 'cursive'); var px = 100 * o.width * kit.frame().w / Math.max(1, ctx.measureText(text).width); ctx.font = (o.weight ? o.weight + ' ' : '') + px.toFixed(1) + 'px ' + (o.family || 'cursive'); }\n  else ctx.font = o.font || '120px cursive';\n  var w = ctx.measureText(text).width, x0 = x - w / 2, u = kit.clamp(o.u == null ? 1 : o.u, 0, 1), edge = x0 - 10 + u * (w + 30);\n  ctx.save(); ctx.beginPath(); ctx.rect(x0 - 40, y - w - 400, edge - x0 + 40, w + 800); ctx.clip();\n  ctx.fillStyle = o.color || '#3a2c28'; ctx.fillText(text, x0, y); ctx.restore();\n  if (o.nib !== false && u > 0 && u < 1) { ctx.beginPath(); ctx.arc(edge, y - parseFloat(ctx.font.replace(/^[^0-9]*/, '')) * 0.3, 5, 0, Math.PI * 2); ctx.fillStyle = o.nibColor || '#ec5b3c'; ctx.fill(); }\n  ctx.restore(); return w;\n};\n// pp:CelebrationHit — linked from the design graph\nkit.confetti = function (ctx, x, y, age, o) {\n  if (age < 0) return; o = o || {}; var n = o.count || 90, cols = o.colors || ['#ec5b3c', '#ffcd4a', '#5aa9a0', '#b48cff', '#3a2c28'], spread = o.spread || 1, grav = o.gravity == null ? 140 : o.gravity;\n  for (var i = 0; i < n; i++) {\n    var a = kit.hash(i + 10) * Math.PI * 2, sp = (260 + kit.hash(i + 20) * 520) * spread, k = age * Math.exp(-age * 0.9);\n    var px = x + Math.cos(a) * sp * k, py = y + Math.sin(a) * sp * k + grav * age * age, fade = kit.clamp(1.6 - age * 0.55, 0, 1);\n    if (fade <= 0) continue;\n    ctx.save(); ctx.translate(px, py); ctx.rotate(age * (4 + kit.hash(i) * 8) + i); ctx.globalAlpha = fade;\n    ctx.fillStyle = cols[i % cols.length]; ctx.fillRect(-5, -2.5, 10 + kit.hash(i + 3) * 6, 5); ctx.restore();\n  }\n};\nkit.burst = function (ctx, x, y, age, o) {\n  if (age < 0 || age > 0.4) return; o = o || {}; var n = o.rays || 10, len = (o.len || 60) * (1 - age / 0.4), r0 = (o.r || 40) + age * 260;\n  ctx.save(); ctx.globalAlpha = 1 - age / 0.4;\n  for (var i = 0; i < n; i++) { var a = i / n * Math.PI * 2 + 0.3; kit.line(ctx, [[x + Math.cos(a) * r0, y + Math.sin(a) * r0], [x + Math.cos(a) * (r0 + len), y + Math.sin(a) * (r0 + len)]], o.width || 4, o.color || '#3a2c28'); }\n  ctx.restore();\n};\n// ── end of design graph kit ──\n\n// ---- 5 beats, one 80 BPM bar (3 s) each ----\n// 0-3 rise · 3-6 blow · 6-9 roll & wave · 9-12 dive + tail splash · 12-15 title lockup\nconst W = 1920, H = 1080, HZ = 560, WL = 650, INK = '#0a1022';\nlet BOIL = 0;\nconst hs = (n) => { const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; return s - Math.floor(s); };\nconst E = kit.ease, sg = (t, a, b) => kit.seg(t, a, b);\n\nfunction samp(start, segs, n) {\n  const pts = [start.slice()]; let [x, y] = start;\n  for (const s of segs) {\n    for (let i = 1; i <= n; i++) { const u = i / n, v = 1 - u;\n      pts.push([v*v*v*x + 3*v*v*u*s[0] + 3*v*u*u*s[2] + u*u*u*s[4], v*v*v*y + 3*v*v*u*s[1] + 3*v*u*u*s[3] + u*u*u*s[5]]); }\n    x = s[4]; y = s[5];\n  }\n  return pts;\n}\nfunction poly(ctx, pts, amp, seed, close) {\n  ctx.beginPath();\n  pts.forEach((q, i) => { const jx = amp ? (hs(i * 1.7 + seed) - 0.5) * 2 * amp : 0, jy = amp ? (hs(i * 2.3 + seed + 9) - 0.5) * 2 * amp : 0;\n    i ? ctx.lineTo(q[0] + jx, q[1] + jy) : ctx.moveTo(q[0] + jx, q[1] + jy); });\n  if (close) ctx.closePath();\n}\nfunction sketch(ctx, pts, o) {\n  const lw = o.lw || 4, amp = o.amp == null ? 2.5 : o.amp, seed = o.seed || 0, close = o.close !== false;\n  if (o.fill) { ctx.fillStyle = o.fill; poly(ctx, pts, 0, 0, close); ctx.fill(); }\n  ctx.strokeStyle = o.ink || INK; ctx.lineJoin = 'round'; ctx.lineCap = 'round';\n  for (let k = 0; k < 2; k++) { ctx.lineWidth = lw * (k ? 0.45 : 1); poly(ctx, pts, amp * (k ? 1.7 : 1), seed + k * 31 + BOIL * 5.3, close); ctx.stroke(); }\n}\nfunction hatchBox(ctx, b, gap, col, lw, seed) {\n  const [x0, y0, x1, y1] = b, h = y1 - y0; ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.beginPath();\n  for (let x = x0, i = 0; x < x1 + h; x += gap, i++) { const j = (hs(i * 1.3 + seed) - 0.5) * gap * 0.6;\n    ctx.moveTo(x + j + (hs(i + seed + BOIL) - 0.5) * 5, y1); ctx.lineTo(x + j - h + (hs(i * 2 + seed + BOIL) - 0.5) * 5, y0); }\n  ctx.stroke();\n}\nfunction ellPts(cx, cy, rx, ry, n, a0, a1) { const p = []; for (let i = 0; i <= n; i++) { const a = a0 + (a1 - a0) * i / n; p.push([cx + Math.cos(a) * rx, cy + Math.sin(a) * ry]); } return p; }\nfunction sparkle(ctx, x, y, r) { ctx.beginPath(); ctx.moveTo(x, y - r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.quadraticCurveTo(x, y, x, y + r); ctx.quadraticCurveTo(x, y, x - r, y); ctx.quadraticCurveTo(x, y, x, y - r); ctx.fill(); }\n\n// ---- the whale: shapes authored around its own centre, head to the right ----\nconst BODY = samp([600, -20], [[570, -150, 320, -215, 20, -195], [-240, -180, -450, -95, -610, -28], [-625, -18, -625, 8, -610, 16], [-440, 50, -180, 175, 110, 172], [360, 170, 560, 105, 600, -20]], 14);\nconst BELLY = samp([600, -5], [[560, 90, 330, 175, 100, 172], [-150, 168, -330, 120, -460, 60], [-250, 95, 200, 100, 600, -5]], 12);\nconst PLEATS = [0, 1, 2, 3, 4, 5, 6].map((k) => samp([575 - k * 8, 28 + k * 13], [[470, 95 + k * 14, 280, 135 + k * 9, 40 - k * 20, 140 + k * 6]], 10));\nconst FLUKE_SIDE = samp([-605, -10], [[-650, -50, -720, -120, -790, -140], [-770, -90, -740, -40, -705, -6], [-740, 25, -770, 70, -795, 95], [-720, 75, -660, 30, -605, 12]], 8);\nconst DORSAL = samp([-330, -150], [[-300, -195, -250, -212, -215, -160]], 8);\nconst MOUTH = samp([598, -10], [[545, 42, 430, 58, 330, 24], [312, 18, 304, 8, 308, -2]], 10);\nconst FIN = samp([18, -12], [[70, 40, 78, 250, 20, 420], [8, 450, -36, 452, -46, 418], [-38, 300, -50, 120, -36, -4], [-24, -20, 0, -22, 18, -12]], 10);\nconst KNOBS = [[580, -80], [560, -115], [530, -140], [495, -160], [455, -175], [410, -186], [362, -193]];\nconst FL = (() => { const L = samp([0, 30], [[-90, 10, -230, -20, -330, -110], [-260, -120, -170, -95, -120, -110], [-80, -120, -30, -100, 0, -80]], 10);\n  return L.concat(L.slice().reverse().map((q) => [-q[0], q[1]])); })();\n\nconst POSES_whale = { rest: { fin: 0.7, tail: 0 }, wave: { fin: 2.6, tail: 0.1 }, dive: { fin: 0.3, tail: -0.3 } };\nfunction FORM_whale(ctx, q, p) {\n  ctx.save(); ctx.translate(-610, 0); ctx.rotate(q.tail); ctx.translate(610, 0);\n  sketch(ctx, FLUKE_SIDE, { fill: p.body, seed: 3 }); ctx.restore();\n  sketch(ctx, DORSAL, { fill: p.body, seed: 5 });\n  ctx.fillStyle = p.body; poly(ctx, BODY, 0, 0, true); ctx.fill();\n  ctx.save(); poly(ctx, BODY, 0, 0, true); ctx.clip();\n  ctx.fillStyle = p.belly; poly(ctx, BELLY, 1.5, 7 + BOIL, true); ctx.fill();\n  hatchBox(ctx, [-700, -230, 650, -120], 16, 'rgba(10,16,34,0.28)', 1.6, 11);\n  hatchBox(ctx, [-700, 60, 650, 200], 12, 'rgba(10,16,34,0.32)', 1.6, 17);\n  PLEATS.forEach((pl, k) => { ctx.strokeStyle = 'rgba(30,50,90,0.55)'; ctx.lineWidth = 2.2; poly(ctx, pl, 1.5, k * 13 + BOIL, false); ctx.stroke(); });\n  ctx.save(); ctx.translate(-6, 6); ctx.globalAlpha = 0.85; ctx.strokeStyle = p.rim; ctx.lineWidth = 10; poly(ctx, BODY, 1.5, BOIL + 40, true); ctx.stroke(); ctx.restore();\n  ctx.restore();\n  sketch(ctx, BODY, { lw: 5, seed: 1 });\n  sketch(ctx, MOUTH, { lw: 4.5, seed: 2, close: false });\n  KNOBS.forEach((k, i) => { ctx.beginPath(); ctx.arc(k[0] + (hs(i + BOIL) - 0.5) * 2, k[1] + 6, 8, 0, Math.PI * 2); ctx.fillStyle = '#6b8cc0'; ctx.fill(); ctx.lineWidth = 2.5; ctx.strokeStyle = INK; ctx.stroke(); });\n  ctx.beginPath(); ctx.moveTo(285, -197); ctx.quadraticCurveTo(300, -207, 318, -199); ctx.lineWidth = 4; ctx.stroke();\n  // the long white pectoral fin\n  ctx.save(); ctx.translate(240, 95); ctx.rotate(q.fin);\n  sketch(ctx, FIN, { fill: p.belly, seed: 8, lw: 4.5 });\n  ctx.save(); poly(ctx, FIN, 0, 0, true); ctx.clip(); hatchBox(ctx, [-60, 150, 80, 460], 11, 'rgba(10,16,34,0.3)', 1.4, 23); ctx.restore();\n  for (let k = 0; k < 5; k++) { const f = FIN[2 + k * 2]; ctx.beginPath(); ctx.arc(f[0] + 3, f[1], 6, 0, Math.PI * 2); ctx.fillStyle = p.belly; ctx.fill(); ctx.lineWidth = 2.5; ctx.strokeStyle = INK; ctx.stroke(); }\n  ctx.restore();\n}\n\nconst WHALE = { x: 960, y: 590, size: 1.1, body: '#4a6fa5', belly: '#dbe7f5', rim: '#fbbf24' };\nfunction whaleState(t, p) {\n  const a = E.inOut(sg(t, 9, 9.4)), d = E.in(sg(t, 9.4, 10.9));\n  let y = p.y + 420 + 30 * E.inOut(sg(t, 0.5, 1.0)) - 450 * E.out(sg(t, 1.0, 3.4)) + 10 * Math.sin(t * 1.7) * sg(t, 3, 4) * (1 - d);\n  y += -25 * a * (1 - d) + 650 * d;\n  let rot = -0.2 * (1 - E.inOut(sg(t, 2.4, 3.8))) - 0.06 * E.inOut(sg(t, 6, 6.6)) * (1 - E.inOut(sg(t, 8.4, 9))) - 0.08 * a * (1 - d) + 0.62 * d;\n  const s = kit.squash(t, 3.4, { amount: 0.12, dur: 0.8 });\n  const up = E.inOut(sg(t, 6, 6.6)) * (1 - E.inOut(sg(t, 8.4, 9)));\n  const wave = 0.38 * Math.sin((t - 6.6) * Math.PI * 2.2) * sg(t, 6.5, 6.8) * (1 - sg(t, 8.2, 8.5));\n  const blink = Math.max(...[1.9, 5.6, 8.9].map((b) => Math.max(0, 1 - Math.abs(t - b) / 0.09)));\n  return { x: p.x + 200 * d, y, rot, sx: s.sx, sy: s.sy,\n    fin: POSES_whale.rest.fin + 1.9 * up + wave + 0.12 * Math.sin(t * 2) * (1 - up) - 0.4 * d,\n    tail: 0.15 * Math.sin(t * 1.4) - 0.3 * d, blink,\n    happy: (t > 3.3 && t < 4.3) || (t > 7.0 && t < 7.8),\n    look: t < 3 ? [0.2, 0.35] : (t > 6 && t < 9 ? [-0.6, -0.5] : [0.5, 0.1]) };\n}\nfunction toWorld(st, lx, ly, s) { const vx = lx * s * st.sx, vy = ly * s * st.sy, c = Math.cos(st.rot), n = Math.sin(st.rot); return [st.x + vx * c - vy * n, st.y + vx * n + vy * c]; }\n\nconst FISH = samp([40, 0], [[22, -22, -18, -20, -30, 0], [-18, 20, 22, 22, 40, 0]], 8);\nconst FTAIL = [[-26, 0], [-54, -20], [-46, 0], [-54, 20]];\nconst LEAPS = [[1.6, 300, 1, 160, 690], [2.4, 1650, -1, 140, 700], [4.6, 230, 1, 200, 720], [5.4, 1780, -1, 150, 690], [7.2, 380, 1, 170, 740],\n  [8.0, 1620, -1, 190, 710], [9.6, 300, 1, 150, 700], [11.9, 1300, 1, 180, 720], [12.6, 520, -1, 160, 700], [13.4, 1500, 1, 200, 740], [14.1, 900, -1, 140, 690]];\nconst NOTES = [\n  { text: 'pfoosh!', font: '700 110px \"Caveat\", \"Comic Sans MS\", cursive', from: 3.5, to: 5.8, x: 1580, y: 250, col: '#dc2626', tgt: 'blow' },\n  { text: 'hello!', font: '700 100px \"Caveat\", \"Comic Sans MS\", cursive', from: 6.7, to: 9.0, x: 470, y: 220, col: '#16a34a', tgt: 'fin' },\n  { text: 'SPLASH!', font: '800 110px \"Caveat\", \"Comic Sans MS\", cursive', from: 11.5, to: 12.9, x: 1560, y: 300, col: '#2563eb', tgt: 'tail' }];\nconst FLUKES = { x: 720, size: 1.1 };\nfunction flukePose(t) { return { hh: -150 + 450 * E.out(sg(t, 10.0, 10.8)) - 200 * E.in(sg(t, 11.05, 11.45)), r: 1.0 * E.in(sg(t, 11.05, 11.45)) + 0.05 * Math.sin(t * 3) * sg(t, 10.6, 10.9) }; }\n\nconst entities = [\n  { id: 'camera', kind: 'camera', from: 0, to: 15,\n    params: { keys: [{ t: 0, x: 960, y: 540, zoom: 1 }, { t: 3.4, x: 1000, y: 520, zoom: 1.06 }, { t: 6, x: 1050, y: 500, zoom: 1.1 }, { t: 9, x: 1050, y: 520, zoom: 1.08 }, { t: 11.6, x: 900, y: 560, zoom: 1.0 }, { t: 15, x: 960, y: 540, zoom: 1 }] },\n    draw(ctx, t, p) { kit.camera(ctx, t, p.keys); } },\n\n  { id: 'sky', kind: 'background', from: 0, to: 15,\n    params: { top: '#172554', top2: '#0b1026', mid: '#3b4f9a', mid2: '#2a2f6e', glow: '#fbbf24', glow2: '#f97316' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8); const u = sg(t, 0, 15);\n      const g = ctx.createLinearGradient(0, 0, 0, HZ);\n      g.addColorStop(0, kit.lerp(p.top, p.top2, u)); g.addColorStop(0.6, kit.lerp(p.mid, p.mid2, u)); g.addColorStop(1, kit.lerp(p.glow, p.glow2, u));\n      ctx.fillStyle = g; ctx.fillRect(-60, -60, W + 120, HZ + 70);\n      hatchBox(ctx, [0, 0, W, 280], 22, 'rgba(255,255,255,0.05)', 1.4, 3);\n      ctx.strokeStyle = 'rgba(253,230,138,0.35)'; ctx.lineWidth = 2.5;\n      for (let i = 0; i < 6; i++) { const y = HZ - 40 - i * 26, x0 = 200 + hs(i) * 600; poly(ctx, [[x0, y], [x0 + 300, y - 4], [x0 + 620 + hs(i + 3) * 300, y + 2]], 3, i + BOIL, false); ctx.stroke(); } } },\n\n  { id: 'stars', kind: 'background', from: 0, to: 15, params: { count: 60, color: '#fef3c7' },\n    draw(ctx, t, p) { ctx.strokeStyle = p.color; ctx.lineWidth = 2;\n      for (let i = 0; i < p.count; i++) { const x = hs(i * 3.1) * W, y = hs(i * 5.7) * 340, a = sg(t, 3 + hs(i) * 6, 6 + hs(i) * 8) * (0.5 + 0.5 * Math.sin(t * 3 + i)), r = 3 + hs(i * 9) * 4;\n        if (a <= 0.02) continue; ctx.globalAlpha = a; ctx.beginPath(); ctx.moveTo(x - r, y); ctx.lineTo(x + r, y); ctx.moveTo(x, y - r); ctx.lineTo(x, y + r); ctx.stroke(); }\n      ctx.globalAlpha = 1; } },\n\n  { id: 'sun', kind: 'background', from: 0, to: 15, params: { x: 780, y: 400, r: 95 },\n    draw(ctx, t, p) { const y = p.y + 190 * E.inOut(sg(t, 0, 15));\n      ctx.save(); ctx.beginPath(); ctx.rect(-60, -60, W + 120, HZ + 60); ctx.clip();\n      const g = ctx.createRadialGradient(p.x, y, 10, p.x, y, 420); g.addColorStop(0, 'rgba(253,230,138,0.75)'); g.addColorStop(1, 'rgba(253,186,116,0)');\n      ctx.fillStyle = g; ctx.fillRect(p.x - 440, y - 440, 880, 880);\n      kit.sun(ctx, p.x, y, p.r, t); ctx.restore(); },\n    bounds(t, p) { return [p.x - p.r, p.y - p.r, p.r * 2, p.r * 2]; } },\n\n  { id: 'clouds', kind: 'background', from: 0, to: 15, params: { fill: '#e9a28b' },\n    draw(ctx, t, p) { [[260, 150, 1.3], [1450, 110, 1.0], [1150, 260, 0.8]].forEach((c, i) => kit.cloud(ctx, c[0] + t * (6 + i * 4), c[1], c[2], { fill: p.fill, opacity: 0.8 })); } },\n\n  { id: 'seaBack', kind: 'background', from: 0, to: 15, params: { top: '#1d3f8f', deep: '#0b1a4a', glint: '#fde68a' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      const g = ctx.createLinearGradient(0, HZ, 0, H); g.addColorStop(0, p.top); g.addColorStop(1, p.deep);\n      ctx.fillStyle = g; ctx.fillRect(-60, HZ, W + 120, H - HZ + 60);\n      const sx = 780; ctx.strokeStyle = p.glint; ctx.lineCap = 'round';\n      for (let k = 0; k < 18; k++) { const y = HZ + 8 + k * k * 1.5, w = 150 - k * 5 + Math.sin(t * 2 + k) * 20, o = Math.sin(t * 1.5 + k * 1.7) * 30;\n        ctx.globalAlpha = 0.85 - k * 0.035; ctx.lineWidth = 3 + k * 0.2; poly(ctx, [[sx + o - w / 2, y], [sx + o + w / 2, y]], 2, k + BOIL, false); ctx.stroke(); }\n      ctx.globalAlpha = 1; ctx.strokeStyle = 'rgba(191,219,254,0.35)'; ctx.lineWidth = 2;\n      for (let j = 0; j < 9; j++) { const y = HZ + 18 + j * j * 6;\n        for (let i = 0; i < 12; i++) { const x = ((i * 180 + j * 67 + t * 20 * (1 + j * 0.2)) % (W + 200)) - 100, s = 14 + j * 3;\n          ctx.beginPath(); ctx.moveTo(x - s, y); ctx.quadraticCurveTo(x, y + s * 0.5, x + s, y); ctx.stroke(); } }\n      ctx.strokeStyle = INK; ctx.lineWidth = 3; poly(ctx, [[-60, HZ], [W / 2, HZ + 1], [W + 60, HZ]], 2, BOIL, false); ctx.stroke(); } },\n\n  { id: 'whale', kind: 'character', from: 0, to: 11, params: WHALE,\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8); const st = whaleState(t, p);\n      const feet = st.y + 180 * p.size * st.sy;\n      kit.hero(ctx, st.x, feet, { size: 0.4, sx: st.sx, sy: st.sy, rot: st.rot,\n        eyes: E.back(sg(t, 0.8, 1.3)), look: st.look, blink: st.blink,\n        mouth: st.happy ? 'smile' : 'flat', blush: true, faceAt: [1.65, -0.04], ground: WL },\n        (ctx, px) => { ctx.translate(0, 0.1); ctx.scale(0.005, 0.005); FORM_whale(ctx, st, p); }); },\n    bounds(t, p) { const st = whaleState(t, p); return [st.x - 880 * p.size, st.y - 240 * p.size, 1500 * p.size, 480 * p.size]; } },\n\n  { id: 'flukes', kind: 'character', from: 10, to: 11.55, params: FLUKES,\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8); const q = flukePose(t);\n      ctx.translate(p.x, WL + 60); ctx.rotate(q.r); ctx.translate(0, -q.hh); ctx.scale(p.size, p.size);\n      sketch(ctx, [[-34, 0], [-45, 150], [-55, 300], [-62, 460], [62, 460], [55, 300], [45, 150], [34, 0]], { fill: WHALE.body, seed: 4 });\n      ctx.fillStyle = WHALE.belly; poly(ctx, FL, 0, 0, true); ctx.fill();\n      ctx.save(); poly(ctx, FL, 0, 0, true); ctx.clip(); ctx.fillStyle = 'rgba(10,16,34,0.75)';\n      [[-200, -60, 40, 18], [-120, -20, 26, 14], [150, -50, 46, 20], [230, -85, 30, 12], [60, -10, 18, 10]].forEach((b) => { ctx.beginPath(); ctx.ellipse(b[0], b[1], b[2], b[3], 0.3, 0, Math.PI * 2); ctx.fill(); });\n      hatchBox(ctx, [-340, -130, 340, 40], 12, 'rgba(10,16,34,0.25)', 1.4, 29); ctx.restore();\n      sketch(ctx, FL, { lw: 5, seed: 6 });\n      if (t < 11.05) { ctx.fillStyle = '#e0f2fe';\n        for (let k = 0; k < 16; k++) { const ph = (t * 1.3 + hs(k * 3.1)) % 1; ctx.beginPath(); ctx.arc(-280 + k * 37, -70 + ph * 520, 4, 0, Math.PI * 2); ctx.fill(); } } },\n    bounds(t, p) { return [p.x - 360, WL - 380, 720, 420]; } },\n\n  { id: 'seaFront', kind: 'background', from: 0, to: 15, params: { top: 'rgba(16,38,100,0.78)', deep: 'rgba(8,16,48,0.93)', foam: '#e0f2fe' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      const g = ctx.createLinearGradient(0, WL, 0, H); g.addColorStop(0, p.top); g.addColorStop(1, p.deep);\n      ctx.fillStyle = g; ctx.fillRect(-60, WL, W + 120, H - WL + 60);\n      ctx.strokeStyle = 'rgba(253,230,138,0.7)'; ctx.lineWidth = 3;\n      for (let k = 0; k < 10; k++) { const y = WL + 30 + k * k * 4, o = Math.sin(t * 1.4 + k * 2) * 40, w = 120 - k * 6; poly(ctx, [[780 + o - w / 2, y], [780 + o + w / 2, y]], 2, k * 7 + BOIL, false); ctx.stroke(); }\n      ctx.strokeStyle = p.foam; ctx.lineCap = 'round';\n      const foam = (x0, x1, a) => { ctx.globalAlpha = a; ctx.lineWidth = 4;\n        for (let x = x0, i = 0; x < x1; x += 34, i++) { const y = WL + Math.sin(t * 3 + i) * 4; poly(ctx, [[x, y], [x + 14 + hs(i) * 12, y - 2]], 2, i + BOIL, false); ctx.stroke(); }\n        ctx.globalAlpha = 1; };\n      if (t > 1.3 && t < 10.7) { const st = whaleState(t, WHALE); foam(st.x - 800, st.x + 680, 0.85 * sg(t, 1.3, 2.2)); }\n      if (t > 10.1 && t < 11.6) foam(FLUKES.x - 120, FLUKES.x + 120, 0.9);\n      ctx.strokeStyle = 'rgba(191,219,254,0.55)'; ctx.lineWidth = 3; poly(ctx, [[-60, WL], [W / 3, WL + 2], [W * 2 / 3, WL - 2], [W + 60, WL]], 3, BOIL + 2, false); ctx.stroke(); } },\n\n  { id: 'spray', kind: 'effect', from: 3.2, to: 6.4, params: { n: 120, color: '#e0f2fe', glint: '#fde68a' },\n    draw(ctx, t, p) {\n      for (let i = 0; i < p.n; i++) { const tb = 3.3 + 0.85 * hs(i * 1.13 + 0.5), age = t - tb, life = 1.6 + 0.9 * hs(i * 4.1);\n        if (age < 0 || age > life) continue;\n        const o = toWorld(whaleState(tb, WHALE), 300, -205, WHALE.size), vy = 560 + 220 * hs(i * 2.71), vx = (hs(i * 3.9) - 0.5) * 180 + 50;\n        const x = o[0] + vx * age, y = o[1] - vy * age + 410 * age * age;\n        ctx.globalAlpha = 1 - age / life;\n        if (hs(i * 7) > 0.65) { ctx.fillStyle = p.glint; sparkle(ctx, x, y, 6 + 6 * (0.5 + 0.5 * Math.sin(t * 14 + i))); }\n        else { ctx.fillStyle = p.color; ctx.beginPath(); ctx.arc(x, y, 3 + 5 * hs(i * 5.1), 0, Math.PI * 2); ctx.fill(); } }\n      const m = sg(t, 3.5, 5.6), o = toWorld(whaleState(3.4, WHALE), 300, -205, WHALE.size);\n      if (m > 0 && m < 1) { ctx.fillStyle = 'rgba(224,242,254,0.14)';\n        for (let k = 0; k < 6; k++) { ctx.globalAlpha = (1 - m); ctx.beginPath(); ctx.arc(o[0] + 40 + k * 30 + m * 80, o[1] - 240 - hs(k) * 80, 50 + m * 70 + k * 6, 0, Math.PI * 2); ctx.fill(); } }\n      ctx.globalAlpha = 1; } },\n\n  { id: 'splash', kind: 'effect', from: 10, to: 15, params: { color: '#e0f2fe' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      const EV = [{ t: 10.25, x: toWorld(whaleState(10.25, WHALE), 450, -100, WHALE.size)[0], n: 60, v: 650, s: 0.7 }, { t: 11.45, x: FLUKES.x, n: 130, v: 900, s: 1.2 }];\n      EV.forEach((ev) => { const age = t - ev.t; if (age < 0) return;\n        for (let k = 0; k < 3; k++) { const a = age - k * 0.3; if (a < 0 || a > 3) continue; ctx.globalAlpha = 0.7 * (1 - a / 3); ctx.strokeStyle = p.color; ctx.lineWidth = 3;\n          poly(ctx, ellPts(ev.x, WL + 6, 80 + a * 300 * ev.s, (80 + a * 300 * ev.s) * 0.12, 30, 0, Math.PI * 2), 3, k + BOIL, true); ctx.stroke(); }\n        const dome = Math.sin(Math.PI * Math.min(1, age / 1.0)) * (age < 1 ? 1 : 0);\n        if (dome > 0) { ctx.globalAlpha = 0.85; sketch(ctx, ellPts(ev.x, WL, 170 * ev.s, -230 * ev.s * dome, 16, 0, Math.PI), { fill: p.color, lw: 3, seed: 9 }); }\n        for (let i = 0; i < ev.n; i++) { const vy = ev.v * (0.5 + 0.5 * hs(i * 2.3 + ev.s)), vx = (hs(i * 1.7 + ev.s) - 0.5) * 500 * ev.s;\n          const x = ev.x + (hs(i * 5.3) - 0.5) * 120 * ev.s + vx * age, y = WL - vy * age + 600 * age * age;\n          if (y > WL + 5) continue; ctx.globalAlpha = 1; ctx.fillStyle = hs(i * 8) > 0.75 ? '#fde68a' : p.color;\n          ctx.beginPath(); ctx.arc(x, y, (3 + 6 * hs(i * 4.4)) * ev.s, 0, Math.PI * 2); ctx.fill(); }\n        if (ev.s > 1) kit.burst(ctx, ev.x, WL - 80, age, { rays: 12 }); });\n      ctx.globalAlpha = 1; } },\n\n  { id: 'fish', kind: 'character', from: 1.5, to: 15.3, params: { color: '#f59e0b', size: 1.4 },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      LEAPS.forEach((L, j) => { const [st, x0, dir, h, y0] = L, dur = 1.2, u = (t - st) / dur; if (u < -0.1 || u > 1.2) return;\n        if (u >= 0 && u <= 1) { const x = x0 + dir * 280 * u, y = y0 - 4 * h * u * (1 - u), ang = Math.atan2(-4 * h * (1 - 2 * u), dir * 280);\n          ctx.save(); ctx.translate(x, y); ctx.rotate(ang); ctx.scale(p.size, p.size * dir);\n          sketch(ctx, FTAIL, { fill: p.color, lw: 2.5, amp: 1, seed: j });\n          sketch(ctx, FISH, { fill: p.color, lw: 2.5, amp: 1, seed: j + 5 });\n          ctx.strokeStyle = 'rgba(10,16,34,0.5)'; ctx.lineWidth = 1.5; ctx.beginPath(); ctx.moveTo(5, -16); ctx.lineTo(0, 16); ctx.stroke();\n          ctx.fillStyle = '#fff'; ctx.beginPath(); ctx.arc(24, -4, 5, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = INK; ctx.beginPath(); ctx.arc(25, -4, 2.5, 0, Math.PI * 2); ctx.fill(); ctx.restore(); }\n        [[0, x0], [1, x0 + dir * 280]].forEach(([at, sx]) => { const a = (u - at) * dur; if (a < 0 || a > 0.4) return;\n          ctx.fillStyle = '#e0f2fe'; ctx.globalAlpha = 1 - a / 0.4;\n          for (let k = 0; k < 6; k++) { ctx.beginPath(); ctx.arc(sx + (k - 2.5) * 10 + (k - 2.5) * a * 60, y0 - 120 * a * (1 + hs(k + j)) + 300 * a * a, 4, 0, Math.PI * 2); ctx.fill(); }\n          ctx.globalAlpha = 1; }); }); } },\n\n  { id: 'notes', kind: 'annotation', from: 3.5, to: 12.9, params: { fill: '#fef3c7' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      NOTES.forEach((n, i) => { if (t < n.from || t > n.to) return;\n        const pop = E.back(sg(t, n.from, n.from + 0.3)), fade = 1 - sg(t, n.to - 0.3, n.to);\n        let tg; if (n.tgt === 'blow') tg = toWorld(whaleState(t, WHALE), 300, -240, WHALE.size);\n        else if (n.tgt === 'fin') { const st = whaleState(t, WHALE); tg = toWorld(st, 240 - 420 * Math.sin(st.fin), 95 + 420 * Math.cos(st.fin), WHALE.size); }\n        else tg = [FLUKES.x + 40, WL - 140];\n        ctx.globalAlpha = fade;\n        const s = [n.x + (tg[0] > n.x ? 150 : -150), n.y + 50], e = [kit.lerp(s[0], tg[0], 0.85), kit.lerp(s[1], tg[1], 0.85)];\n        const pts = samp(s, [[s[0] + (e[0] - s[0]) * 0.3, s[1] + 120, e[0] - (e[0] - s[0]) * 0.2, e[1] - 60, e[0], e[1]]], 20);\n        const k = Math.max(2, Math.round(pts.length * E.out(sg(t, n.from + 0.2, n.from + 0.7))));\n        ctx.strokeStyle = n.col; ctx.lineWidth = 6; ctx.lineCap = 'round'; poly(ctx, pts.slice(0, k), 2, i + BOIL, false); ctx.stroke();\n        if (k >= pts.length) { const a = pts[k - 1], b = pts[k - 3], an = Math.atan2(a[1] - b[1], a[0] - b[0]);\n          ctx.beginPath(); ctx.moveTo(a[0] - 28 * Math.cos(an - 0.5), a[1] - 28 * Math.sin(an - 0.5)); ctx.lineTo(a[0], a[1]); ctx.lineTo(a[0] - 28 * Math.cos(an + 0.5), a[1] - 28 * Math.sin(an + 0.5)); ctx.stroke(); }\n        ctx.save(); ctx.translate(n.x, n.y); ctx.rotate(-0.06 + 0.02 * Math.sin(t * 3 + i)); ctx.scale(pop, pop);\n        ctx.font = n.font; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';\n        ctx.strokeStyle = n.col; ctx.lineWidth = 14; ctx.strokeText(n.text, 0, 0); ctx.fillStyle = p.fill; ctx.fillText(n.text, 0, 0); ctx.restore();\n        ctx.globalAlpha = 1; }); } },\n\n  { id: 'title', kind: 'title', from: 12, to: 15, params: { text: 'Big Blue', color: '#fef3c7', underline: '#dc2626' },\n    draw(ctx, t, p) { BOIL = Math.floor(t * 8);\n      const w = kit.writeOn(ctx, p.text, W / 2, H * 0.3, { width: 0.42, family: '\"Caveat\", \"Segoe Script\", cursive', color: p.color, u: E.inOut(sg(t, 12.0, 13.6)) });\n      const u = E.out(sg(t, 13.6, 14.1)), x0 = W / 2 - w / 2;\n      if (u > 0) { ctx.strokeStyle = p.underline; ctx.lineWidth = 10; ctx.lineCap = 'round';\n        poly(ctx, samp([x0, H * 0.3 + 50], [[x0 + w * 0.3, H * 0.3 + 70, x0 + w * 0.7, H * 0.3 + 40, x0 + w * u, H * 0.3 + 58]], 16), 2, BOIL, false); ctx.stroke(); }\n      kit.confetti(ctx, W / 2, H * 0.24, t - 14.0, { count: 60, colors: ['#fde68a', '#fef3c7', '#60a5fa'] }); } },\n\n  { id: 'grain', kind: 'overlay', from: 0, to: 15, params: {},\n    draw(ctx) { kit.paper(ctx, W, H, { overlay: true }); } },\n];\n\nfunction draw(ctx, t, params, rng) {\n  const cam = entities.find((e) => e.kind === 'camera' && t >= e.from && t <= e.to);\n  for (const e of entities) {\n    if (t < e.from || t > e.to) continue;\n    if (e.kind === 'camera') continue;\n    ctx.save();\n    if (cam && !/^(overlay|title|caption|type)$/.test(e.kind)) cam.draw(ctx, t, cam.params, rng.for(cam.id));\n    e.draw(ctx, t, e.params, rng.for(e.id));\n    ctx.restore();\n  }\n}\n\n// lo-fi, 80 BPM (bar = 3 s), seventh chords in C minor: quiet bar 1, fuller middle, sparse title bar\nfunction compose(params) {\n  const BAR = 3, out = [];\n  const CH = [[48, 51, 55, 58], [44, 48, 51, 55], [41, 44, 48, 51], [43, 47, 50, 53], [48, 51, 55, 58, 62]];\n  const ROOT = [36, 32, 29, 31, 36];\n  for (let b = 0; b < 5; b++) { const t0 = b * BAR, full = b >= 1 && b <= 3;\n    CH[b].forEach((m) => out.push({ inst: 'organ', t: t0, midi: m, dur: 2.9, vel: b === 0 ? 0.16 : 0.22 }));\n    out.push({ inst: 'bass', t: t0 + 0.06, midi: ROOT[b], dur: 1.3, vel: 0.5 });\n    if (full) out.push({ inst: 'bass', t: t0 + 2.31, midi: ROOT[b], dur: 0.6, vel: 0.38 });\n    for (let k = 0; k < 8; k++) out.push({ inst: 'hat', t: t0 + k * 0.375 + (k % 2 ? 0.05 : 0), dur: 0.1, vel: (full ? 0.26 : 0.16) * (k % 2 ? 0.8 : 1) });\n    if (full) { out.push({ inst: 'kick', t: t0, dur: 0.3, vel: 0.6 }); out.push({ inst: 'kick', t: t0 + 1.125, dur: 0.3, vel: 0.45 }); out.push({ inst: 'snare', t: t0 + 1.5, dur: 0.25, vel: 0.4 }); }\n    else out.push({ inst: 'kick', t: t0, dur: 0.3, vel: 0.35 });\n  }\n  out.push({ inst: 'sine', t: 0.9, midi: 48, dur: 2.4, vel: 0.22 });\n  [[1.6, 72], [2.4, 75], [4.6, 79], [5.4, 77], [6.7, 79], [7.25, 82], [7.8, 79], [8.35, 77], [9.6, 75], [12.0, 72], [12.4, 75], [12.8, 79], [13.2, 82], [13.6, 86], [14.0, 84]]\n    .forEach(([t, m]) => out.push({ inst: 'sine', t, midi: m, dur: t >= 14 ? 1.0 : 0.5, vel: 0.35 }));\n  out.push({ inst: 'whoosh', t: 3.3, dur: 0.8, vel: 0.35 }, { inst: 'whoosh', t: 9.35, dur: 0.8, vel: 0.35 },\n    { inst: 'noise', t: 10.25, dur: 0.6, vel: 0.3 }, { inst: 'noise', t: 11.45, dur: 1.0, vel: 0.5 },\n    { inst: 'kick', t: 11.45, dur: 0.4, vel: 0.8 }, { inst: 'tom', t: 11.45, dur: 0.4, vel: 0.5 }, { inst: 'kick', t: 14.0, dur: 0.4, vel: 0.5 });\n  return out;\n}\n","exportProfile":{"format":"mp4","resolution":"720p","fps":30,"durationSeconds":15},"source":{"prompt":"A 15 second animated film on a deep blue twilight sea: a huge friendly humpback whale, filling most of the frame, rises slowly out of the water, blows a tall fountain of glittering spray, rolls on its side and waves one long fin, then dives with a big splash of its tail as the sun sets gold behind it and small fish leap in arcs around it. It ends with the handwritten title 'Big Blue' writing itself on across the sky. Music scored to each beat.","provider":"anthropic","model":"claude-opus-5-5","app":"gallery-reproduction"}},"license":"CC0-1.0"}