{"id":"ember-the-fox","title":"Ember","category":"Drawn film","prompt":"A 15 second animated film: a small orange fox with a fluffy tail tiptoes through an autumn forest with layered trees and falling leaves, pounces into a pile of leaves that bursts everywhere, pops out with a leaf on its head, then runs up a hill as the sun sets and sits watching fireflies come out. It ends with the handwritten title \"Ember\" writing itself on.","designedBy":{"provider":"google","model":"gemini-3.1-pro-preview","inference":{"inputTokens":18440,"outputTokens":7027,"costUsd":0.1454}},"render":{"credits":8,"priceUsd":0.12,"format":"mp4","renderMs":21757,"frameCount":360,"bytes":2620192,"seed":2636714856,"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=ember-the-fox), and it exports as MP4 or GIF, not as a still."},"renderRequest":{"drawContract":"const meta = { duration: 15, fps: 24, size: [1280, 720], seed: 88 };\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 }; };\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: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: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: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  ctx.save(); ctx.translate(x, feetY);\n  if (o.shadow !== false) { ctx.save(); ctx.scale(sx, 0.2); ctx.beginPath(); ctx.arc(0, 0, R * 0.9, 0, Math.PI * 2); ctx.fillStyle = 'rgba(60,30,20,0.18)'; ctx.fill(); ctx.restore(); }\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:WriteOnTitle — linked from the design graph\nkit.writeOn = function (ctx, text, x, y, o) {\n  o = o || {}; ctx.save(); ctx.textBaseline = 'alphabetic';\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// ── end of design graph kit ──\n\nconst WORLD = {\n    scrollX: (t) => {\n        let x = 0;\n        if (t > 0) x += Math.min(t, 3) * 80; // Sneak forward\n        if (t > 6) { \n            // Run sequence\n            let runPos = kit.ease.inOut(kit.seg(t, 6, 8));\n            x += runPos * 900;\n        }\n        return x;\n    },\n    groundY: (worldX) => {\n        let y = 600;\n        // The hill starts at worldX 800 and peaks around 1300\n        if (worldX > 800) {\n            let u = kit.clamp((worldX - 800) / 500, 0, 1);\n            y -= kit.ease.inOut(u) * 160; \n        }\n        return y;\n    },\n    timeOfDay: (t) => kit.ease.inOut(kit.seg(t, 6.5, 9.5)) // 0 = day, 1 = night\n};\n\nconst entities = [\n    { id: 'sky', kind: 'background', from: 0, to: 15, params: {},\n      draw(ctx, t, p) {\n          const u = WORLD.timeOfDay(t);\n          const topDay = '#78aed6', botDay = '#e6cca3';\n          const topSet = '#734164', botSet = '#e8885d';\n          const topNight = '#0f1326', botNight = '#1f2338';\n\n          let top, bot;\n          if (u < 0.5) {\n              let uu = u * 2;\n              top = kit.lerp(topDay, topSet, uu);\n              bot = kit.lerp(botDay, botSet, uu);\n          } else {\n              let uu = (u - 0.5) * 2;\n              top = kit.lerp(topSet, topNight, uu);\n              bot = kit.lerp(botSet, botNight, uu);\n          }\n          kit.sky(ctx, 1280, 720, [top, bot]);\n      } \n    },\n    { id: 'sun_and_moon', kind: 'background', from: 0, to: 15, params: {},\n      draw(ctx, t, p) {\n          let u = WORLD.timeOfDay(t);\n          // Sun arcs down and sets\n          let sx = 1000 - u * 200;\n          let sy = 300 + u * 450; \n          if (sy < 750) kit.sun(ctx, sx, sy, 80, t);\n\n          // Moon fades in on the left\n          let moonU = kit.seg(t, 8, 11);\n          if (moonU > 0) {\n              ctx.globalAlpha = moonU;\n              kit.moon(ctx, 250, 250 - moonU * 40, 60);\n              ctx.globalAlpha = 1;\n          }\n      }\n    },\n    { id: 'stars', kind: 'background', from: 6, to: 15, params: {},\n      draw(ctx, t, p) {\n          let u = kit.seg(t, 8, 10);\n          if (u > 0) {\n              ctx.globalAlpha = u;\n              kit.stars(ctx, 1280, 720, t, { count: 120, y: 0 });\n              ctx.globalAlpha = 1;\n          }\n      }\n    },\n    { id: 'trees_bg', kind: 'background', from: 0, to: 15, params: {},\n      draw(ctx, t, p, rng) {\n          let uNight = WORLD.timeOfDay(t);\n          let scroll = WORLD.scrollX(t) * 0.35; \n          for(let i=0; i<25; i++) {\n              let wx = i * 220;\n              let sx = wx - scroll;\n              if (sx < -200 || sx > 1480) continue;\n              \n              let trunk = kit.lerp('#826d60', '#151726', uNight);\n              let leaf1 = kit.lerp('#cf984a', '#1c1f30', uNight);\n              let leaf2 = kit.lerp('#b86533', '#161929', uNight);\n              \n              // Ground tree to the foreground's actual elevation at this screen X\n              let sy = WORLD.groundY(sx + WORLD.scrollX(t)) - 10;\n\n              ctx.fillStyle = trunk;\n              ctx.fillRect(sx - 8, sy - 250, 16, 250);\n              ctx.fillStyle = leaf1;\n              ctx.beginPath(); ctx.arc(sx, sy - 220, 90, 0, 7); ctx.fill();\n              ctx.fillStyle = leaf2;\n              ctx.beginPath(); ctx.arc(sx-40, sy - 270, 70, 0, 7); ctx.fill();\n              ctx.beginPath(); ctx.arc(sx+40, sy - 260, 80, 0, 7); ctx.fill();\n          }\n      }\n    },\n    { id: 'trees_mg', kind: 'midground', from: 0, to: 15, params: {},\n      draw(ctx, t, p, rng) {\n          let uNight = WORLD.timeOfDay(t);\n          let scroll = WORLD.scrollX(t) * 0.65; \n          for(let i=0; i<20; i++) {\n              let wx = i * 350 + 100;\n              let sx = wx - scroll;\n              if (sx < -300 || sx > 1580) continue;\n\n              let trunk = kit.lerp('#5a4642', '#0d101c', uNight);\n              let leaf1 = kit.lerp('#c94d2c', '#151829', uNight);\n              let leaf2 = kit.lerp('#e3802b', '#111424', uNight);\n              let sy = WORLD.groundY(sx + WORLD.scrollX(t)) + 15; \n\n              ctx.fillStyle = trunk;\n              ctx.fillRect(sx - 14, sy - 380, 28, 380);\n              ctx.fillStyle = leaf1;\n              ctx.beginPath(); ctx.arc(sx, sy - 340, 130, 0, 7); ctx.fill();\n              ctx.fillStyle = leaf2;\n              ctx.beginPath(); ctx.arc(sx-60, sy - 400, 100, 0, 7); ctx.fill();\n              ctx.beginPath(); ctx.arc(sx+70, sy - 380, 110, 0, 7); ctx.fill();\n          }\n      }\n    },\n    { id: 'falling_leaves', kind: 'effect', from: 0, to: 8, params: {},\n      draw(ctx, t, p, rng) {\n          let alpha = 1 - kit.seg(t, 6, 8); // fade out as he runs up hill\n          if (alpha <= 0) return;\n          ctx.globalAlpha = alpha;\n          for(let i=0; i<45; i++) {\n              let wx = (rng.float(0, 3000) + t * 40) % 3000;\n              let sx = wx - WORLD.scrollX(t);\n              if (sx < -50 || sx > 1330) continue;\n              let startY = -100 + rng.float(0, 800);\n              let drop = t * rng.float(50, 90);\n              let sy = (startY + drop) % 800;\n              let drift = Math.sin(t * 2 + i) * 35;\n              \n              ctx.fillStyle = rng.chance(0.6) ? '#d97634' : '#e0a938';\n              ctx.save();\n              ctx.translate(sx + drift, sy);\n              ctx.rotate(t * rng.float(1, 3));\n              ctx.beginPath(); ctx.ellipse(0, 0, 9, 4, 0, 0, 7); ctx.fill();\n              ctx.restore();\n          }\n          ctx.globalAlpha = 1;\n      }\n    },\n    { id: 'foreground', kind: 'background', from: 0, to: 15, params: {},\n      draw(ctx, t, p) {\n          const scroll = WORLD.scrollX(t);\n          const isNight = WORLD.timeOfDay(t);\n          const color = kit.lerp('#6b4434', '#1f2438', isNight);\n          const topColor = kit.lerp('#d4834e', '#2a314d', isNight);\n\n          ctx.fillStyle = color;\n          ctx.beginPath();\n          ctx.lineTo(0, 720);\n          ctx.lineTo(1280, 720);\n          for(let sx = 1280; sx >= 0; sx -= 20) {\n              let wx = sx + scroll;\n              ctx.lineTo(sx, WORLD.groundY(wx));\n          }\n          ctx.fill();\n          \n          ctx.strokeStyle = topColor;\n          ctx.lineWidth = 14;\n          ctx.stroke();\n      }\n    },\n    { id: 'leaf_pile', kind: 'prop', from: 0, to: 15, params: {},\n      draw(ctx, t, p, rng) {\n          const wx = 720; // Position of pile in world\n          const sx = wx - WORLD.scrollX(t);\n          const sy = WORLD.groundY(wx);\n          if (sx < -200 || sx > 1480) return;\n\n          let isNight = WORLD.timeOfDay(t);\n          let c1 = kit.lerp('#b83b18', '#381613', isNight);\n          let c2 = kit.lerp('#d97634', '#45261d', isNight);\n\n          if (t < 3.6) {\n              // Intact pile\n              ctx.fillStyle = c1;\n              ctx.beginPath(); ctx.arc(sx, sy, 55, 0, 7); ctx.fill();\n              ctx.fillStyle = c2;\n              ctx.beginPath(); ctx.arc(sx-25, sy+10, 45, 0, 7); ctx.fill();\n              ctx.beginPath(); ctx.arc(sx+25, sy+10, 45, 0, 7); ctx.fill();\n          } else {\n              // Scattered remains\n              ctx.fillStyle = c1;\n              ctx.beginPath(); ctx.ellipse(sx-50, sy+15, 25, 6, 0.2, 0, 7); ctx.fill();\n              ctx.fillStyle = c2;\n              ctx.beginPath(); ctx.ellipse(sx+60, sy+20, 18, 5, -0.4, 0, 7); ctx.fill();\n          }\n      }\n    },\n    { id: 'burst', kind: 'effect', from: 3.6, to: 6, params: {},\n      draw(ctx, t, p, rng) {\n          const wx = 720;\n          const sx = wx - WORLD.scrollX(t);\n          const sy = WORLD.groundY(wx);\n          const age = t - 3.6;\n          \n          for(let i=0; i<40; i++) {\n              let angle = rng.float(Math.PI + 0.2, Math.PI * 2 - 0.2);\n              let speed = rng.float(150, 500);\n              let t_sec = age;\n              \n              let px = sx + Math.cos(angle) * speed * t_sec * Math.max(0, 1 - t_sec * 1.2);\n              let py = sy + Math.sin(angle) * speed * t_sec + 600 * t_sec * t_sec;\n\n              if (py > sy + 25) continue;\n\n              ctx.fillStyle = rng.chance(0.4) ? '#b83b18' : '#d97634';\n              ctx.save();\n              ctx.translate(px, py);\n              ctx.rotate(t_sec * speed * 0.04);\n              ctx.beginPath(); ctx.ellipse(0, 0, 8, 4, 0, 0, 7); ctx.fill();\n              ctx.restore();\n          }\n      }\n    },\n    { id: 'fox', kind: 'character', from: 0, to: 15, params: {},\n      draw(ctx, t, p, rng) {\n          const foxX_screen = 300;\n          let localX = 0, localY = 0;\n          let squ = 1, sqv = 1, rot = 0;\n          let eyes = 1, mouth = 'smile';\n          let lookDir = [0, 0];\n\n          if (t < 3.0) {\n              // Sneaking\n              localY = -Math.abs(Math.sin(t * 8)) * 14;\n              rot = Math.sin(t * 8) * 0.05;\n              lookDir = [0.6, 0];\n          } else if (t < 3.2) {\n              // Anticipation\n              let u = kit.seg(t, 3.0, 3.2);\n              squ = 1 + u * 0.2; sqv = 1 - u * 0.2;\n              localX = -u * 25;\n              rot = -0.15;\n              lookDir = [0.8, -0.2];\n          } else if (t < 3.6) {\n              // Pounce Arc\n              let u = kit.seg(t, 3.2, 3.6);\n              localX = -25 + kit.ease.out(u) * 205; \n              localY = -(4 * u * (1 - u)) * 140; \n              rot = kit.lerp(-0.25, 0.45, u);\n              squ = 0.85; sqv = 1.15; \n              eyes = 0; \n              mouth = 'o';\n          } else if (t < 4.5) {\n              // Hidden in pile\n              localX = 180; localY = 60; eyes = 0;\n          } else if (t < 4.8) {\n              // Pop out\n              let u = kit.seg(t, 4.5, 4.8);\n              let popU = kit.ease.back(u);\n              localX = 180;\n              localY = kit.lerp(60, -45, popU);\n              squ = 1 - (1-popU)*0.3; sqv = 1 + (1-popU)*0.3;\n              eyes = popU;\n              mouth = 'o';\n          } else if (t < 6.0) {\n              // Look around\n              localX = 180; localY = -45;\n              let faceU = kit.ease.inOut(kit.seg(t, 4.8, 5.3));\n              lookDir = [kit.lerp(-0.6, 0.6, faceU), -0.2];\n          } else if (t < 8.0) {\n              // Run up hill\n              let u = kit.seg(t, 6.0, 8.0);\n              localX = kit.lerp(180, 0, u); // Drift back to center frame\n              localY = kit.lerp(-45, 0, Math.min(1, u * 4)) - Math.abs(Math.sin(t * 16)) * 22;\n              rot = 0.22; \n              squ = 0.92; sqv = 1.08;\n              lookDir = [0.8, -0.2];\n          } else {\n              // Sit and watch\n              localX = 0; localY = 0;\n              rot = 0;\n              lookDir = [0.8, -0.4];\n              squ = 1 + Math.sin(t * 2.5) * 0.015;\n              sqv = 1 - Math.sin(t * 2.5) * 0.015;\n          }\n\n          let finalWx = WORLD.scrollX(t) + foxX_screen + localX;\n          let screenX = finalWx - WORLD.scrollX(t);\n          let groundY = WORLD.groundY(finalWx);\n          let screenY = groundY + localY;\n\n          // Shadow\n          if (t < 3.6 || t > 4.5) {\n              ctx.fillStyle = 'rgba(0,0,0,0.15)';\n              let shadowW = 45 * squ * (localY < -10 ? 0.6 : 1);\n              ctx.beginPath(); ctx.ellipse(screenX, groundY, shadowW, 12, 0, 0, 7); ctx.fill();\n          }\n\n          if (t >= 3.6 && t < 4.5) return; // Completely hidden\n\n          let isNight = WORLD.timeOfDay(t);\n          let bodyColor = kit.lerp('#e36622', '#a3410c', isNight);\n          let bellyColor = kit.lerp('#fce7c5', '#997e61', isNight);\n          let darkColor = kit.lerp('#361e11', '#170c06', isNight);\n\n          kit.hero(ctx, screenX, screenY, {\n              size: 0.18, sx: squ, sy: sqv, rot: rot,\n              eyes: eyes, look: lookDir, mouth: mouth, blush: true,\n              faceAt: [0.15, -0.15]\n          }, (ctx, px) => {\n              // Tail\n              ctx.save();\n              ctx.translate(-0.6, 0.35);\n              let tailRot = 0;\n              if (t < 3) tailRot = Math.sin(t * 8) * 0.25;\n              else if (t >= 8) tailRot = -1.35; // Wrap around legs\n              else if (t >= 6 && t < 8) tailRot = -0.6 + Math.sin(t * 16) * 0.35;\n              else if (t >= 4.5 && t < 6) tailRot = 0.2; \n              ctx.rotate(tailRot);\n              \n              ctx.fillStyle = bodyColor;\n              ctx.beginPath(); ctx.ellipse(-0.45, 0, 0.65, 0.3, -0.1, 0, 7); ctx.fill();\n              ctx.fillStyle = bellyColor; // Fluffy tip\n              ctx.beginPath(); ctx.ellipse(-0.9, 0, 0.25, 0.18, -0.1, 0, 7); ctx.fill();\n              ctx.restore();\n\n              // Legs\n              ctx.fillStyle = darkColor;\n              ctx.beginPath(); ctx.roundRect(-0.25, 0.5, 0.2, 0.5, 0.1); ctx.fill();\n              ctx.beginPath(); ctx.roundRect(0.15, 0.5, 0.2, 0.5, 0.1); ctx.fill();\n\n              // Body mass\n              ctx.fillStyle = bodyColor;\n              ctx.beginPath();\n              ctx.moveTo(0, -0.5);\n              ctx.bezierCurveTo(0.65, -0.5, 0.75, 0.6, 0, 0.7);\n              ctx.bezierCurveTo(-0.75, 0.6, -0.65, -0.5, 0, -0.5);\n              ctx.fill();\n\n              // Belly\n              ctx.fillStyle = bellyColor;\n              ctx.beginPath(); ctx.ellipse(0.1, 0.35, 0.35, 0.35, 0, 0, 7); ctx.fill();\n\n              // Ears\n              ctx.fillStyle = bodyColor;\n              ctx.beginPath(); ctx.moveTo(-0.35, -0.4); ctx.lineTo(-0.6, -1.0); ctx.lineTo(-0.05, -0.5); ctx.fill();\n              ctx.beginPath(); ctx.moveTo(0.35, -0.4); ctx.lineTo(0.6, -1.0); ctx.lineTo(0.05, -0.5); ctx.fill();\n              ctx.fillStyle = darkColor;\n              ctx.beginPath(); ctx.moveTo(-0.32, -0.45); ctx.lineTo(-0.55, -0.9); ctx.lineTo(-0.1, -0.55); ctx.fill();\n              ctx.beginPath(); ctx.moveTo(0.32, -0.45); ctx.lineTo(0.55, -0.9); ctx.lineTo(0.1, -0.55); ctx.fill();\n          });\n\n          // Leaf on head after popping out\n          if (t >= 4.5) {\n              ctx.save();\n              ctx.translate(screenX, screenY);\n              ctx.scale(squ, sqv);\n              ctx.rotate(rot);\n              \n              let headY = -0.18 * 720 * 0.6; \n              ctx.translate(0, headY);\n              \n              let leafColor = kit.lerp('#b83b18', '#631f0b', isNight);\n              let stemColor = kit.lerp('#5a1908', '#260a03', isNight);\n              \n              ctx.fillStyle = leafColor;\n              ctx.beginPath(); ctx.ellipse(6, -12, 14, 7, -0.3, 0, 7); ctx.fill();\n              ctx.strokeStyle = stemColor; ctx.lineWidth = 2.5;\n              ctx.beginPath(); ctx.moveTo(-5, -10); ctx.lineTo(-18, -5); ctx.stroke();\n              \n              ctx.restore();\n          }\n      }\n    },\n    { id: 'fireflies', kind: 'effect', from: 7, to: 15, params: {},\n      draw(ctx, t, p, rng) {\n          let u = kit.seg(t, 7.5, 9.5); \n          if (u <= 0) return;\n          \n          for (let i=0; i<60; i++) {\n              let wx = rng.float(1000, 2600); \n              let sx = wx - WORLD.scrollX(t);\n              if (sx < -50 || sx > 1330) continue;\n              \n              let sy = WORLD.groundY(wx) - rng.float(15, 300);\n              let driftX = Math.sin(t * rng.float(0.5, 2) + i) * 40;\n              let driftY = Math.cos(t * rng.float(0.5, 2) + i) * 30;\n              let pulse = 0.5 + 0.5 * Math.sin(t * rng.float(1.5, 4) + i);\n              \n              let glowAlpha = u * pulse;\n              ctx.fillStyle = `rgba(196, 255, 143, ${glowAlpha * 0.9})`;\n              ctx.beginPath(); ctx.arc(sx + driftX, sy + driftY, 2 + pulse * 2, 0, 7); ctx.fill();\n              \n              ctx.fillStyle = `rgba(196, 255, 143, ${glowAlpha * 0.25})`;\n              ctx.beginPath(); ctx.arc(sx + driftX, sy + driftY, 10 + pulse * 6, 0, 7); ctx.fill();\n          }\n      }\n    },\n    { id: 'title', kind: 'title', from: 9.5, to: 15, params: { color: '#fcd379' },\n      draw(ctx, t, p) {\n          let u = kit.ease.inOut(kit.seg(t, 10.0, 11.5));\n          kit.writeOn(ctx, 'Ember', 640, 260, { width: 0.35, family: '\"Great Vibes\", cursive', color: p.color, u: u });\n      }\n    },\n    { id: 'vignette', kind: 'overlay', from: 0, to: 15, params: {},\n      draw(ctx, t, p) {\n          kit.paper(ctx, 1280, 720, { overlay: true });\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(); \n        e.draw(ctx, t, e.params, rng.for(e.id)); \n        ctx.restore();\n    }\n}\n\nfunction compose(params) {\n    return [\n        // Tiptoeing through the forest\n        { inst: 'pluck', t: 0.2, midi: 60, dur: 0.4, vel: 0.6 },\n        { inst: 'pluck', t: 0.8, midi: 64, dur: 0.4, vel: 0.6 },\n        { inst: 'pluck', t: 1.4, midi: 67, dur: 0.4, vel: 0.6 },\n        { inst: 'pluck', t: 2.0, midi: 64, dur: 0.4, vel: 0.6 },\n        { inst: 'pluck', t: 2.6, midi: 72, dur: 0.4, vel: 0.6 },\n        \n        // Pounce & Land\n        { inst: 'whoosh', t: 3.2, dur: 0.5, vel: 0.5 },\n        { inst: 'kick', t: 3.6, dur: 0.3, vel: 0.8 },\n        { inst: 'noise', t: 3.6, dur: 0.5, vel: 0.7 },\n        \n        // Pop out\n        { inst: 'pop', t: 4.5, dur: 0.2, vel: 0.8 },\n        { inst: 'marimba', t: 4.6, midi: 76, dur: 0.6, vel: 0.7 },\n        \n        // Run up hill\n        { inst: 'strings', t: 6.0, midi: 60, dur: 2.5, vel: 0.4 },\n        { inst: 'strings', t: 6.0, midi: 67, dur: 2.5, vel: 0.4 },\n        { inst: 'whoosh', t: 6.2, dur: 1.8, vel: 0.35 },\n        \n        // Sitting, sunset and fireflies\n        { inst: 'pad', t: 8.0, midi: 53, dur: 7.0, vel: 0.4 }, \n        { inst: 'pad', t: 8.0, midi: 60, dur: 7.0, vel: 0.35 }, \n        { inst: 'pad', t: 8.0, midi: 65, dur: 7.0, vel: 0.35 }, \n        { inst: 'pad', t: 8.0, midi: 69, dur: 7.0, vel: 0.35 }, \n        { inst: 'piano', t: 8.2, midi: 77, dur: 3.0, vel: 0.5 },\n        \n        // Title reveal\n        { inst: 'bell', t: 10.0, midi: 81, dur: 4.0, vel: 0.5 },\n        { inst: 'bell', t: 10.5, midi: 84, dur: 4.0, vel: 0.4 },\n        { inst: 'harp', t: 10.0, midi: 69, dur: 4.0, vel: 0.6 }\n    ];\n}\n","exportProfile":{"format":"mp4","resolution":"720p","fps":30,"durationSeconds":15},"source":{"prompt":"A 15 second animated film: a small orange fox with a fluffy tail tiptoes through an autumn forest with layered trees and falling leaves, pounces into a pile of leaves that bursts everywhere, pops out with a leaf on its head, then runs up a hill as the sun sets and sits watching fireflies come out. It ends with the handwritten title \"Ember\" writing itself on.","provider":"google","model":"gemini-3.1-pro-preview","app":"gallery-reproduction"}},"license":"CC0-1.0"}