Claude Skill

render-imessage-cascade

Assemble an iMessage notification-cascade video ad (≈14s, 9:16) from a phone-on-desk plate + 3–5 messages — authentic Apple Messages banners composited in PIL (SF Pro text, green Messages icon, warm translucent-greige fill, soft shadow) spring in one-by-one at the BOTTOM and push

LLM Mart · 0 points · 6 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gooseworks-ai-goose-skills-skills_ads_capabilities_render-imessage-cascade-e1592ee.zip · 10 KB
Part of gooseworks-ai/goose-skills — 44 skills

Install

skills CLI npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-imessage-cascade
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
Git git clone https://github.com/gooseworks-ai/goose-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gooseworks-ai/goose-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

render-imessage-cascade

The free, deterministic renderer for the imessage-notification-cascade video ad format — the viral iOS trend where a phone sits on a desk and Apple Messages notifications STACK IN one after another. The signature mechanic is the bottom-up push: each new banner springs in at the bottom (nearest the phone) and shoves every existing one UP a row; the iOS grouped "⌄ Show less / ✕" pill rides above the stack; the ✕ clears the stack; then a serif end card resolves.

This is a DETERMINISTIC composite — no generative video of the UI. Authentic iMessage banners are drawn in PIL and animated in FFmpeg over a Ken-Burns plate, so the notification text + wordmark stay pixel-crisp (a video model would smear type). The template recipe supplies the per-brand plate, notifications, and end_card config and gates the only paid steps — cleaning the plate (→ create-image-fal) and the music bed/pop (→ create-music-elevenlabs) — to their own capabilities. This capability itself makes no paid calls.

Scripts (free)

  • scripts/build_assets.py — draws the assets from config: nb-1..N.png (authentic banners — green Messages icon, warm translucent-greige fill, soft box-shadow, title/body/NOW/handle), pill.png (right-aligned "⌄ Show less / ✕"), endcard.png (serif CTA + wordmark lockup + url). Fonts are load-bearing: SF Pro (SFNS.ttf) via set_variation_by_name for the banner title/body/NOW/handle (Arial fallback), Times/serif for the end-card CTA. Do NOT swap in Helvetica/Arial as the primary — the banners must read as the real iOS system font.
  • scripts/compose.py — Ken-Burns push-in on the plate → each banner springs in at the BOTTOM while later arrivals push the stack UP (FFmpeg overlay y expressions) → pill rides above → ✕-clear swipes the stack up + fades → serif end card fades in → optional audio (bed + pop per arrival + a free FFmpeg swoosh on the clear) → encode h264 + aac.
  • scripts/config.example.json — the shape of the brand config the recipe binds.

Geometry contract (load-bearing — build_assets.py and compose.py MUST share it)

W=1080 H=1920, SIDE=135 → banner width BODY_W=810, BANNER_H=176, PAD=60, row pitch H=214, bottom anchor YB=1200. Icon ~100px at a ~24px left inset; body text starts ~150px from the banner's left edge. Change one, change both.

Craft rules (faithful to the source molecule)

  • Keep the REAL iMessage UI: green Apple Messages icon, warm TRANSLUCENT greige banner (NOT white, no white bloom), soft dark box-shadow. Do NOT rebrand the banner to the brand's colors — the brand lives ONLY on the handle (bottom-right) + the end card.
  • SF Pro for all banner text (title Semibold ~38, body Regular ~36, NOW/handle ~25). Never AI-render text.
  • 3–5 notifications (more crowds the top / clips the pill); newest enters at the BOTTOM, so banner 1 is the oldest and ends up on TOP.
  • ✕-clear then end card (a real hand-swipe needs a paid i2v — out of scope here).

Requires

watch (QC the final master). The recipe gates create-image-fal (plate clean) and create-music-elevenlabs (bed/pop) — both paid, proxy-routed, billed to the Ads agent.

Files (goose-skills)
  • scripts
    • build_assets.py 9.6 KB
      #!/usr/bin/env python3
      """build_assets.py — deterministic PIL builder for the iMessage notification-cascade ad.
      
      Reads the same config.json as one_shot.py / compose.py and writes, into --work-dir:
        nb-1.png .. nb-N.png   one transparent banner per notification (full-1080-wide canvas)
        pill.png               the right-aligned "v Show less / X" grouped-notification control
        endcard.png            the serif CTA + brand wordmark lockup (transparent 1080x1920)
      
      Every banner is an AUTHENTIC iMessage notification: green Apple Messages icon, bold
      title, message body, "NOW", italic sender handle bottom-right. The background is a
      warm TRANSLUCENT greige (NOT white) with a soft dark drop shadow (NOT a white bloom)
      — sampled from the source trend (fill composites to ~RGB 220,197,186 over a warm
      desk). ALL text is composited here, never AI-rendered (LEARNINGS L4).
      
      Geometry constants MUST stay in sync with compose.py (BODY_W, BANNER_H, PAD, SIDE).
      """
      import argparse, json, os
      from PIL import Image, ImageDraw, ImageFont, ImageFilter
      
      # ---- geometry (keep in sync with compose.py) ----
      CANVAS_W = 1080
      BODY_W = 810
      BANNER_H = 176
      SIDE = (CANVAS_W - BODY_W) // 2      # 135
      PAD = 60
      ROW_CANVAS_H = BANNER_H + PAD * 2    # 296
      RADIUS = 40
      # warm cream, TRANSLUCENT — over a warm desk this reads as the source greige (~220,197,186)
      FILL = (246, 228, 219, 205)
      SHADOW = (30, 22, 16)                # warm-dark soft box-shadow
      
      # ---- fonts (macOS; SF Pro with Arial fallback, Times for the serif CTA) ----
      SF = "/System/Library/Fonts/SFNS.ttf"
      ARIAL_B = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
      ARIAL = "/System/Library/Fonts/Supplemental/Arial.ttf"
      ARIAL_I = "/System/Library/Fonts/Supplemental/Arial Italic.ttf"
      TIMES = "/System/Library/Fonts/Times.ttc"
      
      def font(kind, size):
          try:
              if kind in ("bold", "reg", "semibold", "medium"):
                  f = ImageFont.truetype(SF, size)
                  f.set_variation_by_name({"bold": "Bold", "reg": "Regular", "semibold": "Semibold", "medium": "Medium"}[kind])
                  return f
          except Exception:
              pass
          return ImageFont.truetype({"bold": ARIAL_B, "semibold": ARIAL_B, "medium": ARIAL_B, "reg": ARIAL, "italic": ARIAL_I}.get(kind, ARIAL), size)
      
      def serif(size):
          try:
              return ImageFont.truetype(TIMES, size, index=1)
          except Exception:
              return ImageFont.truetype("/System/Library/Fonts/Supplemental/Times New Roman Bold.ttf", size)
      
      def rounded(size, radius, fill):
          im = Image.new("RGBA", size, (0, 0, 0, 0))
          ImageDraw.Draw(im).rounded_rectangle([0, 0, size[0]-1, size[1]-1], radius=radius, fill=fill)
          return im
      
      def hex_rgb(s, default=(240, 95, 34)):
          if not s:
              return default
          s = s.lstrip("#")
          return tuple(int(s[i:i+2], 16) for i in (0, 2, 4))
      
      # ---- Apple Messages green icon (drawn, not AI) ----
      def messages_icon(px=100):
          grad = Image.new("RGBA", (px, px), (0, 0, 0, 0))
          top, bot = (99, 232, 92), (28, 199, 62)
          for y in range(px):
              f = y / (px - 1)
              c = tuple(int(top[i] + (bot[i]-top[i]) * f) for i in range(3))
              for x in range(px):
                  grad.putpixel((x, y), c + (255,))
          mask = Image.new("L", (px, px), 0)
          ImageDraw.Draw(mask).rounded_rectangle([0, 0, px-1, px-1], radius=int(px*0.235), fill=255)
          icon = Image.new("RGBA", (px, px), (0, 0, 0, 0)); icon.paste(grad, (0, 0), mask)
          d = ImageDraw.Draw(icon); bw, bh = int(px*0.60), int(px*0.50); bx = (px-bw)//2; by = int(px*0.20)
          d.rounded_rectangle([bx, by, bx+bw, by+bh], radius=int(bh*0.42), fill=(255, 255, 255, 255))
          tx, ty = bx+int(bw*0.16), by+bh-2
          d.polygon([(tx, ty-6), (tx+18, ty-6), (tx-2, ty+14)], fill=(255, 255, 255, 255))
          return icon
      
      def icon_from_path(path, px=100):
          im = Image.open(path).convert("RGBA")
          g = min(im.size)
          im = im.crop(((im.width-g)//2, (im.height-g)//2, (im.width+g)//2, (im.height+g)//2)).resize((px, px), Image.LANCZOS)
          mask = Image.new("L", (px, px), 0)
          ImageDraw.Draw(mask).rounded_rectangle([0, 0, px-1, px-1], radius=int(px*0.235), fill=255)
          out = Image.new("RGBA", (px, px), (0, 0, 0, 0)); out.paste(im, (0, 0), mask)
          return out
      
      def get_icon(cfg, px=100):
          ic = cfg.get("app_icon", "messages")
          if ic and ic != "messages" and os.path.exists(ic):
              return icon_from_path(ic, px)
          return messages_icon(px)
      
      # ---- one notification banner ----
      def build_banner(cfg, title, body, handle, out):
          canvas = Image.new("RGBA", (CANVAS_W, ROW_CANVAS_H), (0, 0, 0, 0))
          bx0, by0 = SIDE, PAD; bx1, by1 = SIDE+BODY_W, PAD+BANNER_H
          # soft dark drop shadow (real box-shadow; diffuse, low opacity) — NOT a white bloom
          sh = Image.new("RGBA", (CANVAS_W, ROW_CANVAS_H), (0, 0, 0, 0))
          ImageDraw.Draw(sh).rounded_rectangle([bx0-4, by0-2, bx1+4, by1+10], radius=RADIUS+4, fill=SHADOW+(120,))
          sh = sh.filter(ImageFilter.GaussianBlur(26)); canvas = Image.alpha_composite(canvas, sh)
          # translucent frosted body
          canvas.alpha_composite(rounded((BODY_W, BANNER_H), RADIUS, tuple(cfg.get("fill", FILL))), (bx0, by0))
          d = ImageDraw.Draw(canvas)
          icon = get_icon(cfg, 100); ix = bx0+24; iy = by0+(BANNER_H-100)//2; canvas.alpha_composite(icon, (ix, iy))
          text_x = ix+100+24
          f_title, f_body, f_now, f_handle = font("bold", 38), font("reg", 36), font("reg", 25), font("italic", 25)
          ty = by0+34
          d.text((text_x, ty), title, font=f_title, fill=(20, 20, 22, 255))
          d.text((text_x, ty+50), body, font=f_body, fill=(70, 68, 72, 255))
          now_w = d.textlength("NOW", font=f_now); d.text((bx1-26-now_w, by0+30), "NOW", font=f_now, fill=(140, 138, 140, 255))
          h_w = d.textlength(handle, font=f_handle); d.text((bx1-26-h_w, by1-42), handle, font=f_handle, fill=(150, 146, 146, 255))
          canvas.save(out)
      
      # ---- right-aligned "v Show less / X" control pill ----
      def build_pill(out):
          H = 140; canvas = Image.new("RGBA", (CANVAS_W, H), (0, 0, 0, 0)); d0 = ImageDraw.Draw(canvas)
          f = font("reg", 31); label = "Show less"; lw = d0.textlength(label, font=f)
          pill_w = int(40+30+lw+38); pill_h = 72; x_circle = 72; gap = 18
          group_w = pill_w+gap+x_circle; right_edge = SIDE+BODY_W; gx = right_edge-group_w; gy = (H-pill_h)//2
          sh = Image.new("RGBA", (CANVAS_W, H), (0, 0, 0, 0))
          ImageDraw.Draw(sh).rounded_rectangle([gx-2, gy, gx+pill_w+2, gy+pill_h+8], radius=pill_h//2, fill=SHADOW+(110,))
          ImageDraw.Draw(sh).ellipse([gx+pill_w+gap-2, gy, gx+pill_w+gap+x_circle+2, gy+pill_h+8], fill=SHADOW+(110,))
          sh = sh.filter(ImageFilter.GaussianBlur(20)); canvas = Image.alpha_composite(canvas, sh)
          d = ImageDraw.Draw(canvas)
          d.rounded_rectangle([gx, gy, gx+pill_w, gy+pill_h], radius=pill_h//2, fill=(247, 244, 240, 210))
          cxx = gx+32; cyy = gy+pill_h//2
          d.line([(cxx-11, cyy-6), (cxx, cyy+6), (cxx+11, cyy-6)], fill=(90, 88, 90, 255), width=5)
          d.text((cxx+20, gy+pill_h//2-21), label, font=f, fill=(70, 68, 72, 255))
          ox = gx+pill_w+gap; d.ellipse([ox, gy, ox+x_circle, gy+pill_h], fill=(247, 244, 240, 210))
          cx2 = ox+x_circle//2; cy2 = gy+pill_h//2; r = 16
          d.line([(cx2-r, cy2-r), (cx2+r, cy2+r)], fill=(90, 88, 90, 255), width=6)
          d.line([(cx2-r, cy2+r), (cx2+r, cy2-r)], fill=(90, 88, 90, 255), width=6)
          canvas.save(out)
      
      # ---- serif CTA + brand wordmark end card ----
      def build_endcard(ec, out):
          W, Hh = 1080, 1920; card = Image.new("RGBA", (W, Hh), (0, 0, 0, 0)); d = ImageDraw.Draw(card)
          accent = hex_rgb(ec.get("accent", "#f05f22"))
          def center(txt, fnt, y, fill, sh=True):
              w = d.textlength(txt, font=fnt); x = (W-w)//2
              if sh: d.text((x+2, y+3), txt, font=fnt, fill=(0, 0, 0, 150))
              d.text((x, y), txt, font=fnt, fill=fill)
          center(ec.get("line1", "MEET YOUR"), serif(92), 690, (245, 240, 235, 255))
          center(ec.get("line2", "AI COWORKER"), serif(70), 802, accent+(255,))
          # wordmark lockup: optional brand icon + "Word"+"mark" split (bold + light)
          wm = ec.get("wordmark_text", "")
          f1, f2 = font("bold", 66), font("reg", 66)
          icon_w = 0; wm_icon = None
          if ec.get("wordmark_icon") and os.path.exists(ec["wordmark_icon"]):
              wm_icon = rounded((78, 78), 18, accent+(255,))
              gi = Image.open(ec["wordmark_icon"]).convert("RGBA"); g = min(gi.size)
              gi = gi.crop(((gi.width-g)//2, (gi.height-g)//2, (gi.width+g)//2, (gi.height+g)//2)).resize((64, 64), Image.LANCZOS)
              m = Image.new("L", (64, 64), 0); ImageDraw.Draw(m).ellipse([0, 0, 63, 63], fill=255)
              wm_icon.paste(gi, (7, 7), m); icon_w = 78+20
          split = max(1, len(wm)*3//5)
          a, b = wm[:split], wm[split:]
          w1 = d.textlength(a, font=f1); w2 = d.textlength(b, font=f2)
          total = icon_w + w1 + w2; x0 = int((W-total)//2); wm_y = 1010
          if wm_icon is not None:
              card.alpha_composite(wm_icon, (x0, wm_y-6))
          tx = x0 + icon_w
          d.text((tx, wm_y), a, font=f1, fill=(247, 244, 240, 255)); d.text((tx+w1, wm_y), b, font=f2, fill=(196, 188, 180, 255))
          url = ec.get("url", "")
          if url:
              fu = font("semibold", 38); uw = d.textlength(url, font=fu)
              r, g, bl = accent; d.text(((W-uw)//2, 1140), url, font=fu, fill=(min(255, r+30), g+55, bl+86, 255))
          card.save(out)
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--config", required=True)
          ap.add_argument("--work-dir", required=True)
          a = ap.parse_args()
          cfg = json.load(open(a.config))
          os.makedirs(a.work_dir, exist_ok=True)
          notifs = cfg["notifications"]
          for i, n in enumerate(notifs, 1):
              build_banner(cfg, n["title"], n["body"], n.get("handle", ""), os.path.join(a.work_dir, f"nb-{i}.png"))
          build_pill(os.path.join(a.work_dir, "pill.png"))
          build_endcard(cfg.get("end_card", {}), os.path.join(a.work_dir, "endcard.png"))
          print(f"built {len(notifs)} banners + pill + endcard -> {a.work_dir}")
      
      if __name__ == "__main__":
          main()
      
    • compose.py 6 KB
      #!/usr/bin/env python3
      """compose.py — the FFmpeg composite engine for the iMessage notification-cascade ad.
      
      Takes the plate + the PNGs build_assets.py produced (nb-*.png, pill.png, endcard.png)
      and renders the signature mechanic:
      
        - Ken Burns push-in on a clean phone-on-desk plate.
        - Each notification SPRINGS IN AT THE BOTTOM (by the phone); every already-present
          banner is PUSHED UP one row. The "Show less / X" pill rides above the stack.
        - At the clear time, the X is pressed: the whole stack + pill swipe up and fade.
        - The serif end card fades in over the clean desk.
      
      Optional audio: a music bed, a soft pop at each arrival, a swipe swoosh on the clear.
      
      Geometry constants MUST match build_assets.py.
      """
      import argparse, json, os, subprocess, sys
      
      CANVAS_W = 1080
      BODY_W = 810
      BANNER_H = 176
      SIDE = (CANVAS_W - BODY_W) // 2
      PAD = 60
      H = 214            # row pitch (canvas-top spacing between stacked banners)
      YB = 1200          # bottom anchor: canvas-top of the newest (bottom) banner
      BOTTOM_HINT = YB + PAD + BANNER_H  # ~1436 body bottom -> just above the phone
      
      def push(a):    return f"(1-exp(-7*max(0\\,t-{a})))"
      def spring(a):  return f"(60*exp(-9*max(0\\,t-{a})))"
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--config", required=True)
          ap.add_argument("--work-dir", required=True)
          ap.add_argument("--out", required=True)
          ap.add_argument("--no-audio", action="store_true")
          a = ap.parse_args()
          cfg = json.load(open(a.config))
          work = a.work_dir
          N = len(cfg["notifications"])
          tm = cfg.get("timing", {})
          arrivals = tm.get("arrivals") or [round(1.6 + 2.0*i, 2) for i in range(N)]
          assert len(arrivals) == N, "timing.arrivals length must equal number of notifications"
          Tc = tm.get("clear", round(arrivals[-1] + 1.6, 2))
          EC_IN = tm.get("endcard_in", round(Tc + 0.7, 2))
          DUR = tm.get("duration", round(EC_IN + 4.1, 2))
          if N > 5:
              print(f"WARNING: {N} notifications — the top of the stack may clip / crowd the pill. 3-5 recommended.", file=sys.stderr)
      
          plate = cfg["plate"]
          CLEAR = f"(560*(1-exp(-12*max(0\\,t-{Tc}))))"
      
          def ybanner(k):  # k is 1-indexed arrival order; pushed up by every later arrival
              later = "+".join(push(arrivals[j]) for j in range(k, N))
              base = f"{YB}-{H}*({later})" if later else f"{YB}"
              return f"{base}+{spring(arrivals[k-1])}-{CLEAR}"
          later_all = "+".join(push(arrivals[j]) for j in range(1, N))
          y_pill = f"({YB}-{H}*({later_all}))-70-{CLEAR}" if later_all else f"({YB})-70-{CLEAR}"
      
          # ---- inputs ----
          inp = ["-loop", "1", "-framerate", "30", "-t", str(DUR), "-i", plate]
          for k in range(1, N+1):
              inp += ["-loop", "1", "-framerate", "30", "-t", str(DUR), "-i", os.path.join(work, f"nb-{k}.png")]
          idx_pill = N+1
          inp += ["-loop", "1", "-framerate", "30", "-t", str(DUR), "-i", os.path.join(work, "pill.png")]
          idx_ec = N+2
          inp += ["-loop", "1", "-framerate", "30", "-t", str(DUR), "-i", os.path.join(work, "endcard.png")]
      
          # ---- video filtergraph ----
          fc = []
          fc.append("[0:v]scale=1188:2088:force_original_aspect_ratio=increase,crop=1188:2088,setsar=1,"
                    "zoompan=z='min(1+0.00026*on\\,1.13)':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s=1080x1920:fps=30,"
                    "fade=t=in:st=0:d=0.5,format=rgba[plate]")
          for k in range(1, N+1):
              fc.append(f"[{k}:v]format=rgba,fade=t=in:st={arrivals[k-1]}:d=0.35:alpha=1,fade=t=out:st={Tc}:d=0.4:alpha=1[b{k}]")
          fc.append(f"[{idx_pill}:v]format=rgba,fade=t=in:st={arrivals[0]}:d=0.35:alpha=1,fade=t=out:st={Tc}:d=0.4:alpha=1[pill]")
          fc.append(f"[{idx_ec}:v]format=rgba,fade=t=in:st={EC_IN}:d=0.6:alpha=1[ec]")
          cur = "plate"
          for k in range(1, N+1):
              fc.append(f"[{cur}][b{k}]overlay=x=0:y='{ybanner(k)}'[v{k}]"); cur = f"v{k}"
          fc.append(f"[{cur}][pill]overlay=x=0:y='{y_pill}'[vp]")
          fc.append(f"[vp][ec]overlay=x=0:y=0:enable='gte(t,{round(EC_IN-0.3,2)})',format=yuv420p[vout]")
      
          # ---- optional audio ----
          audio = cfg.get("audio", {}) or {}
          have_audio = (not a.no_audio) and audio.get("bed") and os.path.exists(audio.get("bed", ""))
          aud_inputs = []
          if have_audio:
              bed = audio["bed"]; pop = audio.get("pop"); swoosh = audio.get("swoosh")
              base_i = idx_ec + 1
              aud_inputs += ["-i", bed]; bed_i = base_i
              pop_i = swoosh_i = None
              nxt = base_i + 1
              if pop and os.path.exists(pop):
                  aud_inputs += ["-i", pop]; pop_i = nxt; nxt += 1
              if swoosh and os.path.exists(swoosh):
                  aud_inputs += ["-i", swoosh]; swoosh_i = nxt; nxt += 1
              parts = []
              mixes = []
              if pop_i is not None:
                  parts.append(f"[{pop_i}:a]asplit={N}" + "".join(f"[pp{k}]" for k in range(N)) + ";")
                  for k in range(N):
                      ms = int(arrivals[k]*1000)
                      vol = 0.62 if k == N-1 else 0.55
                      parts.append(f"[pp{k}]adelay={ms}|{ms},volume={vol}[p{k}];"); mixes.append(f"[p{k}]")
              if swoosh_i is not None:
                  ms = int(Tc*1000); parts.append(f"[{swoosh_i}:a]adelay={ms}|{ms},volume=0.5[sw];"); mixes.append("[sw]")
              parts.append(f"[{bed_i}:a]volume=1.0[bed];")
              n_mix = 1 + len(mixes)
              parts.append("[bed]" + "".join(mixes) + f"amix=inputs={n_mix}:normalize=0:duration=first:dropout_transition=0,"
                           f"loudnorm=I=-14:TP=-1.5:LRA=11,afade=t=in:st=0:d=0.4,afade=t=out:st={round(DUR-0.8,2)}:d=0.8[aout]")
              fc.append("".join(parts).rstrip(";"))
      
          filt = ";".join(fc)
      
          cmd = ["ffmpeg", "-y"] + inp + aud_inputs + ["-filter_complex", filt, "-map", "[vout]"]
          if have_audio:
              cmd += ["-map", "[aout]", "-c:a", "aac", "-b:a", "256k"]
          cmd += ["-r", "30", "-t", str(DUR), "-c:v", "libx264", "-preset", "medium", "-crf", "18", "-pix_fmt", "yuv420p", a.out]
          print("duration", DUR, "| N", N, "| arrivals", arrivals, "| clear", Tc, "| endcard", EC_IN)
          r = subprocess.run(cmd, capture_output=True, text=True)
          if r.returncode:
              print(r.stderr[-1500:], file=sys.stderr); sys.exit(1)
          print("WROTE", a.out)
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 1.3 KB
      {
        "_comment": "Illustrative example (the source Gooseworks 'inbound leads' remix). The recipe binds a NEW brand's own values here at remix time — EVERY brand-specific field is a placeholder to REPLACE, including end_card.accent (do NOT ship the Gooseworks orange #f05f22 for another brand; set the remixed brand's own accent).",
        "title": "Gooseworks — Notification Cascade (inbound leads)",
        "plate": "/abs/path/plate-phone-desk.png",
        "plate_ref": "/abs/path/source-frame-with-a-notification.jpg",
        "app_icon": "messages",
        "notifications": [
          { "title": "New lead", "body": "Hey! Saw your email.", "handle": "Gooseworks" },
          { "title": "New lead", "body": "Can you send pricing?", "handle": "Gooseworks" },
          { "title": "New lead", "body": "I'm interested.", "handle": "Gooseworks" },
          { "title": "New lead", "body": "Let's book a call.", "handle": "Gooseworks" }
        ],
        "end_card": {
          "line1": "MEET YOUR",
          "line2": "AI COWORKER",
          "wordmark_text": "Gooseworks",
          "wordmark_icon": "/abs/path/brand-logo.jpg",
          "url": "gooseworks.ai",
          "accent": "#f05f22"
        },
        "timing": {
          "arrivals": [1.6, 3.6, 5.6, 7.6],
          "clear": 9.2,
          "endcard_in": 9.9,
          "duration": 14
        },
        "audio": {
          "bed": "/abs/path/bed.mp3",
          "pop": "/abs/path/pop.mp3",
          "swoosh": "/abs/path/swoosh.mp3"
        }
      }
      
  • tests
    • smoke-test.md 2.4 KB
      # Smoke test — render-imessage-cascade
      
      Verifies the free PIL + ffmpeg assembly end-to-end from the bundled example config.
      No paid calls. Needs: Python 3 with Pillow, ffmpeg/ffprobe, and macOS system fonts
      (SF Pro `SFNS.ttf`, Times). A clean phone-on-desk plate PNG.
      
      ## Setup
      
      ```bash
      cd scripts
      python3 -m pip install pillow            # if not present
      mkdir -p /tmp/imsg-cascade-smoke
      # Point config.json at a real plate: copy config.example.json → config.json and set
      # "plate" to any 1080x1920 phone-on-desk PNG (lock screen on, no notification), and
      # fill in end_card.{line1,line2,wordmark_text,url,accent}. Run --no-audio to stay $0.
      cp config.example.json /tmp/imsg-cascade-smoke/config.json
      ```
      
      ## Run
      
      ```bash
      python3 build_assets.py --config /tmp/imsg-cascade-smoke/config.json \
                              --work-dir /tmp/imsg-cascade-smoke
      python3 compose.py      --config /tmp/imsg-cascade-smoke/config.json \
                              --work-dir /tmp/imsg-cascade-smoke \
                              --out /tmp/imsg-cascade-smoke/final.mp4 --no-audio
      ```
      
      ## Expect
      
      - `build_assets.py` writes `nb-1..N.png`, `pill.png`, `endcard.png` into the work dir.
        Banners are the warm TRANSLUCENT greige (NOT white / no white bloom) with a green
        Apple Messages icon and a soft box-shadow; **title/body/NOW/handle render in SF Pro**
        (bold title, regular body, italic handle), pixel-crisp. The pill reads
        "⌄ Show less / ✕", right-aligned.
      - `final.mp4` — 1080×1920, ~14s. The plate Ken-Burns push-ins; banners spring in at the
        BOTTOM one-by-one and push the stack UP (banner 1 ends on TOP); the ✕ clears the stack;
        a serif end card (line1 white, line2 accent) with the real wordmark + url resolves.
      - `ffprobe` confirms dimensions/duration; run the `watch` skill on the master to confirm
        beat order, the warm greige banners, the ✕-clear, and the end card.
      
      ## Fail signals
      
      - Banners read as WHITE / have a white bloom → the `fill` isn't the warm greige (check
        `FILL` / `config.fill`).
      - Banner text looks like Helvetica/Arial, not the iOS system font → SF Pro didn't load;
        confirm `SFNS.ttf` exists and `set_variation_by_name` succeeded (Arial is fallback only).
      - Newest banner appears at the TOP / stack pushes DOWN → arrival order or the `ybanner`
        push expression is inverted (newest must enter at the bottom).
      - Text baked into the banner is smeared/warped → something AI-rendered the UI; this engine
        is PIL-only, never a video model.
      
  • SKILL.md 3.9 KB
    ---
    name: render-imessage-cascade
    description: Assemble an iMessage notification-cascade video ad (≈14s, 9:16) from a phone-on-desk plate + 3–5 messages — authentic Apple Messages banners composited in PIL (SF Pro text, green Messages icon, warm translucent-greige fill, soft shadow) spring in one-by-one at the BOTTOM and push the stack UP, a right-aligned Show-less/X pill rides above, the X clears the stack, then a serif end card resolves. FREE assembly (PIL + ffmpeg); the recipe supplies the per-brand plate, notifications, and end-card config and gates the paid plate-clean/music calls to their own capabilities. Use for the imessage-notification-cascade format.
    status: active
    ---
    
    # render-imessage-cascade
    
    The free, deterministic renderer for the **imessage-notification-cascade** video ad
    format — the viral iOS trend where a phone sits on a desk and Apple Messages
    notifications STACK IN one after another. The signature mechanic is the **bottom-up
    push**: each new banner springs in at the bottom (nearest the phone) and shoves every
    existing one UP a row; the iOS grouped "⌄ Show less / ✕" pill rides above the stack;
    the ✕ clears the stack; then a serif end card resolves.
    
    This is a DETERMINISTIC composite — **no generative video of the UI**. Authentic
    iMessage banners are drawn in PIL and animated in FFmpeg over a Ken-Burns plate, so
    the notification text + wordmark stay pixel-crisp (a video model would smear type).
    The template recipe supplies the per-brand `plate`, `notifications`, and `end_card`
    config and gates the only paid steps — cleaning the plate (→ `create-image-fal`) and
    the music bed/pop (→ `create-music-elevenlabs`) — to their own capabilities. This
    capability itself makes **no paid calls**.
    
    ## Scripts (free)
    
    - `scripts/build_assets.py` — draws the assets from `config`: `nb-1..N.png` (authentic
      banners — green Messages icon, warm translucent-greige fill, soft box-shadow,
      title/body/NOW/handle), `pill.png` (right-aligned "⌄ Show less / ✕"), `endcard.png`
      (serif CTA + wordmark lockup + url). **Fonts are load-bearing: SF Pro (`SFNS.ttf`)
      via `set_variation_by_name` for the banner title/body/NOW/handle** (Arial fallback),
      Times/serif for the end-card CTA. Do NOT swap in Helvetica/Arial as the primary —
      the banners must read as the real iOS system font.
    - `scripts/compose.py` — Ken-Burns push-in on the plate → each banner springs in at the
      BOTTOM while later arrivals push the stack UP (FFmpeg overlay `y` expressions) → pill
      rides above → ✕-clear swipes the stack up + fades → serif end card fades in → optional
      audio (bed + pop per arrival + a free FFmpeg swoosh on the clear) → encode h264 + aac.
    - `scripts/config.example.json` — the shape of the brand `config` the recipe binds.
    
    ## Geometry contract (load-bearing — build_assets.py and compose.py MUST share it)
    
    `W=1080 H=1920`, `SIDE=135` → banner width `BODY_W=810`, `BANNER_H=176`, `PAD=60`,
    row pitch `H=214`, bottom anchor `YB=1200`. Icon ~100px at a ~24px left inset; body
    text starts ~150px from the banner's left edge. Change one, change both.
    
    ## Craft rules (faithful to the source molecule)
    
    - Keep the REAL iMessage UI: green Apple Messages icon, warm TRANSLUCENT greige banner
      (NOT white, no white bloom), soft dark box-shadow. Do NOT rebrand the banner to the
      brand's colors — the brand lives ONLY on the handle (bottom-right) + the end card.
    - **SF Pro for all banner text** (title Semibold ~38, body Regular ~36, NOW/handle ~25).
      Never AI-render text.
    - 3–5 notifications (more crowds the top / clips the pill); newest enters at the BOTTOM,
      so banner 1 is the oldest and ends up on TOP.
    - ✕-clear then end card (a real hand-swipe needs a paid i2v — out of scope here).
    
    ## Requires
    
    `watch` (QC the final master). The recipe gates `create-image-fal` (plate clean) and
    `create-music-elevenlabs` (bed/pop) — both paid, proxy-routed, billed to the Ads agent.
    
  • skill.meta.json 323 B
    {
      "slug": "render-imessage-cascade",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-imessage-cascade",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related