{"id":"night-market","title":"Night Market","category":"Drawn film","prompt":"A 28-second illustrated film: a small night market under strings of paper lanterns after rain. Steam rises from a noodle stall, lantern light shimmers in the wet cobbles, a cat walks along a rooftop, and one by one the lanterns sway as a breeze passes through. Closing title: NIGHT MARKET. Paint it, do not assemble it: graded skies, soft radial glows built from gradients (never a flat translucent disc), layered depth with atmospheric fade, paper-cut shapes with subtle shadows, gentle continuous motion, and one clear moment of change. No text except a small closing title in clear space. Every frame should stand as a composed picture.","designedBy":{"provider":"google","model":"gemini-3.7-flash · 1 revision","inference":{"inputTokens":6147,"outputTokens":12068,"costUsd":0.2585}},"render":{"credits":12,"priceUsd":0.18,"format":"mp4","frameCount":672,"bytes":2380793,"seed":42,"width":1280,"height":720,"fps":24,"durationSeconds":28},"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=night-market), and it exports as MP4 or GIF, not as a still."},"renderRequest":{"drawContract":"const meta = { duration: 28, fps: 24, size: [1280, 720], seed: 42 };\n\nfunction draw(ctx, t, params, rng) {\n  const W = 1280;\n  const H = 720;\n\n  // --- Helper math & easing ---\n  const clamp = (v, min, max) => Math.max(min, Math.min(max, v));\n  const smoothstep = (edge0, edge1, x) => {\n    const u = clamp((x - edge0) / (edge1 - edge0), 0, 1);\n    return u * u * (3 - 2 * u);\n  };\n\n  // Breeze wave model: travels across the street (x: 0 -> W) around t=7.5s to 18s\n  const breezeProgress = (t - 7.5) * 110; // travels ~110 px/sec\n  const getBreezeIntensity = (x) => {\n    if (t < 7.0 || t > 21.0) return 0;\n    const dist = x - breezeProgress;\n    const wave = Math.exp(-(dist * dist) / (2 * 180 * 180));\n    const decay = smoothstep(7.0, 9.5, t) * (1 - smoothstep(17.0, 21.0, t));\n    return wave * decay;\n  };\n\n  // ==========================================\n  // 1. SKY & DISTANT MIST\n  // ==========================================\n  const skyGrad = ctx.createLinearGradient(0, 0, 0, H * 0.75);\n  skyGrad.addColorStop(0.0, '#060a14');\n  skyGrad.addColorStop(0.4, '#0d1527');\n  skyGrad.addColorStop(0.75, '#16233b');\n  skyGrad.addColorStop(1.0, '#233247');\n  ctx.fillStyle = skyGrad;\n  ctx.fillRect(0, 0, W, H);\n\n  // Distant post-rain low clouds / blue mist drift\n  ctx.save();\n  for (let i = 0; i < 4; i++) {\n    const cloudY = 90 + i * 55;\n    const drift = ((t * (6 + i * 3) + i * 280) % (W + 600)) - 300;\n    const cloudGrad = ctx.createRadialGradient(drift, cloudY, 30, drift, cloudY, 340);\n    cloudGrad.addColorStop(0, `rgba(40, 60, 90, ${0.12 - i * 0.02})`);\n    cloudGrad.addColorStop(0.6, `rgba(25, 40, 65, ${0.06 - i * 0.01})`);\n    cloudGrad.addColorStop(1, 'rgba(10, 20, 35, 0)');\n    ctx.fillStyle = cloudGrad;\n    ctx.beginPath();\n    ctx.ellipse(drift, cloudY, 320, 65, 0, 0, Math.PI * 2);\n    ctx.fill();\n  }\n  ctx.restore();\n\n  // ==========================================\n  // 2. DISTANT BACKGROUND ROOFTOPS & PAGODAS (Layer 1)\n  // ==========================================\n  ctx.save();\n  ctx.fillStyle = '#0f1828';\n  ctx.beginPath();\n  ctx.moveTo(0, 380);\n  ctx.lineTo(80, 365);\n  ctx.lineTo(140, 375);\n  ctx.lineTo(240, 320); // pagoda spire\n  ctx.lineTo(245, 290);\n  ctx.lineTo(250, 320);\n  ctx.lineTo(340, 360);\n  ctx.lineTo(460, 345);\n  ctx.lineTo(580, 390);\n  ctx.lineTo(720, 350);\n  ctx.lineTo(840, 330);\n  ctx.lineTo(950, 365);\n  ctx.lineTo(1100, 340);\n  ctx.lineTo(1280, 380);\n  ctx.lineTo(1280, H);\n  ctx.lineTo(0, H);\n  ctx.closePath();\n  ctx.fill();\n\n  // Distant glowing soft windows in the blue gloom\n  const distWindows = [\n    [110, 385], [130, 390], [420, 370], [440, 375], [760, 368], [880, 355], [1040, 365]\n  ];\n  for (const [wx, wy] of distWindows) {\n    const wGlow = ctx.createRadialGradient(wx, wy, 1, wx, wy, 14);\n    wGlow.addColorStop(0, 'rgba(255, 180, 80, 0.45)');\n    wGlow.addColorStop(0.5, 'rgba(255, 140, 40, 0.15)');\n    wGlow.addColorStop(1, 'rgba(255, 100, 20, 0)');\n    ctx.fillStyle = wGlow;\n    ctx.fillRect(wx - 14, wy - 14, 28, 28);\n    ctx.fillStyle = '#ffd175';\n    ctx.fillRect(wx - 2, wy - 3, 4, 6);\n  }\n  ctx.restore();\n\n  // Midground atmospheric haze separator\n  const midHaze = ctx.createLinearGradient(0, 330, 0, 470);\n  midHaze.addColorStop(0, 'rgba(18, 30, 48, 0)');\n  midHaze.addColorStop(0.5, 'rgba(24, 38, 58, 0.4)');\n  midHaze.addColorStop(1, 'rgba(15, 22, 35, 0.85)');\n  ctx.fillStyle = midHaze;\n  ctx.fillRect(0, 330, W, 140);\n\n  // ==========================================\n  // 3. MIDGROUND ALLEY ARCHITECTURE & ROOF RIDGES\n  // ==========================================\n  // Left Building & Eaves\n  ctx.fillStyle = '#101722';\n  ctx.beginPath();\n  ctx.moveTo(0, 160);\n  ctx.quadraticCurveTo(120, 190, 260, 220); // sweeping curved eave\n  ctx.lineTo(270, 235);\n  ctx.quadraticCurveTo(140, 215, 0, 200);\n  ctx.closePath();\n  ctx.fill();\n\n  // Left building body\n  ctx.fillRect(0, 190, 230, H - 190);\n\n  // Left tile ridge texture\n  ctx.strokeStyle = '#182436';\n  ctx.lineWidth = 2.5;\n  for (let i = 0; i < 9; i++) {\n    const ex = 20 + i * 26;\n    const ey = 168 + i * 6.5;\n    ctx.beginPath();\n    ctx.moveTo(ex, ey);\n    ctx.lineTo(ex - 25, ey + 28);\n    ctx.stroke();\n  }\n\n  // Right Rooftop (where cat walks)\n  ctx.fillStyle = '#121924';\n  ctx.beginPath();\n  ctx.moveTo(760, 270);\n  ctx.quadraticCurveTo(960, 250, 1280, 210); // traditional concave curve\n  ctx.lineTo(1280, 235);\n  ctx.quadraticCurveTo(970, 270, 770, 288);\n  ctx.closePath();\n  ctx.fill();\n\n  // Right building facade & supporting timber\n  ctx.fillRect(790, 280, 490, H - 280);\n\n  // Eave beam trim\n  ctx.strokeStyle = '#1d2a3d';\n  ctx.lineWidth = 4;\n  ctx.beginPath();\n  ctx.moveTo(760, 270);\n  ctx.quadraticCurveTo(960, 250, 1280, 210);\n  ctx.stroke();\n\n  // ==========================================\n  // 4. THE CAT ON THE ROOFTOP\n  // ==========================================\n  ctx.save();\n  const catT = t;\n  let catX, catY, catState;\n\n  if (catT < 4.0) {\n    catX = -100; catY = -100; catState = 'off';\n  } else if (catT < 12.0) {\n    // walking in from right along the roof curve\n    const u = (catT - 4.0) / 8.0;\n    catX = 1180 - u * 300;\n    const q = (catX - 760) / 520;\n    catY = (1 - q) * (1 - q) * 270 + 2 * (1 - q) * q * 250 + q * q * 210 - 8;\n    catState = 'walk';\n  } else if (catT < 21.0) {\n    // sitting & looking down at market\n    catX = 880;\n    const q = (catX - 760) / 520;\n    catY = (1 - q) * (1 - q) * 270 + 2 * (1 - q) * q * 250 + q * q * 210 - 8;\n    catState = 'sit';\n  } else {\n    // slowly walking down the left ridge corner\n    const u = (catT - 21.0) / 7.0;\n    catX = 880 - u * 110;\n    const q = (catX - 760) / 520;\n    catY = (1 - q) * (1 - q) * 270 + 2 * (1 - q) * q * 250 + q * q * 210 - 8;\n    catState = 'walk';\n  }\n\n  if (catState !== 'off') {\n    ctx.translate(catX, catY);\n    ctx.fillStyle = '#0a0e16'; // silhouette\n\n    if (catState === 'walk') {\n      const walkCycle = catT * 7.5;\n      const leg1 = Math.sin(walkCycle) * 6;\n      const leg2 = Math.sin(walkCycle + Math.PI) * 6;\n      const tailWag = Math.sin(catT * 3.5) * 8;\n\n      // Body\n      ctx.beginPath();\n      ctx.ellipse(0, -6, 12, 6, -0.1, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Head & ears (facing left)\n      ctx.beginPath();\n      ctx.arc(-13, -10, 5, 0, Math.PI * 2);\n      ctx.fill();\n      // Ears\n      ctx.beginPath();\n      ctx.moveTo(-16, -14); ctx.lineTo(-13, -19); ctx.lineTo(-11, -14); ctx.fill();\n      ctx.beginPath();\n      ctx.moveTo(-12, -14); ctx.lineTo(-9, -18); ctx.lineTo(-7, -13); ctx.fill();\n\n      // Legs\n      ctx.lineWidth = 2.2;\n      ctx.strokeStyle = '#0a0e16';\n      // Front legs\n      ctx.beginPath(); ctx.moveTo(-8, -2); ctx.lineTo(-10 + leg1, 5); ctx.stroke();\n      ctx.beginPath(); ctx.moveTo(-6, -2); ctx.lineTo(-6 - leg1, 5); ctx.stroke();\n      // Back legs\n      ctx.beginPath(); ctx.moveTo(7, -2); ctx.lineTo(8 + leg2, 5); ctx.stroke();\n      ctx.beginPath(); ctx.moveTo(9, -2); ctx.lineTo(9 - leg2, 5); ctx.stroke();\n\n      // Tail curling up & swaying\n      ctx.beginPath();\n      ctx.moveTo(11, -6);\n      ctx.quadraticCurveTo(20, -14 + tailWag * 0.3, 17 + tailWag * 0.4, -22);\n      ctx.lineWidth = 2;\n      ctx.stroke();\n    } else {\n      // Sitting silhouette\n      const breath = Math.sin(catT * 2.0) * 0.8;\n      const tailSwish = Math.sin(catT * 2.2) * 6;\n      const headTilt = Math.sin(catT * 1.2) * 1.5;\n\n      // Hindquarters / seated body\n      ctx.beginPath();\n      ctx.ellipse(2, -4, 8, 7 + breath * 0.3, 0.2, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Chest & upright posture\n      ctx.beginPath();\n      ctx.ellipse(-4, -9, 6, 8, -0.2, 0, Math.PI * 2);\n      ctx.fill();\n\n      // Head looking down-left into the market\n      ctx.save();\n      ctx.translate(-7, -18);\n      ctx.rotate(0.3 + headTilt * 0.05);\n      ctx.beginPath();\n      ctx.arc(0, 0, 5.5, 0, Math.PI * 2);\n      ctx.fill();\n      // Ears perked\n      ctx.beginPath();\n      ctx.moveTo(-4, -4); ctx.lineTo(-3, -10); ctx.lineTo(0, -5); ctx.fill();\n      ctx.beginPath();\n      ctx.moveTo(1, -4); ctx.lineTo(4, -9); ctx.lineTo(5, -3); ctx.fill();\n      ctx.restore();\n\n      // Paws planted in front\n      ctx.lineWidth = 2.4;\n      ctx.strokeStyle = '#0a0e16';\n      ctx.beginPath(); ctx.moveTo(-6, -4); ctx.lineTo(-7, 4); ctx.stroke();\n      ctx.beginPath(); ctx.moveTo(-3, -4); ctx.lineTo(-3, 4); ctx.stroke();\n\n      // Tail swishing lazily down the roof tile\n      ctx.beginPath();\n      ctx.moveTo(8, -1);\n      ctx.quadraticCurveTo(18 + tailSwish, 3, 14 + tailSwish * 1.2, 14);\n      ctx.lineWidth = 2.2;\n      ctx.stroke();\n    }\n  }\n  ctx.restore();\n\n  // ==========================================\n  // 5. NOODLE STALL (YATAI) & FOREGROUND STRUCTURES\n  // ==========================================\n  const stallX = 280;\n  const stallY = 360;\n\n  // Stall Timber Frame\n  ctx.fillStyle = '#171a21';\n  ctx.fillRect(stallX, stallY, 14, 210);\n  ctx.fillRect(stallX + 270, stallY, 14, 210);\n  ctx.fillRect(stallX + 135, stallY + 30, 10, 180);\n\n  // Stall Counter\n  ctx.fillStyle = '#221915';\n  ctx.fillRect(stallX - 15, stallY + 120, 310, 18);\n  ctx.fillStyle = '#3a271d';\n  ctx.fillRect(stallX - 15, stallY + 120, 310, 4);\n\n  // Stall Lower Cabinet\n  ctx.fillStyle = '#141217';\n  ctx.fillRect(stallX, stallY + 138, 284, 82);\n  ctx.strokeStyle = '#0c0a0f';\n  ctx.lineWidth = 2;\n  for (let sx = stallX + 16; sx < stallX + 280; sx += 20) {\n    ctx.beginPath(); ctx.moveTo(sx, stallY + 138); ctx.lineTo(sx, stallY + 220); ctx.stroke();\n  }\n\n  // Broth cauldron\n  const potX = stallX + 45;\n  const potY = stallY + 104;\n  ctx.fillStyle = '#333842';\n  ctx.beginPath();\n  ctx.roundRect(potX, potY, 44, 18, [2, 2, 8, 8]);\n  ctx.fill();\n  ctx.fillStyle = '#565f6e';\n  ctx.fillRect(potX + 2, potY, 40, 3);\n\n  // Stacked ramen bowls\n  ctx.fillStyle = '#d4c7b8';\n  ctx.beginPath(); ctx.ellipse(stallX + 160, stallY + 115, 12, 5, 0, 0, Math.PI * 2); ctx.fill();\n  ctx.beginPath(); ctx.ellipse(stallX + 160, stallY + 111, 11, 4.5, 0, 0, Math.PI * 2); ctx.fill();\n  ctx.beginPath(); ctx.ellipse(stallX + 188, stallY + 115, 12, 5, 0, 0, Math.PI * 2); ctx.fill();\n\n  // Awning flutter\n  const breezeAwning = Math.sin(t * 3.5 + stallX * 0.01) * 3 * getBreezeIntensity(stallX + 100);\n  const awningSway = Math.sin(t * 1.8) * 1.5 + breezeAwning;\n\n  // Slanted canopy roof\n  ctx.fillStyle = '#59201a';\n  ctx.beginPath();\n  ctx.moveTo(stallX - 30, stallY + 25);\n  ctx.lineTo(stallX + 310, stallY + 10);\n  ctx.lineTo(stallX + 325, stallY + 38 + awningSway * 0.5);\n  ctx.lineTo(stallX - 40, stallY + 54 + awningSway);\n  ctx.closePath();\n  ctx.fill();\n\n  // Awning cloth stripes\n  ctx.fillStyle = '#cfb997';\n  for (let i = 0; i < 6; i++) {\n    const startFrac = (i * 2 + 0.5) / 12;\n    const endFrac = (i * 2 + 1.5) / 12;\n    ctx.beginPath();\n    ctx.moveTo(stallX - 30 + startFrac * 340, stallY + 25 - startFrac * 15);\n    ctx.lineTo(stallX - 30 + endFrac * 340, stallY + 25 - endFrac * 15);\n    ctx.lineTo(stallX - 40 + endFrac * 365, stallY + 54 + awningSway - endFrac * 16);\n    ctx.lineTo(stallX - 40 + startFrac * 365, stallY + 54 + awningSway - startFrac * 16);\n    ctx.closePath();\n    ctx.fill();\n  }\n\n  // Scalloped noren hem\n  ctx.fillStyle = '#3d1511';\n  ctx.beginPath();\n  ctx.moveTo(stallX - 40, stallY + 54 + awningSway);\n  for (let c = 0; c < 12; c++) {\n    const cx0 = stallX - 40 + (c / 12) * 365;\n    const cx1 = stallX - 40 + ((c + 1) / 12) * 365;\n    const midX = (cx0 + cx1) / 2;\n    const cy = stallY + 54 + awningSway - (c / 12) * 16;\n    const flap = Math.sin(t * 4.0 + c * 0.8) * (2 + getBreezeIntensity(midX) * 6);\n    ctx.quadraticCurveTo(midX, cy + 10 + flap, cx1, cy - (1 / 12) * 16);\n  }\n  ctx.lineTo(stallX + 325, stallY + 38);\n  ctx.lineTo(stallX + 325, stallY + 30);\n  ctx.lineTo(stallX - 40, stallY + 46);\n  ctx.closePath();\n  ctx.fill();\n\n  // Warm light spill from inside noodle stall\n  const cartGlow = ctx.createRadialGradient(stallX + 140, stallY + 95, 10, stallX + 140, stallY + 95, 170);\n  cartGlow.addColorStop(0, 'rgba(255, 200, 110, 0.55)');\n  cartGlow.addColorStop(0.3, 'rgba(255, 150, 60, 0.28)');\n  cartGlow.addColorStop(0.7, 'rgba(220, 100, 30, 0.08)');\n  cartGlow.addColorStop(1, 'rgba(200, 80, 20, 0)');\n  ctx.fillStyle = cartGlow;\n  ctx.beginPath();\n  ctx.arc(stallX + 140, stallY + 95, 170, 0, Math.PI * 2);\n  ctx.fill();\n\n  // Cook silhouette\n  ctx.fillStyle = '#0f131a';\n  ctx.beginPath(); ctx.arc(stallX + 90, stallY + 82, 13, 0, Math.PI * 2); ctx.fill();\n  ctx.fillRect(stallX + 80, stallY + 76, 20, 5);\n  ctx.beginPath();\n  ctx.moveTo(stallX + 70, stallY + 120);\n  ctx.quadraticCurveTo(stallX + 90, stallY + 95, stallX + 112, stallY + 120);\n  ctx.closePath();\n  ctx.fill();\n\n  // Ladle movement\n  const ladleMove = Math.sin(t * 1.5) * 4;\n  ctx.lineWidth = 4;\n  ctx.strokeStyle = '#0f131a';\n  ctx.beginPath();\n  ctx.moveTo(stallX + 98, stallY + 104);\n  ctx.quadraticCurveTo(stallX + 80, stallY + 108 + ladleMove, potX + 22, potY + 4 + ladleMove);\n  ctx.stroke();\n\n  // Right Side Market Stall Silhouette\n  const rStallX = 940;\n  const rStallY = 400;\n  ctx.fillStyle = '#131822';\n  ctx.fillRect(rStallX, rStallY, 12, 180);\n  ctx.fillRect(rStallX + 220, rStallY, 12, 180);\n  ctx.fillRect(rStallX - 10, rStallY + 100, 250, 16);\n  ctx.fillStyle = '#1a332d';\n  ctx.beginPath();\n  ctx.moveTo(rStallX - 20, rStallY + 20);\n  ctx.lineTo(rStallX + 250, rStallY + 5);\n  ctx.lineTo(rStallX + 240, rStallY + 45);\n  ctx.lineTo(rStallX - 30, rStallY + 58);\n  ctx.closePath();\n  ctx.fill();\n\n  // ==========================================\n  // 6. RISING STEAM FROM THE NOODLE POT\n  // ==========================================\n  ctx.save();\n  const breezeXDrift = getBreezeIntensity(potX) * 45;\n  const numSteamPuffs = 28;\n  for (let i = 0; i < numSteamPuffs; i++) {\n    const pSeed = (i * 1.37) % 1.0;\n    const pAge = (t * 0.45 + pSeed) % 1.0;\n    const pAlpha = Math.sin(pAge * Math.PI) * (1 - pAge * 0.4);\n\n    const swirl = Math.sin(pAge * 7.0 + i + t * 2.2) * (14 + pAge * 35);\n    const px = potX + 22 + swirl + pAge * (25 + breezeXDrift);\n    const py = potY - pAge * 150 - (pAge * pAge * 30);\n    const pRadius = 7 + pAge * 36;\n\n    const steamGrad = ctx.createRadialGradient(px, py, 0, px, py, pRadius);\n    const rCol = Math.round(255 - pAge * 90);\n    const gCol = Math.round(220 - pAge * 70);\n    const bCol = Math.round(180 - pAge * 40);\n    steamGrad.addColorStop(0, `rgba(${rCol}, ${gCol}, ${bCol}, ${pAlpha * 0.22})`);\n    steamGrad.addColorStop(0.5, `rgba(${rCol}, ${gCol}, ${bCol}, ${pAlpha * 0.11})`);\n    steamGrad.addColorStop(1, 'rgba(20, 30, 45, 0)');\n\n    ctx.fillStyle = steamGrad;\n    ctx.beginPath();\n    ctx.arc(px, py, pRadius, 0, Math.PI * 2);\n    ctx.fill();\n  }\n  ctx.restore();\n\n  // ==========================================\n  // 7. LANTERN STRINGS & SWAYING PAPER LANTERNS\n  // ==========================================\n  const wires = [\n    { x0: 0, y0: 195, x1: 640, y1: 270, sag: 55 },\n    { x0: 580, y0: 260, x1: 1280, y1: 225, sag: 65 },\n    { x0: 160, y0: 250, x1: 960, y1: 305, sag: 70 }\n  ];\n\n  ctx.strokeStyle = '#0d131d';\n  ctx.lineWidth = 1.8;\n  for (const w of wires) {\n    ctx.beginPath();\n    ctx.moveTo(w.x0, w.y0);\n    ctx.quadraticCurveTo((w.x0 + w.x1) / 2, (w.y0 + w.y1) / 2 + w.sag, w.x1, w.y1);\n    ctx.stroke();\n  }\n\n  const lanterns = [\n    { x: 120, y: 228, type: 'round-red', size: 28, color: '#e83e28', glow: '#ff6238', phase: 0.2 },\n    { x: 230, y: 255, type: 'oval-amber', size: 34, color: '#f7881e', glow: '#ffaa3b', phase: 0.9 },\n    { x: 360, y: 282, type: 'round-red', size: 30, color: '#eb382a', glow: '#ff5840', phase: 1.6 },\n    { x: 490, y: 286, type: 'cyl-ivory', size: 36, color: '#f5deb3', glow: '#ffecc4', phase: 2.3 },\n    { x: 670, y: 288, type: 'oval-amber', size: 35, color: '#f2811d', glow: '#ffa633', phase: 3.1 },\n    { x: 790, y: 285, type: 'round-red', size: 32, color: '#e63324', glow: '#ff553a', phase: 3.8 },\n    { x: 920, y: 275, type: 'cyl-ivory', size: 33, color: '#fae4be', glow: '#fff0cf', phase: 4.5 },\n    { x: 1060, y: 255, type: 'round-gold', size: 30, color: '#f2a61d', glow: '#ffc84d', phase: 5.2 },\n    { x: 1190, y: 230, type: 'oval-amber', size: 28, color: '#f07b1d', glow: '#ff9d3b', phase: 5.9 }\n  ];\n\n  function drawPaperLantern(lan) {\n    const breeze = getBreezeIntensity(lan.x);\n    const baseIdleSway = Math.sin(t * 1.6 + lan.phase) * 2.8;\n    const breezeSway = Math.sin(t * 3.6 + lan.phase * 0.5) * (breeze * 32.0);\n    const totalAngle = (baseIdleSway + breezeSway) * (Math.PI / 180);\n\n    ctx.save();\n    ctx.translate(lan.x, lan.y);\n\n    // Soft Radial Ambient Glow\n    const glowRadius = lan.size * 5.2;\n    const radGlow = ctx.createRadialGradient(0, lan.size * 0.5, 4, 0, lan.size * 0.5, glowRadius);\n    radGlow.addColorStop(0, `${lan.glow}99`);\n    radGlow.addColorStop(0.2, `${lan.glow}4d`);\n    radGlow.addColorStop(0.5, `${lan.glow}18`);\n    radGlow.addColorStop(0.85, `${lan.glow}06`);\n    radGlow.addColorStop(1, 'rgba(0,0,0,0)');\n\n    ctx.fillStyle = radGlow;\n    ctx.beginPath();\n    ctx.arc(0, lan.size * 0.5, glowRadius, 0, Math.PI * 2);\n    ctx.fill();\n\n    // Rotate lantern around anchor\n    ctx.rotate(totalAngle);\n\n    // Hanging little top wire\n    ctx.strokeStyle = '#1a1f2c';\n    ctx.lineWidth = 1.5;\n    ctx.beginPath(); ctx.moveTo(0, -6); ctx.lineTo(0, 0); ctx.stroke();\n\n    // Top wooden cap\n    ctx.fillStyle = '#1c140e';\n    ctx.beginPath();\n    ctx.roundRect(-lan.size * 0.35, 0, lan.size * 0.7, 4, 2);\n    ctx.fill();\n\n    // Lantern Paper Body\n    const bodyW = lan.size * 0.78;\n    const bodyH = lan.size * 1.15;\n    const paperGrad = ctx.createLinearGradient(-bodyW, 0, bodyW, 0);\n    paperGrad.addColorStop(0, '#5a120a');\n    paperGrad.addColorStop(0.25, lan.color);\n    paperGrad.addColorStop(0.5, '#fff4d4');\n    paperGrad.addColorStop(0.75, lan.color);\n    paperGrad.addColorStop(1, '#5a120a');\n\n    ctx.fillStyle = paperGrad;\n    ctx.beginPath();\n    if (lan.type.startsWith('round')) {\n      ctx.ellipse(0, 4 + bodyH * 0.45, bodyW * 0.95, bodyH * 0.5, 0, 0, Math.PI * 2);\n    } else if (lan.type.startsWith('oval')) {\n      ctx.ellipse(0, 4 + bodyH * 0.48, bodyW * 0.85, bodyH * 0.58, 0, 0, Math.PI * 2);\n    } else {\n      ctx.roundRect(-bodyW * 0.75, 4, bodyW * 1.5, bodyH, 6);\n    }\n    ctx.fill();\n\n    // Bamboo ribs\n    ctx.strokeStyle = 'rgba(60, 20, 10, 0.4)';\n    ctx.lineWidth = 1.2;\n    const ribCount = 5;\n    for (let r = 1; r <= ribCount; r++) {\n      const ry = 4 + (r / (ribCount + 1)) * bodyH;\n      const rWidth = bodyW * 0.82;\n      ctx.beginPath();\n      ctx.ellipse(0, ry, rWidth, 3, 0, 0, Math.PI * 2);\n      ctx.stroke();\n    }\n\n    // Calligraphy mark silhouette\n    ctx.fillStyle = 'rgba(50, 15, 10, 0.45)';\n    ctx.fillRect(-2, 4 + bodyH * 0.35, 4, 12);\n    ctx.fillRect(-6, 4 + bodyH * 0.42, 12, 3);\n\n    // Bottom rim\n    ctx.fillStyle = '#1c140e';\n    ctx.beginPath();\n    ctx.roundRect(-lan.size * 0.32, 4 + bodyH, lan.size * 0.64, 4, 2);\n    ctx.fill();\n\n    // Tassel swinging with inertial lag\n    const tasselLag = Math.sin(t * 3.6 + lan.phase * 0.5 - 0.5) * (breeze * 42.0);\n    const tasselAngle = (baseIdleSway * 1.3 + tasselLag) * (Math.PI / 180);\n\n    ctx.save();\n    ctx.translate(0, 8 + bodyH);\n    ctx.rotate(tasselAngle - totalAngle);\n\n    ctx.strokeStyle = '#9c1c1c';\n    ctx.lineWidth = 2.0;\n    ctx.beginPath(); ctx.moveTo(0, 0); ctx.lineTo(0, 10); ctx.stroke();\n\n    // Tassel fringe skirt\n    ctx.fillStyle = '#b82323';\n    ctx.beginPath();\n    ctx.moveTo(-3, 10);\n    ctx.lineTo(3, 10);\n    ctx.lineTo(5, 26);\n    ctx.lineTo(-5, 26);\n    ctx.closePath();\n    ctx.fill();\n    ctx.restore();\n\n    ctx.restore();\n  }\n\n  for (const lan of lanterns) {\n    drawPaperLantern(lan);\n  }\n\n  // ==========================================\n  // 8. WET COBBLESTONES & REFLECTION SHIMMER\n  // ==========================================\n  const groundY = 530;\n\n  const roadGrad = ctx.createLinearGradient(0, groundY, 0, H);\n  roadGrad.addColorStop(0, '#0c121b');\n  roadGrad.addColorStop(0.3, '#101722');\n  roadGrad.addColorStop(0.7, '#151e2b');\n  roadGrad.addColorStop(1, '#0b1017');\n  ctx.fillStyle = roadGrad;\n  ctx.fillRect(0, groundY, W, H - groundY);\n\n  ctx.save();\n  const rows = 14;\n  for (let r = 0; r < rows; r++) {\n    const yFrac = r / rows;\n    const cy = groundY + yFrac * (H - groundY);\n    const rowH = 8 + yFrac * 18;\n    const stoneW = 24 + yFrac * 45;\n    const cols = Math.ceil(W / stoneW) + 2;\n    const xOffset = (r % 2) * (stoneW * 0.5);\n\n    for (let c = -1; c < cols; c++) {\n      const cx = c * stoneW + xOffset;\n\n      let minLanDist = 999;\n      let nearestLan = lanterns[0];\n      for (const lan of lanterns) {\n        const d = Math.abs(lan.x - cx);\n        if (d < minLanDist) {\n          minLanDist = d;\n          nearestLan = lan;\n        }\n      }\n\n      const shimmerWave = Math.sin(t * 3.0 + cx * 0.08 + cy * 0.15);\n      const isReflecting = minLanDist < 90;\n      const refAlpha = isReflecting\n        ? Math.max(0, (1 - minLanDist / 90) * (0.35 + 0.15 * shimmerWave))\n        : 0.03;\n\n      ctx.strokeStyle = '#070a10';\n      ctx.lineWidth = 1.2 + yFrac * 1.5;\n      ctx.strokeRect(cx + 2, cy + 2, stoneW - 4, rowH - 3);\n\n      const wetHighlight = ctx.createLinearGradient(cx, cy, cx, cy + rowH * 0.4);\n      if (isReflecting) {\n        wetHighlight.addColorStop(0, `${nearestLan.glow}${Math.floor(refAlpha * 255).toString(16).padStart(2, '0')}`);\n        wetHighlight.addColorStop(1, 'rgba(0,0,0,0)');\n      } else {\n        wetHighlight.addColorStop(0, 'rgba(80, 120, 170, 0.12)');\n        wetHighlight.addColorStop(1, 'rgba(0,0,0,0)');\n      }\n      ctx.fillStyle = wetHighlight;\n      ctx.fillRect(cx + 3, cy + 2, stoneW - 6, rowH * 0.45);\n    }\n  }\n  ctx.restore();\n\n  // Puddle reflections\n  for (const lan of lanterns) {\n    const pX = lan.x;\n    const pY = groundY + 40 + ((lan.x * 37) % 110);\n    const pRadX = 65 + (lan.size * 0.8);\n    const pRadY = 16 + (lan.size * 0.2);\n\n    const rip = Math.sin(t * 2.8 + lan.x * 0.05) * 4;\n\n    const pGlow = ctx.createRadialGradient(pX + rip, pY, 2, pX, pY, pRadX);\n    pGlow.addColorStop(0, `${lan.glow}77`);\n    pGlow.addColorStop(0.3, `${lan.glow}33`);\n    pGlow.addColorStop(0.7, `${lan.glow}0e`);\n    pGlow.addColorStop(1, 'rgba(0,0,0,0)');\n\n    ctx.fillStyle = pGlow;\n    ctx.beginPath();\n    ctx.ellipse(pX + rip, pY, pRadX, pRadY, 0, 0, Math.PI * 2);\n    ctx.fill();\n\n    // Vertical light sheen\n    const sheenGrad = ctx.createLinearGradient(0, groundY, 0, H);\n    sheenGrad.addColorStop(0, `${lan.glow}44`);\n    sheenGrad.addColorStop(0.4, `${lan.glow}18`);\n    sheenGrad.addColorStop(1, 'rgba(0,0,0,0)');\n    ctx.fillStyle = sheenGrad;\n    ctx.fillRect(pX - 12 + rip * 0.5, groundY, 24, H - groundY);\n  }\n\n  // Golden stall puddle\n  const cartPuddleGrad = ctx.createRadialGradient(stallX + 130, groundY + 70, 10, stallX + 130, groundY + 70, 160);\n  cartPuddleGrad.addColorStop(0, 'rgba(255, 180, 80, 0.45)');\n  cartPuddleGrad.addColorStop(0.4, 'rgba(230, 120, 40, 0.2)');\n  cartPuddleGrad.addColorStop(0.8, 'rgba(180, 70, 20, 0.05)');\n  cartPuddleGrad.addColorStop(1, 'rgba(0,0,0,0)');\n  ctx.fillStyle = cartPuddleGrad;\n  ctx.beginPath();\n  ctx.ellipse(stallX + 130, groundY + 70, 180, 35, 0, 0, Math.PI * 2);\n  ctx.fill();\n\n  // ==========================================\n  // 9. SUBTLE POST-RAIN VIGNETTE & FOREGROUND LEAVES\n  // ==========================================\n  ctx.fillStyle = '#080c14';\n  ctx.beginPath();\n  ctx.moveTo(0, 0);\n  ctx.quadraticCurveTo(70, 30, 130, 70);\n  ctx.lineTo(130, 75);\n  ctx.quadraticCurveTo(65, 34, 0, 8);\n  ctx.closePath();\n  ctx.fill();\n\n  const leaves = [\n    [50, 28, 0.4], [75, 40, 0.6], [95, 52, 0.3], [115, 64, 0.7], [130, 72, 0.5]\n  ];\n  for (const [lx, ly, la] of leaves) {\n    const leafSway = Math.sin(t * 2.0 + lx) * 0.08 + getBreezeIntensity(lx) * 0.15;\n    ctx.save();\n    ctx.translate(lx, ly);\n    ctx.rotate(la + leafSway);\n    ctx.fillStyle = '#070a12';\n    ctx.beginPath();\n    ctx.ellipse(0, 0, 16, 5, 0.2, 0, Math.PI * 2);\n    ctx.fill();\n\n    const dropAlpha = (Math.sin(t * 3.0 + lx * 2) * 0.5 + 0.5) * 0.7;\n    ctx.fillStyle = `rgba(220, 240, 255, ${dropAlpha})`;\n    ctx.beginPath();\n    ctx.arc(14, 1, 1.3, 0, Math.PI * 2);\n    ctx.fill();\n    ctx.restore();\n  }\n\n  // Soft atmospheric vignette\n  const vignette = ctx.createRadialGradient(W / 2, H / 2, W * 0.32, W / 2, H / 2, W * 0.72);\n  vignette.addColorStop(0, 'rgba(0,0,0,0)');\n  vignette.addColorStop(0.7, 'rgba(4, 7, 14, 0.25)');\n  vignette.addColorStop(1, 'rgba(2, 4, 8, 0.72)');\n  ctx.fillStyle = vignette;\n  ctx.fillRect(0, 0, W, H);\n\n  // ==========================================\n  // 10. CLOSING TITLE: \"NIGHT MARKET\"\n  // ==========================================\n  if (t > 21.5) {\n    const titleFade = smoothstep(22.0, 24.5, t);\n    ctx.save();\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'middle';\n\n    const titleX = W / 2;\n    const titleY = 110;\n\n    // Ambient glow behind title\n    const textGlow = ctx.createRadialGradient(titleX, titleY, 10, titleX, titleY, 220);\n    textGlow.addColorStop(0, `rgba(255, 210, 140, ${titleFade * 0.25})`);\n    textGlow.addColorStop(0.6, `rgba(230, 130, 50, ${titleFade * 0.08})`);\n    textGlow.addColorStop(1, 'rgba(0,0,0,0)');\n    ctx.fillStyle = textGlow;\n    ctx.beginPath();\n    ctx.arc(titleX, titleY, 220, 0, Math.PI * 2);\n    ctx.fill();\n\n    // Main Serif Title\n    ctx.font = '300 32px \"Cinzel\", \"Playfair Display\", \"Georgia\", serif';\n    const text = 'N I G H T   M A R K E T';\n\n    // Drop shadow\n    ctx.fillStyle = `rgba(5, 8, 15, ${titleFade * 0.8})`;\n    ctx.fillText(text, titleX, titleY + 2);\n\n    // Warm ivory text\n    ctx.fillStyle = `rgba(255, 243, 220, ${titleFade * 0.95})`;\n    ctx.fillText(text, titleX, titleY);\n\n    // Subtle line accent\n    ctx.lineWidth = 1.0;\n    const lineGrad = ctx.createLinearGradient(titleX - 90, 0, titleX + 90, 0);\n    lineGrad.addColorStop(0, 'rgba(255, 200, 120, 0)');\n    lineGrad.addColorStop(0.5, `rgba(255, 200, 120, ${titleFade * 0.5})`);\n    lineGrad.addColorStop(1, 'rgba(255, 200, 120, 0)');\n    ctx.strokeStyle = lineGrad;\n    ctx.beginPath();\n    ctx.moveTo(titleX - 90, titleY + 24);\n    ctx.lineTo(titleX + 90, titleY + 24);\n    ctx.stroke();\n\n    ctx.restore();\n  }\n}\n","exportProfile":{"format":"mp4","resolution":"720p","fps":24,"durationSeconds":28},"source":{"prompt":"A 28-second illustrated film: a small night market under strings of paper lanterns after rain. Steam rises from a noodle stall, lantern light shimmers in the wet cobbles, a cat walks along a rooftop, and one by one the lanterns sway as a breeze passes through. Closing title: NIGHT MARKET. Paint it, do not assemble it: graded skies, soft radial glows built from gradients (never a flat translucent disc), layered depth with atmospheric fade, paper-cut shapes with subtle shadows, gentle continuous motion, and one clear moment of change. No text except a small closing title in clear space. Every frame should stand as a composed picture.","provider":"google","model":"gemini-3.7-flash · 1 revision","app":"gallery-reproduction"}},"license":"CC0-1.0"}