{"id":"pasta-night-omni-lyria","title":"Pasta Night — Omni Flash + Lyria + PinePaper","category":"Omni Flash + Lyria + PinePaper","prompt":"A 10-second vertical restaurant promo for NONNA'S: a generated overhead clip of a fork twirling pasta under PinePaper's vector ad overlay — the hook 'Pasta night just got loud', 'Fresh tagliatelle', a $12 price burst with 'Thursdays only', a 'Book a table' button and an end card — scored with an afrobeats track from the PinePaper music library.","designedBy":{"provider":"pinepaper","model":"PinePaper team (concept written by hand; overlay laid out by PinePaper's ad layouts)","inference":{"measured":true,"inputTokens":0,"outputTokens":0,"costUsd":0,"layoutByCode":"the ad overlay layout","basis":"laid out by PinePaper code (the ad overlay layout) from a hand-written brief; no model wrote the program, so no design tokens were bought"}},"render":{"credits":10,"priceUsd":0.15,"format":"mp4","renderMs":20234,"frameCount":300,"bytes":7768871,"seed":7,"width":1080,"height":1920,"fps":30,"durationSeconds":10},"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":"A hybrid: this draw contract is drawn over a generated clip (Gemini Omni Flash (Google), image to video), named in renderRequest.scene.baseVideo. That clip is an asset of PinePaper's own organisation, so the request below is refused for any other account until you swap in your own: upload a clip with POST /v1/assets, or make one with POST /v1/generate, and put its /scene-assets/ ref there."},"plate":{"ref":"/scene-assets/a20c1a6af922056d26a3e47f5e8e0892","fit":"cover","model":"omni-flash-i2v","label":"Gemini Omni Flash (Google), image to video","provider":"google","prompt":"A hand twirls the fork into the pasta and lifts a bite; a basil leaf settles. Slow overhead camera drift. Natural motion.","seconds":10,"priceUsd":0,"tier":"cleared","note":"carries Google's SynthID watermark; the same clip as Pasta, Plated — Omni, reused at no cost","reused":{"from":"recipe-omni-hybrid","jobId":"gen_2ee2e706-c60d-45d0-87d3-ec6406230af1","priceUsd":3.045}},"music":{"trackId":"afrobeats-sunny","title":"Sunny Side","genre":"afrobeats","credit":"Music: Google DeepMind Lyria · SynthID","use":"POST /v1/music-library/afrobeats-sunny/use, then put its ref in renderRequest.audioTracks as { ref, role: \"music\" }"},"template":{"id":"pasta-night","url":"/templates?t=pasta-night"},"renderRequest":{"drawContract":"// PinePaper ad overlay — family bold-hook, 9:16, 10s. Laid out by PinePaper's ad layouts; every part is an entity, editable by id.\nconst meta = { duration: 10, fps: 30, size: [1080, 1920], seed: 7, poster: 6.35 };\nconst AD = { W: 1080, H: 1920, f: { display: \"Bebas Neue\", dw: \"400\", body: \"Poppins\", bw: \"700\" }, c: { primary: \"#f97316\", primaryInk: \"#111114\", accent: \"#ffd43b\", accentInk: \"#111114\", ground: \"#1c1917\", groundInk: \"#ffffff\", groundSoft: \"#bfbfbe\", hookInk: \"#ffffff\", edge: \"#0b0b0f\", scrim: \"rgba(8,9,12,0.66)\", chipBg: \"#ffffff\", chipInk: \"#14161c\", capBg: \"#ffffff\", capInk: \"#111114\", cardBg: \"#ffffff\", cardInk: \"#14161c\", cardSoft: \"#4a5060\", track: \"#e3e6ed\", accentOnCard: \"#ffd43b\", star: \"#f5a623\", shadow: \"rgba(0,0,0,0.28)\", dim: \"rgba(12,12,14,0.5)\", divider: \"#ffffff\", bugPlate: \"rgba(255,255,255,0.94)\", logoPlate: \"#ffffff\" } };\nfunction clamp01(x) { return x < 0 ? 0 : x > 1 ? 1 : x; }\nfunction easeOutCubic(x) { x = clamp01(x); return 1 - Math.pow(1 - x, 3); }\nfunction easeInCubic(x) { x = clamp01(x); return x * x * x; }\nfunction easeOutBack(x) { x = clamp01(x); var c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(x - 1, 3) + c1 * Math.pow(x - 1, 2); }\nfunction inOut(u, len, a, b) { var i = clamp01(u / a); var o = b > 0 ? clamp01((len - u) / b) : 1; return { i: i, o: o, a: Math.min(easeOutCubic(i), easeOutCubic(o)) }; }\nfunction fontOf(fam, weight, px) { return weight + \" \" + Math.round(px) + 'px \"' + fam + '\", sans-serif'; }\n\nfunction rr(ctx, x, y, w, h, r) {\n  r = Math.max(0, Math.min(r, w / 2, h / 2));\n  ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);\n  ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h);\n  ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();\n}\n\nfunction wrapLines(ctx, text, maxW) {\n  var words = String(text).split(/\\s+/).filter(function (w) { return w.length > 0; });\n  var lines = [], cur = \"\";\n  for (var i = 0; i < words.length; i++) {\n    var w = words[i];\n    if (ctx.measureText(w).width > maxW) {\n      if (cur) { lines.push(cur); cur = \"\"; }\n      var piece = \"\";\n      for (var k = 0; k < w.length; k++) { var nx = piece + w[k]; if (ctx.measureText(nx).width > maxW && piece) { lines.push(piece); piece = w[k]; } else piece = nx; }\n      cur = piece; continue;\n    }\n    var next = cur ? cur + \" \" + w : w;\n    if (ctx.measureText(next).width <= maxW || !cur) cur = next; else { lines.push(cur); cur = w; }\n  }\n  if (cur) lines.push(cur);\n  return lines;\n}\n\nvar FIT = new Map();\n/** The largest size, from maxPx down to minPx, at which the text wraps into maxLines lines inside maxW \\u00D7 maxH. */\nfunction fit(ctx, text, fam, weight, maxW, maxH, maxPx, minPx, maxLines, lh) {\n  var key = [text, fam, weight, Math.round(maxW), Math.round(maxH), Math.round(maxPx), Math.round(minPx), maxLines, lh].join(\"|\");\n  var hit = FIT.get(key); if (hit) return hit;\n  var px = maxPx, lines = [], ok = false;\n  for (var n = 0; n < 60; n++) {\n    ctx.font = fontOf(fam, weight, px);\n    lines = wrapLines(ctx, text, maxW);\n    if (lines.length <= maxLines && lines.length * px * lh <= maxH) { ok = true; break; }\n    if (px <= minPx) break;\n    px = Math.max(minPx, px * 0.94);\n  }\n  if (!ok) {\n    ctx.font = fontOf(fam, weight, px);\n    var rows = Math.max(1, Math.min(maxLines, Math.floor(maxH / (px * lh))));\n    if (lines.length > rows) {\n      lines = lines.slice(0, rows);\n      var last = lines[rows - 1];\n      while (last.length > 1 && ctx.measureText(last + \"\\u2026\").width > maxW) last = last.slice(0, -1);\n      lines[rows - 1] = last.replace(/\\s+$/, \"\") + \"\\u2026\";\n    }\n  }\n  var out = { px: px, lines: lines, cut: !ok };\n  FIT.set(key, out);\n  return out;\n}\n\n/** Outline then fill: the outline is the separation from any footage (contract-screen's rule: a stroke of the same text). */\nfunction outlined(ctx, s, x, y, ink, edge, lw) {\n  ctx.lineJoin = \"round\"; ctx.miterLimit = 2;\n  if (lw > 0) { ctx.strokeStyle = edge; ctx.lineWidth = lw; ctx.strokeText(s, x, y); }\n  ctx.fillStyle = ink; ctx.fillText(s, x, y);\n}\n\nfunction scrimBand(ctx, x, y, w, h, color, alpha) {\n  var g = ctx.createLinearGradient(0, y, 0, y + h);\n  g.addColorStop(0, \"rgba(0,0,0,0)\");\n  g.addColorStop(0.22, color); g.addColorStop(0.78, color);\n  g.addColorStop(1, \"rgba(0,0,0,0)\");\n  ctx.save(); ctx.globalAlpha *= alpha; ctx.fillStyle = g; ctx.fillRect(x, y, w, h); ctx.restore();\n}\n\nfunction coverImage(ctx, img, x, y, w, h, zoom, fx, fy) {\n  var iw = img.width || 1, ih = img.height || 1;\n  var s = Math.max(w / iw, h / ih) * zoom;\n  var dw = iw * s, dh = ih * s;\n  ctx.save(); ctx.beginPath(); ctx.rect(x, y, w, h); ctx.clip();\n  ctx.drawImage(img, x + (w - dw) * fx, y + (h - dh) * fy, dw, dh);\n  ctx.restore();\n}\n\nfunction containRect(img, x, y, w, h, align) {\n  var iw = img.width || 1, ih = img.height || 1;\n  var s = Math.min(w / iw, h / ih), dw = iw * s, dh = ih * s;\n  return { x: align === \"left\" ? x : x + (w - dw) / 2, y: y + (h - dh) / 2, w: dw, h: dh };\n}\nfunction containImage(ctx, img, x, y, w, h, align) {\n  var r = containRect(img, x, y, w, h, align);\n  ctx.drawImage(img, r.x, r.y, r.w, r.h);\n  return r;\n}\n\nfunction arrow(ctx, x, y, s, color) {\n  ctx.save(); ctx.strokeStyle = color; ctx.lineWidth = Math.max(3, s * 0.16); ctx.lineCap = \"round\"; ctx.lineJoin = \"round\";\n  ctx.beginPath(); ctx.moveTo(x - s * 0.45, y); ctx.lineTo(x + s * 0.4, y); ctx.moveTo(x + s * 0.05, y - s * 0.38); ctx.lineTo(x + s * 0.42, y); ctx.lineTo(x + s * 0.05, y + s * 0.38); ctx.stroke(); ctx.restore();\n}\n\nfunction check(ctx, cx, cy, r, bg, ink) {\n  ctx.fillStyle = bg; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill();\n  ctx.strokeStyle = ink; ctx.lineWidth = Math.max(2.5, r * 0.22); ctx.lineCap = \"round\"; ctx.lineJoin = \"round\";\n  ctx.beginPath(); ctx.moveTo(cx - r * 0.45, cy + r * 0.02); ctx.lineTo(cx - r * 0.1, cy + r * 0.36); ctx.lineTo(cx + r * 0.48, cy - r * 0.34); ctx.stroke();\n}\n\nfunction starShape(ctx, cx, cy, ro, ri, n, rot) {\n  ctx.beginPath();\n  for (var i = 0; i < n * 2; i++) { var r = i % 2 ? ri : ro, a = rot + (i * Math.PI) / n - Math.PI / 2; var px = cx + Math.cos(a) * r, py = cy + Math.sin(a) * r; if (i) ctx.lineTo(px, py); else ctx.moveTo(px, py); }\n  ctx.closePath();\n}\n\n// \\u2500\\u2500 the parts \\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\\u2500\n\nvar R = {};\n\n/** The plate itself when it is a still: a slow push-in so a photo reads as footage. A video plate is under the canvas. */\nR.plate = function (ctx, u, len, p) {\n  var img = image(p.ref);\n  var z = 1.04 + 0.08 * clamp01(u / Math.max(0.1, len));\n  coverImage(ctx, img, 0, 0, AD.W, AD.H, z, p.fx, p.fy);\n};\n\n/** A soft dark band where text sits, so footage under it never decides whether the words read. */\nR.scrim = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.35, 0.35);\n  scrimBand(ctx, 0, p.y, AD.W, p.h, AD.c.scrim, e.a * p.alpha);\n};\n\n/** THE HOOK: big kinetic type, every word on screen by 1.0 s, one word in the accent with a marker behind it. */\nR.hook = function (ctx, u, len, p) {\n  var text = p.upper ? String(p.text).toUpperCase() : String(p.text);\n  var f = fit(ctx, text, AD.f.display, AD.f.dw, p.w, p.h, p.size, p.min, p.lines, 1.04);\n  ctx.font = fontOf(AD.f.display, AD.f.dw, f.px);\n  ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  var lh = f.px * 1.04, total = f.lines.length * lh;\n  var y0 = p.y + (p.valign === \"top\" ? 0 : (p.h - total) / 2) + lh / 2;\n  var all = [];\n  for (var li = 0; li < f.lines.length; li++) {\n    var words = f.lines[li].split(\" \"), lw = ctx.measureText(f.lines[li]).width, sp = ctx.measureText(\" \").width;\n    var x = p.align === \"center\" ? p.x + (p.w - lw) / 2 : p.x;\n    for (var wi = 0; wi < words.length; wi++) { var ww = ctx.measureText(words[wi]).width; all.push({ s: words[wi], x: x, y: y0 + li * lh, w: ww }); x += ww + sp; }\n  }\n  var n = all.length, stagger = Math.min(0.11, 0.85 / Math.max(1, n));\n  var out = len > 0 ? clamp01((len - u) / 0.3) : 1;\n  var lift = (1 - easeOutCubic(out)) * f.px * 0.5;\n  var em = String(p.em || \"\").toUpperCase();\n  for (var k = 0; k < n; k++) {\n    var w = all[k], local = (u - k * stagger) / 0.26;\n    if (local <= 0) continue;\n    var cxw = w.x + w.w / 2, s = Math.min(1.2 - 0.2 * easeOutBack(local), 2 * (cxw - p.sx0) / w.w, 2 * (p.sx1 - cxw) / w.w), a = clamp01(local * 1.6) * easeOutCubic(out);\n    ctx.save(); ctx.globalAlpha *= a;\n    ctx.translate(w.x + w.w / 2, w.y - lift); ctx.scale(s, s);\n    var hit = em && w.s.toUpperCase().replace(/[^\\p{L}\\p{N}]/gu, \"\") === em.replace(/[^\\p{L}\\p{N}]/gu, \"\");\n    if (hit) { ctx.fillStyle = AD.c.accent; rr(ctx, -w.w / 2 - f.px * 0.12, -f.px * 0.52, w.w + f.px * 0.24, f.px * 1.0, f.px * 0.14); ctx.fill(); }\n    outlined(ctx, w.s, -w.w / 2, 0, hit ? AD.c.accentInk : AD.c.hookInk, AD.c.edge, hit ? 0 : Math.max(4, f.px * 0.1));\n    ctx.restore();\n  }\n};\n\n/**\n * THE MESSAGE: the headline at display size, held through the middle of the ad. style: \"outline\" (white, outlined, over\n * a scrim), \"ink\" (on a band of the brand colour the layout lays under it), \"box\" (each line on a white caption box).\n */\nR.message = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.4, 0.3);\n  var body = p.face === \"body\", fam = body ? AD.f.body : AD.f.display, wt = body ? AD.f.bw : AD.f.dw, lh = body ? 1.14 : 1.04;\n  var text = p.upper ? String(p.text).toUpperCase() : String(p.text);\n  var boxPad = p.style === \"box\" ? p.padx : 0;\n  var f = fit(ctx, text, fam, wt, p.w - boxPad * 2 - p.size * 0.6, p.h - p.pady * 2, p.size, p.min, p.lines, lh);\n  ctx.font = fontOf(fam, wt, f.px);\n  ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  var L = f.px * lh, total = f.lines.length * L;\n  var y0 = p.y + (p.h - total) / 2 + L / 2;\n  ctx.save(); ctx.globalAlpha *= e.a; ctx.translate(0, (1 - easeOutCubic(e.i)) * f.px * 0.3);\n  for (var i = 0; i < f.lines.length; i++) {\n    var s = f.lines[i], lw = ctx.measureText(s).width, y = y0 + i * L;\n    var x = p.align === \"center\" ? p.x + (p.w - lw) / 2 : p.x + boxPad;\n    if (p.style === \"box\") {\n      ctx.fillStyle = AD.c.capBg; rr(ctx, x - boxPad, y - L / 2 - f.px * 0.06, lw + boxPad * 2, L + f.px * 0.12, f.px * 0.24); ctx.fill();\n      ctx.fillStyle = AD.c.capInk; ctx.fillText(s, x, y + f.px * 0.03);\n    } else if (p.style === \"outline\") outlined(ctx, s, x, y, AD.c.hookInk, AD.c.edge, Math.max(4, f.px * 0.09));\n    else { ctx.fillStyle = p.ink; ctx.fillText(s, x, y); }\n  }\n  ctx.restore();\n};\n\n/** A GROUND: a full-bleed band of colour under the message or the offer \\u2014 across the top, across the middle, or a floor. */\nR.band = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.35, 0.3), k = easeOutCubic(e.i), t = Math.max(4, AD.H * 0.006);\n  ctx.save(); ctx.globalAlpha *= easeOutCubic(e.o);\n  ctx.fillStyle = p.color;\n  if (p.edge === \"top\") { ctx.fillRect(0, 0, AD.W, p.size * k); ctx.fillStyle = p.line; ctx.fillRect(0, p.size * k - t, AD.W, t); }\n  else if (p.edge === \"bottom\") { ctx.fillRect(0, AD.H - p.size * k, AD.W, p.size * k + 1); ctx.fillStyle = p.line; ctx.fillRect(0, AD.H - p.size * k, AD.W, t); }\n  else { var hh = p.size * k; ctx.fillRect(0, p.at - hh / 2, AD.W, hh); ctx.fillStyle = p.line; ctx.fillRect(0, p.at - hh / 2, AD.W, t); ctx.fillRect(0, p.at + hh / 2 - t, AD.W, t); }\n  ctx.restore();\n};\n\n/** A line of supporting copy on a solid plate in the brand colour (the headline under a hook, an offer line). */\nR.line = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.4, 0.3);\n  var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.w - p.pad * 2, p.h - p.pad * 2, p.size, p.min, p.lines, 1.18);\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px);\n  var tw = 0; for (var i = 0; i < f.lines.length; i++) tw = Math.max(tw, ctx.measureText(f.lines[i]).width);\n  var bw = tw + p.pad * 2, bh = f.lines.length * f.px * 1.18 + p.pad * 1.6;\n  var bx = p.align === \"center\" ? p.x + (p.w - bw) / 2 : p.x, by = p.y;\n  ctx.save(); ctx.globalAlpha *= e.a; ctx.translate(0, (1 - easeOutCubic(e.i)) * 30);\n  ctx.fillStyle = p.bg; rr(ctx, bx, by, bw, bh, Math.min(p.radius, bh / 2)); ctx.fill();\n  ctx.fillStyle = p.ink; ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  for (var j = 0; j < f.lines.length; j++) ctx.fillText(f.lines[j], bx + p.pad, by + p.pad * 0.8 + f.px * 1.18 * (j + 0.5));\n  ctx.restore();\n};\n\n/** A benefit chip: a check in the accent, the words on a light pill; slides in from its side. */\nR.chip = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.35, 0.25);\n  var r = p.h / 2, icon = p.h * 0.34;\n  var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.w - p.h - r, p.h * 0.9, p.size, p.min, 1, 1.1);\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px);\n  var tw = ctx.measureText(f.lines[0] || \"\").width, bw = Math.min(p.w, tw + p.h + r * 0.9);\n  var bx = p.align === \"right\" ? p.x + p.w - bw : p.align === \"center\" ? p.x + (p.w - bw) / 2 : p.x;\n  var dx = (1 - easeOutCubic(e.i)) * (p.align === \"right\" ? 120 : -120);\n  ctx.save(); ctx.globalAlpha *= e.a; ctx.translate(dx, 0);\n  ctx.fillStyle = AD.c.chipBg; rr(ctx, bx, p.y, bw, p.h, r); ctx.fill();\n  check(ctx, bx + r, p.y + r, icon, AD.c.accent, AD.c.accentInk);\n  ctx.fillStyle = AD.c.chipInk; ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  ctx.fillText(f.lines[0] || \"\", bx + p.h * 0.92, p.y + p.h / 2 + f.px * 0.04);\n  ctx.restore();\n};\n\n/** THE PRICE / OFFER BURST: a turning star in the accent, popped in, the price at display size, the offer under it. */\nR.burst = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.45, 0.25), s = easeOutBack(clamp01(u / 0.45)) * easeOutCubic(e.o);\n  if (s <= 0.001) return;\n  ctx.save(); ctx.translate(p.x, p.y); ctx.scale(s, s); ctx.rotate(-0.12 + 0.02 * Math.sin(u * 2.4));\n  ctx.save(); ctx.rotate(u * 0.25); ctx.fillStyle = AD.c.accent; starShape(ctx, 0, 0, p.r, p.r * 0.84, 18, 0); ctx.fill();\n  ctx.strokeStyle = AD.c.accentInk; ctx.globalAlpha *= 0.35; ctx.lineWidth = Math.max(2, p.r * 0.02); starShape(ctx, 0, 0, p.r * 0.9, p.r * 0.76, 18, 0); ctx.stroke(); ctx.restore();\n  ctx.fillStyle = AD.c.accentInk; ctx.textAlign = \"center\"; ctx.textBaseline = \"middle\";\n  var inner = p.r * 1.3, top = p.sub ? -p.r * 0.2 : 0;\n  var f = fit(ctx, String(p.text), AD.f.display, AD.f.dw, inner, p.r * (p.sub ? 0.62 : 0.9), p.r * 0.62, p.r * 0.22, 2, 1.0);\n  ctx.font = fontOf(AD.f.display, AD.f.dw, f.px);\n  for (var i = 0; i < f.lines.length; i++) ctx.fillText(f.lines[i], 0, top + (i - (f.lines.length - 1) / 2) * f.px);\n  if (p.sub) {\n    var g = fit(ctx, String(p.sub), AD.f.body, AD.f.bw, inner * 0.92, p.r * 0.36, p.r * 0.2, p.r * 0.1, 2, 1.1);\n    ctx.font = fontOf(AD.f.body, AD.f.bw, g.px);\n    var y0 = top + (f.lines.length / 2) * f.px + g.px * 0.75;\n    for (var j = 0; j < g.lines.length; j++) ctx.fillText(g.lines[j], 0, y0 + j * g.px * 1.1);\n  }\n  ctx.restore();\n};\n\n/** BURNED-IN CAPTION: one phrase at a time in a white box, the word being said lit in the brand colour. */\nR.caption = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.12, 0.12);\n  var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.w - p.pad * 2, p.h, p.size, p.min, p.lines, 1.22);\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px);\n  var words = [], lh = f.px * 1.22, sp = ctx.measureText(\" \").width;\n  for (var li = 0; li < f.lines.length; li++) {\n    var lw = ctx.measureText(f.lines[li]).width, x = p.x + (p.w - lw) / 2, parts = f.lines[li].split(\" \");\n    for (var wi = 0; wi < parts.length; wi++) { var ww = ctx.measureText(parts[wi]).width; words.push({ s: parts[wi], x: x, y: p.y + li * lh, w: ww }); x += ww + sp; }\n  }\n  var lit = Math.min(words.length - 1, Math.floor(clamp01(u / Math.max(0.2, len * 0.92)) * words.length));\n  var pop = easeOutBack(clamp01(u / 0.18));\n  ctx.save(); ctx.globalAlpha *= e.a;\n  ctx.translate(p.x + p.w / 2, p.y + (f.lines.length * lh) / 2); ctx.scale(0.9 + 0.1 * pop, 0.9 + 0.1 * pop); ctx.translate(-(p.x + p.w / 2), -(p.y + (f.lines.length * lh) / 2));\n  ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  for (var l2 = 0; l2 < f.lines.length; l2++) {\n    var w2 = ctx.measureText(f.lines[l2]).width;\n    ctx.fillStyle = AD.c.capBg; rr(ctx, p.x + (p.w - w2) / 2 - p.pad, p.y + l2 * lh - f.px * 0.08, w2 + p.pad * 2, lh + f.px * 0.1, f.px * 0.28); ctx.fill();\n  }\n  for (var k = 0; k < words.length; k++) {\n    var w = words[k];\n    if (k === lit) { ctx.fillStyle = AD.c.primary; rr(ctx, w.x - f.px * 0.1, w.y + f.px * 0.02, w.w + f.px * 0.2, lh - f.px * 0.04, f.px * 0.2); ctx.fill(); ctx.fillStyle = AD.c.primaryInk; }\n    else ctx.fillStyle = AD.c.capInk;\n    ctx.fillText(w.s, w.x, w.y + lh / 2);\n  }\n  ctx.restore();\n};\n\n/** THE CTA BUTTON: pops in, then breathes; a light sweep crosses it once a second. */\nR.cta = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.4, 0.2), pop = easeOutBack(clamp01(u / 0.42));\n  if (pop <= 0.001) return;\n  var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.w - p.h * 1.4, p.h * 0.62, p.h * 0.42, p.h * 0.22, 1, 1.1);\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px);\n  var tw = ctx.measureText(f.lines[0] || \"\").width, bw = Math.min(p.w, tw + p.h * 1.5), bx = p.x + (p.w - bw) / 2;\n  var breathe = 1 + 0.035 * Math.sin(Math.max(0, u - 0.45) * Math.PI * 2 * 0.9);\n  ctx.save(); ctx.globalAlpha *= e.a;\n  ctx.translate(bx + bw / 2, p.y + p.h / 2); ctx.scale(pop * breathe, pop * breathe); ctx.translate(-(bx + bw / 2), -(p.y + p.h / 2));\n  ctx.fillStyle = AD.c.shadow; rr(ctx, bx, p.y + p.h * 0.1, bw, p.h, p.h / 2); ctx.fill();\n  ctx.fillStyle = AD.c.primary; rr(ctx, bx, p.y, bw, p.h, p.h / 2); ctx.fill();\n  var sweep = ((u * 0.9) % 1.6) / 1.6;\n  ctx.save(); rr(ctx, bx, p.y, bw, p.h, p.h / 2); ctx.clip();\n  var sx = bx - p.h + sweep * (bw + p.h * 2);\n  var g = ctx.createLinearGradient(sx - p.h * 0.6, 0, sx + p.h * 0.6, 0);\n  g.addColorStop(0, \"rgba(255,255,255,0)\"); g.addColorStop(0.5, \"rgba(255,255,255,0.28)\"); g.addColorStop(1, \"rgba(255,255,255,0)\");\n  ctx.fillStyle = g; ctx.fillRect(bx, p.y, bw, p.h); ctx.restore();\n  ctx.fillStyle = AD.c.primaryInk; ctx.textBaseline = \"middle\"; ctx.textAlign = \"left\";\n  var tx = bx + (bw - tw - p.h * 0.55) / 2;\n  ctx.fillText(f.lines[0] || \"\", tx, p.y + p.h / 2 + f.px * 0.04);\n  arrow(ctx, tx + tw + p.h * 0.36 + Math.sin(u * 5) * p.h * 0.04, p.y + p.h / 2, p.h * 0.34, AD.c.primaryInk);\n  ctx.restore();\n};\n\n/** THE END CARD: the brand ground wipes up; logo (or the name set in the display face), a line, the CTA. */\nR.endcard = function (ctx, u, len, p) {\n  var wipe = easeOutCubic(clamp01(u / 0.5));\n  ctx.save();\n  ctx.fillStyle = AD.c.ground; ctx.fillRect(0, AD.H * (1 - wipe), AD.W, AD.H * wipe + 1);\n  ctx.fillStyle = AD.c.primary; ctx.fillRect(0, AD.H * (1 - wipe) - AD.H * 0.012, AD.W, AD.H * 0.012);\n  var c = clamp01((u - 0.3) / 0.45), ce = easeOutCubic(c);\n  ctx.globalAlpha *= ce; ctx.translate(0, (1 - ce) * 40);\n  if (p.logo) {\n    var img = image(p.logo);\n    // the plate hugs the logo as drawn (contained in its box), not the box\n    var lr = containRect(img, p.lx, p.ly, p.lw, p.lh);\n    ctx.save();\n    if (AD.c.logoPlate !== \"none\") { ctx.fillStyle = AD.c.logoPlate; rr(ctx, lr.x - p.lpad, lr.y - p.lpad, lr.w + p.lpad * 2, lr.h + p.lpad * 2, p.lpad); ctx.fill(); }\n    ctx.drawImage(img, lr.x, lr.y, lr.w, lr.h); ctx.restore();\n  } else if (p.name) {\n    var fn = fit(ctx, String(p.name), AD.f.display, AD.f.dw, p.lw, p.lh, p.lh * 0.8, p.lh * 0.3, 2, 1.0);\n    ctx.font = fontOf(AD.f.display, AD.f.dw, fn.px); ctx.fillStyle = AD.c.groundInk; ctx.textAlign = \"center\"; ctx.textBaseline = \"middle\";\n    for (var i = 0; i < fn.lines.length; i++) ctx.fillText(fn.lines[i], p.lx + p.lw / 2, p.ly + p.lh / 2 + (i - (fn.lines.length - 1) / 2) * fn.px);\n  }\n  if (p.text) {\n    var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.tw, p.th, p.tsize, p.tsize * 0.5, 3, 1.18);\n    ctx.font = fontOf(AD.f.body, AD.f.bw, f.px); ctx.fillStyle = AD.c.groundInk; ctx.textAlign = \"center\"; ctx.textBaseline = \"middle\";\n    for (var j = 0; j < f.lines.length; j++) ctx.fillText(f.lines[j], p.tx + p.tw / 2, p.ty + p.th / 2 + (j - (f.lines.length - 1) / 2) * f.px * 1.18);\n  }\n  if (p.url) {\n    ctx.font = fontOf(AD.f.body, AD.f.bw, p.usize); ctx.fillStyle = AD.c.groundSoft; ctx.textAlign = \"center\"; ctx.textBaseline = \"middle\";\n    var uf = fit(ctx, String(p.url), AD.f.body, AD.f.bw, p.tw, p.usize * 1.3, p.usize, p.usize * 0.6, 1, 1.1);\n    ctx.font = fontOf(AD.f.body, AD.f.bw, uf.px); ctx.fillText(uf.lines[0] || \"\", p.tx + p.tw / 2, p.uy);\n  }\n  ctx.restore();\n};\n\n/** A logo bug in the corner while the ad runs, on a plate so any logo reads over any footage. */\nR.bug = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.5, 0.3);\n  ctx.save(); ctx.globalAlpha *= e.a * 0.96;\n  if (p.logo) {\n    var bimg = image(p.logo), pad = p.h * 0.16;\n    var br = containRect(bimg, p.x + pad, p.y + p.h * 0.14, p.w - pad * 2, p.h * 0.72, \"left\");\n    if (AD.c.bugPlate !== \"none\") { ctx.fillStyle = AD.c.bugPlate; rr(ctx, p.x, p.y, br.w + pad * 2, p.h, p.h * 0.24); ctx.fill(); }\n    ctx.drawImage(bimg, br.x, br.y, br.w, br.h);\n  } else if (p.name) {\n    var f = fit(ctx, String(p.name), AD.f.body, AD.f.bw, p.w - p.h * 0.5, p.h * 0.6, p.h * 0.46, p.h * 0.24, 1, 1.0);\n    ctx.font = fontOf(AD.f.body, AD.f.bw, f.px); var tw = ctx.measureText(f.lines[0] || \"\").width;\n    ctx.fillStyle = AD.c.chipBg; rr(ctx, p.x, p.y, tw + p.h * 0.5, p.h, p.h / 2); ctx.fill();\n    ctx.fillStyle = AD.c.chipInk; ctx.textAlign = \"left\"; ctx.textBaseline = \"middle\"; ctx.fillText(f.lines[0] || \"\", p.x + p.h * 0.25, p.y + p.h / 2 + f.px * 0.04);\n  }\n  ctx.restore();\n};\n\n/** A countdown that drains: a bar in the accent across the offer's window, with the deadline the brief gave (never a made-up clock). */\nR.timer = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.4, 0.3), left = 1 - clamp01(u / Math.max(0.1, len));\n  ctx.save(); ctx.globalAlpha *= e.a;\n  ctx.fillStyle = AD.c.cardBg; rr(ctx, p.x, p.y, p.w, p.h, p.h * 0.3); ctx.fill();\n  var pad = p.h * 0.18, bh = p.h * 0.22, by = p.y + p.h - pad - bh;\n  ctx.fillStyle = AD.c.track; rr(ctx, p.x + pad, by, p.w - pad * 2, bh, bh / 2); ctx.fill();\n  ctx.fillStyle = AD.c.accentOnCard; rr(ctx, p.x + pad, by, Math.max(bh, (p.w - pad * 2) * left), bh, bh / 2); ctx.fill();\n  var f = fit(ctx, String(p.text), AD.f.body, AD.f.bw, p.w - pad * 2, p.h - bh - pad * 2.4, p.h * 0.36, p.h * 0.18, 1, 1.1);\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px); ctx.fillStyle = AD.c.cardInk; ctx.textAlign = \"left\"; ctx.textBaseline = \"middle\";\n  var blink = 0.75 + 0.25 * Math.cos(u * Math.PI * 2);\n  ctx.fillStyle = AD.c.accentOnCard; ctx.beginPath(); ctx.arc(p.x + pad + f.px * 0.32, p.y + pad + f.px * 0.55, f.px * 0.24 * blink, 0, Math.PI * 2); ctx.fill();\n  ctx.fillStyle = AD.c.cardInk; ctx.fillText(f.lines[0] || \"\", p.x + pad + f.px * 0.8, p.y + pad + f.px * 0.58);\n  ctx.restore();\n};\n\n/** A TESTIMONIAL: a card with the quote, the stars only when a rating was given, and who said it. */\nR.quote = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.45, 0.3);\n  var pad = p.pad, starH = p.rating > 0 ? p.size * 0.9 : 0, who = p.author ? p.size * 0.95 : 0;\n  var f = fit(ctx, \"\\u201C\" + String(p.text) + \"\\u201D\", AD.f.body, AD.f.bw, p.w - pad * 2, p.h - pad * 2 - starH * 1.4 - who * 1.6, p.size, p.min, p.lines, 1.25);\n  var bh = pad * 2 + starH * (starH ? 1.4 : 0) + f.lines.length * f.px * 1.25 + (who ? who * 1.6 : 0);\n  var by = p.y + (p.h - bh) / 2;\n  ctx.save(); ctx.globalAlpha *= e.a; ctx.translate(0, (1 - easeOutCubic(e.i)) * 50);\n  ctx.fillStyle = AD.c.shadow; rr(ctx, p.x, by + pad * 0.25, p.w, bh, pad * 0.8); ctx.fill();\n  ctx.fillStyle = AD.c.cardBg; rr(ctx, p.x, by, p.w, bh, pad * 0.8); ctx.fill();\n  ctx.fillStyle = AD.c.primary; ctx.fillRect(p.x + pad, by, p.w * 0.18, Math.max(4, pad * 0.18));\n  var y = by + pad;\n  if (starH) { for (var s = 0; s < 5; s++) { var lit = s < p.rating; ctx.fillStyle = lit ? AD.c.star : AD.c.track; starShape(ctx, p.x + pad + starH * 0.55 + s * starH * 1.15, y + starH * 0.5, starH * 0.5, starH * 0.21, 5, 0); ctx.fill(); } y += starH * 1.4; }\n  ctx.font = fontOf(AD.f.body, AD.f.bw, f.px); ctx.fillStyle = AD.c.cardInk; ctx.textAlign = \"left\"; ctx.textBaseline = \"middle\";\n  var shown = Math.ceil(clamp01((u - 0.3) / Math.max(0.6, f.lines.length * 0.35)) * f.lines.length);\n  for (var i = 0; i < f.lines.length; i++) { ctx.save(); ctx.globalAlpha *= i < shown ? 1 : 0.0; ctx.fillText(f.lines[i], p.x + pad, y + f.px * 1.25 * (i + 0.5)); ctx.restore(); }\n  y += f.lines.length * f.px * 1.25;\n  if (who) { ctx.font = fontOf(AD.f.body, AD.f.bw, who * 0.82); ctx.fillStyle = AD.c.cardSoft; ctx.fillText(\"\\u2014 \" + String(p.author), p.x + pad, y + who * 1.05); }\n  ctx.restore();\n};\n\n/** BEFORE / AFTER over one plate: the \"before\" side is dimmed and the divider sweeps; each side is labelled. */\nR.split = function (ctx, u, len, p) {\n  var e = inOut(u, len, 0.4, 0.3), sweep = easeOutCubic(clamp01(u / 1.1));\n  var horizontal = p.dir === \"h\";\n  var at = horizontal ? AD.W * (1 - 0.5 * sweep) : AD.H * (1 - 0.5 * sweep);\n  ctx.save(); ctx.globalAlpha *= e.a;\n  ctx.fillStyle = AD.c.dim;\n  if (horizontal) ctx.fillRect(0, 0, at, AD.H); else ctx.fillRect(0, 0, AD.W, at);\n  ctx.fillStyle = AD.c.divider;\n  if (horizontal) ctx.fillRect(at - 4, 0, 8, AD.H); else ctx.fillRect(0, at - 4, AD.W, 8);\n  ctx.beginPath(); if (horizontal) ctx.arc(at, AD.H / 2, 34, 0, Math.PI * 2); else ctx.arc(AD.W / 2, at, 34, 0, Math.PI * 2); ctx.fill();\n  ctx.restore();\n};\nconst entities = [\n  { id: \"logo_bug\", kind: \"overlay\", from: 0.2, to: 8.35,\n    params: { logo: \"\", name: \"NONNA'S\", x: 72, y: 250, w: 250, h: 78 },\n    draw(ctx, t, p, rng) { R.bug(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"hook_scrim\", kind: \"overlay\", from: 0, to: 4.2,\n    params: { y: 267, h: 873, alpha: 1 },\n    draw(ctx, t, p, rng) { R.scrim(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"hook\", kind: \"title\", from: 0, to: 4.2,\n    params: { text: \"Pasta night just got loud\", em: \"loud\", upper: true, x: 114, y: 371, w: 756, h: 665.5, size: 172.8, min: 86.4, lines: 4, align: \"center\", valign: \"middle\", sx0: 72, sx1: 912 },\n    draw(ctx, t, p, rng) { R.hook(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"message_scrim\", kind: \"overlay\", from: 4.1, to: 8,\n    params: { y: 333, h: 532, alpha: 1 },\n    draw(ctx, t, p, rng) { R.scrim(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"message\", kind: \"title\", from: 4.1, to: 8,\n    params: { text: \"Fresh tagliatelle\", upper: true, face: \"display\", style: \"outline\", x: 72, y: 435, w: 840, h: 328, size: 145.8, min: 101.5, lines: 4, align: \"center\", padx: 0, pady: 12, ink: \"\", bg: \"\" },\n    draw(ctx, t, p, rng) { R.message(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"price_burst\", kind: \"overlay\", from: 4.1, to: 8,\n    params: { text: \"$12\", sub: \"Thursdays only\", x: 714.9, y: 1032.9, r: 176 },\n    draw(ctx, t, p, rng) { R.burst(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"end_card\", kind: \"overlay\", from: 8, to: 10,\n    params: { logo: \"\", name: \"NONNA'S\", lx: 231.6, ly: 467.8, lw: 520.8, lh: 205.7, lpad: 27, text: \"Fresh tagliatelle\", tx: 72, ty: 727.5, tw: 840, th: 235, tsize: 65.3, url: \"\", usize: 40.5, uy: 1021.3 },\n    draw(ctx, t, p, rng) { R.endcard(ctx, t - this.from, this.to - this.from, p); } },\n  { id: \"cta\", kind: \"overlay\", from: 5.8, to: 10,\n    params: { text: \"Book a table\", x: 139.2, y: 1302.8, w: 705.6, h: 131 },\n    draw(ctx, t, p, rng) { R.cta(ctx, t - this.from, this.to - this.from, p); } },\n];\nfunction draw(ctx, t, params, rng) {\n  for (const e of entities) {\n    if (t < e.from || t > e.to) continue;\n    ctx.save(); e.draw(ctx, t, e.params, rng.for(e.id)); ctx.restore();\n  }\n}\n","scene":{"baseVideo":{"ref":"/scene-assets/a20c1a6af922056d26a3e47f5e8e0892","loop":"pingpong","fit":"cover"}},"exportProfile":{"format":"mp4","resolution":"720p","fps":30,"durationSeconds":10},"source":{"prompt":"A 10-second vertical restaurant promo for NONNA'S: a generated overhead clip of a fork twirling pasta under PinePaper's vector ad overlay — the hook 'Pasta night just got loud', 'Fresh tagliatelle', a $12 price burst with 'Thursdays only', a 'Book a table' button and an end card — scored with an afrobeats track from the PinePaper music library.","provider":"pinepaper","model":"PinePaper team (concept written by hand; overlay laid out by PinePaper's ad layouts)","app":"gallery-reproduction"}},"license":"CC0-1.0"}