Claude Skill

render-airdrop-carousel

Assemble a viral iOS "AirDrop" notification-carousel video ad (≈6–8s, 9:16) from a brand line plus 6–16 real product photos — a native AirDrop share-sheet card ("Brand would like to share a ___ · Decline / Accept") springs up and its preview window CYCLES through the products, la

LLM Mart · 0 points · 5 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-airdrop-carousel-e1592ee.zip · 13 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-airdrop-carousel
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-airdrop-carousel

The free, deterministic renderer for the airdrop-notification-carousel video ad format — the viral iOS "AirDrop" trend where a native share-sheet card ("Brand would like to share a candle · Decline / Accept") springs up on the phone and its preview window flips through a carousel of real product photos, landing on a range/lineup payoff with an Accept tap.

This is a DETERMINISTIC composite — no generative video, no AI-rendered product. A real-DOM AirDrop card is rendered once to PNG (headless Chrome), chroma-keyed, and its preview window is refilled per-product in PIL, then animated + audio-synthed with FFmpeg. The whole point of the format is crisp system-UI text and real product photography, both of which a video model would smear. This capability makes no paid calls; the recipe gates the only optional paid step — generating a hero product shot when the brand has NO usable photo at all (→ create-image-fal).

Default output ≈ 6–8s, 1080×1920, h264 + aac. Duration is DERIVED, not trimmed — first_hold + (N-1)·per + final_hold. Add images or raise per to lengthen.

Scripts (free)

  • scripts/build_card.py — brand params → chrome.html + chrome-pressed.html: the AirDrop card on a green page (#00e000) with a magenta preview window (#ff00ff) and a solid brand band (wordmark SVG or text + tagline). Real DOM text — AirDrop, Decline, Accept, and the brand line are DOM/SVG, never AI-rendered.
  • scripts/one_shot.py — glue: build_card → headless-Chrome (Playwright) fullPage screenshot of both card states → compose_carousel. One --config, one MP4.
  • scripts/compose_carousel.py — the render engine: green-key the card → detect the magenta window → fill it per-product (cover-crop) → blurred per-product backdrop + push-in → card spring-up (iOS ease-out-back) + carousel + Accept tap → synth audio (whoosh on entry, chime on land, a tick per swap, a pop on the tap) → encode h264+aac.
  • scripts/config.example.json — the shape of the brand config the recipe binds (brand-neutral worked defaults; replace every /abs/path/... placeholder).

Chroma contract (load-bearing — build_card.py and compose_carousel.py MUST share it)

#00e000 green = page background, keyed to the card's alpha. #ff00ff magenta = preview window, replaced per product. Neither color may appear in the card art or any product photo — a product image containing near-pure green or magenta gets keyed/misread. Swap the image or tighten the window_mask thresholds if it bleeds.

Craft rules (faithful to the source molecule)

  • Real product photos only. The format's credibility is that these look like real AirDropped items — no AI-generated products in the carousel.
  • Never AI-render text — the card UI, wordmark, and band are DOM/SVG composited so they stay pixel-crisp. Cover-crop the window; white-bg PDP shots reading as "product in a white tile" is on-brand and fine.
  • 6–16 images, ordered; end on the range — the payoff (final_image) should read as "the whole line" (lineup / family / all-shades).
  • Hard cuts on rhythm, a tick per swap — do NOT crossfade.
  • Never invent proof — a 1M+ sold tagline must be the brand's OWN stated figure, not filled from a brand-kit proof section.
  • Duration is derived, not trimmed — add images or raise per; never cut the audio.

Requires

  • Python 3 with numpy + Pillow, and ffmpeg/ffprobe on PATH.
  • Playwright chromium for the screenshot step (pip install playwright && playwright install chromium). If Playwright is unavailable, run build_card.py, screenshot chrome.html / chrome-pressed.html (fullPage) → chrome-green.png / chrome-green-pressed.png via the chrome-devtools MCP, then call compose_carousel.py directly.
  • watch (QC the final master). The recipe gates create-image-fal (optional hero shot when the brand has NO usable product photo) — the only paid, proxy-routed step.
Files (goose-skills)
  • scripts
    • build_card.py 4.9 KB
      #!/usr/bin/env python3
      """build_card.py — generate the keyable iOS "AirDrop" share-sheet card HTML.
      
      Writes two HTML files (normal + Accept-pressed) sized to the target frame. Each
      renders a native-looking AirDrop card on a GREEN page (#00e000, keyed out in
      compose_carousel.py) with a MAGENTA preview window (#ff00ff, replaced per-product
      in compose_carousel.py) and a SOLID brand band (wordmark + tagline). Text is real
      DOM text, never AI-rendered, so it stays pixel-crisp.
      
      Render these two HTML files to PNG (fullPage) with any headless Chrome — Playwright
      (see one_shot.py) or the chrome-devtools MCP — then feed the PNGs to
      compose_carousel.py.
      
      The two chroma colors are load-bearing and must not appear in the card art:
        GREEN   #00e000 = page background  -> keyed to transparency (the card's alpha)
        MAGENTA #ff00ff = preview window   -> replaced by each product image
      """
      import argparse, html, os
      
      GREEN = "#00e000"
      MAGENTA = "#ff00ff"
      
      
      def build(brand, message, tagline, wordmark_svg, wordmark_text, accent,
                band_color, card_width, pressed):
          accept_bg = "background:rgba(0,0,0,.06);" if pressed else ""
          if wordmark_svg and os.path.exists(wordmark_svg):
              wm = open(wordmark_svg).read()
          else:
              wm = f'<div style="font-size:56px;font-weight:800;letter-spacing:-1px;color:#1a1a1a">{html.escape(wordmark_text)}</div>'
          tag = html.escape(tagline).replace("(R)", "&reg;").replace(" - ", " &middot; ")
          return f"""<!doctype html><html><head><meta charset="utf-8"><style>
      *{{margin:0;padding:0;box-sizing:border-box;-webkit-font-smoothing:antialiased;}}
      html,body{{background:{GREEN};}}
      .frame{{position:relative;width:1080px;height:1920px;overflow:hidden;background:{GREEN};
        font-family:-apple-system,"SF Pro Display","SF Pro Text","Helvetica Neue",Arial,sans-serif;}}
      .card{{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
        width:{card_width}px;background:#fafafa;border-radius:42px;overflow:hidden;box-shadow:none;}}
      .pad{{padding:42px 46px 10px;}}
      .title{{text-align:center;font-size:45px;font-weight:700;letter-spacing:.3px;color:#111;}}
      .sub{{text-align:center;font-size:30px;font-weight:400;color:#3a3a3c;margin-top:13px;line-height:1.35;}}
      .sub b{{font-weight:800;color:#111;letter-spacing:-.5px;}}
      .preview{{margin:30px 46px 36px;border-radius:28px;overflow:hidden;position:relative;
        aspect-ratio:928/1010;background:{MAGENTA};}}
      .band{{position:absolute;left:0;right:0;bottom:0;padding:22px 30px 26px;
        background:{band_color};border-top:1px solid rgba(0,0,0,.06);}}
      .wm{{display:flex;justify-content:center;align-items:center;}}
      .wm svg{{height:50px;width:auto;}}
      .tagline{{color:#1a1a1a;font-size:18px;font-weight:600;letter-spacing:2.2px;text-align:center;
        margin-top:10px;text-transform:uppercase;opacity:.82;}}
      .btns{{display:flex;border-top:1px solid rgba(0,0,0,.12);}}
      .btn{{flex:1;text-align:center;padding:36px 0;font-size:35px;font-weight:500;}}
      .decline{{color:#8a8a8e;}}
      .accept{{color:{accent};font-weight:800;border-left:1px solid rgba(0,0,0,.12);{accept_bg}}}
      </style></head><body>
      <div class="frame"><div class="card">
        <div class="pad"><div class="title">AirDrop</div>
          <div class="sub"><b>{html.escape(brand)}</b> {html.escape(message)}</div></div>
        <div class="preview"><div class="band"><div class="wm">{wm}</div>
          <div class="tagline">{tag}</div></div></div>
        <div class="btns"><div class="btn decline">Decline</div><div class="btn accept">Accept</div></div>
      </div></div></body></html>"""
      
      
      def main():
          ap = argparse.ArgumentParser(description="Generate keyable AirDrop card HTML (normal + pressed).")
          ap.add_argument("--brand", required=True, help='sender name, bold in the line, e.g. "dibs."')
          ap.add_argument("--message", required=True, help='rest of the line, e.g. "would like to share a blush"')
          ap.add_argument("--tagline", default="", help='band tagline, e.g. "The viral Desert Island Duo - 1M+ sold"')
          ap.add_argument("--wordmark-svg", default="", help="path to brand wordmark SVG (inlined); falls back to --wordmark-text")
          ap.add_argument("--wordmark-text", default="", help="text wordmark when no SVG (defaults to --brand)")
          ap.add_argument("--accent", default="#d98695", help="Accept-button color (brand accent)")
          ap.add_argument("--band-color", default="#f5e9da", help="brand band background color")
          ap.add_argument("--card-width", type=int, default=672)
          ap.add_argument("--out-dir", required=True)
          a = ap.parse_args()
          os.makedirs(a.out_dir, exist_ok=True)
          wt = a.wordmark_text or a.brand
          for pressed, name in ((False, "chrome.html"), (True, "chrome-pressed.html")):
              htmlstr = build(a.brand, a.message, a.tagline, a.wordmark_svg, wt, a.accent,
                              a.band_color, a.card_width, pressed)
              open(os.path.join(a.out_dir, name), "w").write(htmlstr)
          print(f"wrote {a.out_dir}/chrome.html + chrome-pressed.html")
      
      
      if __name__ == "__main__":
          main()
      
    • compose_carousel.py 9.3 KB
      #!/usr/bin/env python3
      """compose_carousel.py — the AirDrop product-carousel render engine.
      
      Takes the two rendered card PNGs (green page + magenta preview window, from
      build_card.py + a headless-Chrome screenshot) and a list of product images, and
      produces the finished vertical video:
      
        * green-key the card  -> the card's alpha (drop shadow synthesized in PIL)
        * detect the magenta window -> fill it with each product (cover), in sequence
        * blurred, darkened per-product backdrop with a slow push-in
        * card springs up (iOS ease-out-back), holds; preview cycles the products;
          lands on --final-image with an Accept tap-highlight
        * synth audio: whoosh on entry, chime on land, soft tick per swap, tap pop
        * encode h264 + aac via ffmpeg
      
      Deterministic — no generative video, so the UI text stays crisp. Requires
      numpy, Pillow, and ffmpeg on PATH.
      
      Example:
        python3 compose_carousel.py \
          --chrome chrome-green.png --chrome-pressed chrome-green-pressed.png \
          --images "a.png,b.png,c.png" --final-image lineup.jpg \
          --out out.mp4
      """
      import argparse, os, shutil, subprocess, sys, wave
      import numpy as np
      from PIL import Image, ImageFilter, ImageEnhance
      
      
      def key_green(path, W, H):
          """Green screen (#00e000) -> alpha. Returns HxWx4 uint8; magenta window stays opaque."""
          im = np.asarray(Image.open(path).convert("RGB").resize((W, H), Image.LANCZOS)).astype(np.int16)
          r, g, b = im[..., 0], im[..., 1], im[..., 2]
          greenness = g - np.maximum(r, b)
          alpha = np.clip((120 - greenness) / 60.0, 0, 1)
          alpha = (alpha * 255).astype(np.uint8)
          rgb = im.copy()                       # green despill on partially-keyed edge pixels
          spill = (greenness > 0) & (alpha < 255)
          rgb[..., 1] = np.where(spill, np.minimum(g, np.maximum(r, b)), g)
          return np.dstack([rgb.astype(np.uint8), alpha])
      
      
      def window_mask(chrome):
          """Magenta (#ff00ff) preview-window mask + its bounding box."""
          r, g, b, a = (chrome[..., i].astype(int) for i in range(4))
          mag = (r > 165) & (g < 115) & (b > 165) & (a > 128)
          ys, xs = np.where(mag)
          if len(xs) == 0:
              sys.exit("ERROR: no magenta preview window found in the card PNG.")
          return mag, (xs.min(), ys.min(), xs.max(), ys.max())
      
      
      def fill_card(chrome, mask, bbox, prod_path):
          """Copy of the card with the magenta window filled by the product (cover-crop)."""
          x0, y0, x1, y1 = bbox
          ww, wh = x1 - x0 + 1, y1 - y0 + 1
          card = chrome.copy()
          p = Image.open(prod_path).convert("RGB")
          sc = max(ww / p.width, wh / p.height)
          p2 = p.resize((int(p.width * sc) + 1, int(p.height * sc) + 1), Image.LANCZOS)
          cx, cy = (p2.width - ww) // 2, (p2.height - wh) // 2
          crop = np.asarray(p2.crop((cx, cy, cx + ww, cy + wh)))
          region = card[y0:y1 + 1, x0:x1 + 1, :3]
          m = mask[y0:y1 + 1, x0:x1 + 1]
          region[m] = crop[m]
          card[y0:y1 + 1, x0:x1 + 1, :3] = region
          return card
      
      
      def make_bg(path, BW, BH):
          im = Image.open(path).convert("RGB")
          sc = max(BW / im.width, BH / im.height)
          im2 = im.resize((int(im.width * sc) + 1, int(im.height * sc) + 1), Image.LANCZOS)
          l, t = (im2.width - BW) // 2, (im2.height - BH) // 2
          bg = im2.crop((l, t, l + BW, t + BH)).filter(ImageFilter.GaussianBlur(26))
          bg = ImageEnhance.Brightness(bg).enhance(0.5)
          return ImageEnhance.Color(bg).enhance(1.08)
      
      
      def ease_out_back(x):
          c1 = 1.70158; c3 = c1 + 1
          return 1 + c3 * ((x - 1) ** 3) + c1 * ((x - 1) ** 2)
      
      
      def synth_audio(path, dur, swaps, tap_t, sr=44100):
          n = int(dur * sr); buf = np.zeros(n)
          def tone(freq, t0, d, amp, decay):
              i0 = int(t0 * sr); L = int(d * sr); tt = np.arange(L) / sr
              seg = amp * np.sin(2 * np.pi * freq * tt) * np.exp(-tt / decay)
              e = min(i0 + L, n); buf[i0:e] += seg[:e - i0]
          def whoosh(t0, d, amp):
              i0 = int(t0 * sr); L = int(d * sr); tt = np.arange(L) / sr
              env = np.sin(np.pi * np.clip(tt / d, 0, 1)) ** 2
              seg = amp * np.random.RandomState(1).randn(L) * env
              seg = np.convolve(seg, np.ones(80) / 80, mode="same")
              e = min(i0 + L, n); buf[i0:e] += seg[:e - i0]
          whoosh(0.05, 0.55, 0.10)
          tone(1319, 0.70, 0.7, 0.34, 0.34); tone(1976, 0.70, 0.6, 0.16, 0.28); tone(2637, 0.70, 0.4, 0.07, 0.2)
          for st in swaps:
              tone(2650, st, 0.05, 0.14, 0.02); tone(3500, st, 0.035, 0.06, 0.015)
          if tap_t is not None:
              tone(2400, tap_t, 0.05, 0.16, 0.02); tone(760, tap_t + 0.02, 0.16, 0.12, 0.09); tone(1140, tap_t + 0.02, 0.16, 0.07, 0.09)
          buf = buf / (np.max(np.abs(buf)) or 1.0) * 0.85
          st = np.clip(np.stack([buf, buf], 1), -1, 1)
          with wave.open(path, "wb") as w:
              w.setnchannels(2); w.setsampwidth(2); w.setframerate(sr)
              w.writeframes((st * 32767).astype("<i2").tobytes())
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--chrome", required=True)
          ap.add_argument("--chrome-pressed", default="")
          ap.add_argument("--images", default="", help="comma-separated ordered product image paths")
          ap.add_argument("--images-dir", default="", help="dir of product images (sorted) if --images omitted")
          ap.add_argument("--final-image", required=True, help="the payoff image held at the end")
          ap.add_argument("--out", required=True)
          ap.add_argument("--per", type=float, default=0.34, help="seconds per carousel image")
          ap.add_argument("--first-hold", type=float, default=1.00, help="first image hold (covers slide-in)")
          ap.add_argument("--final-hold", type=float, default=2.10)
          ap.add_argument("--slide", type=float, default=0.72)
          ap.add_argument("--fps", type=int, default=30)
          ap.add_argument("--width", type=int, default=1080)
          ap.add_argument("--height", type=int, default=1920)
          ap.add_argument("--no-audio", action="store_true")
          a = ap.parse_args()
          W, H, FPS = a.width, a.height, a.fps
      
          imgs = [s for s in a.images.split(",") if s] if a.images else \
              sorted(os.path.join(a.images_dir, f) for f in os.listdir(a.images_dir)
                     if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp")))
          if not imgs:
              sys.exit("ERROR: no product images (pass --images or --images-dir).")
          seq = [(imgs[0], a.first_hold)] + [(p, a.per) for p in imgs[1:]] + [(a.final_image, a.final_hold)]
      
          dur = sum(s for _, s in seq); N = int(round(dur * FPS))
          bounds, acc = [], 0.0
          for _, s in seq:
              bounds.append((acc, acc + s)); acc += s
          swaps = [b[0] for b in bounds[1:]]
      
          chrome = key_green(a.chrome, W, H)
          chrome_p = key_green(a.chrome_pressed, W, H) if a.chrome_pressed else chrome
          mask, bbox = window_mask(chrome)
          variants = [fill_card(chrome, mask, bbox, p) for p, _ in seq]
          if a.chrome_pressed:
              mask_p, bbox_p = window_mask(chrome_p)
              final_pressed = fill_card(chrome_p, mask_p, bbox_p, a.final_image)
          else:
              final_pressed = variants[-1]
          BW, BH = int(W * 1.1), int(H * 1.1)
          bgs = [make_bg(p, BW, BH) for p, _ in seq]
      
          al = chrome[..., 3]
          sh = np.zeros((H, W, 4), np.uint8); sh[..., 3] = (al * 0.5).astype(np.uint8)
          shadow = Image.fromarray(sh, "RGBA").filter(ImageFilter.GaussianBlur(38))
      
          frames = os.path.join(os.path.dirname(a.out) or ".", ".carousel_frames")
          if os.path.exists(frames):
              shutil.rmtree(frames)
          os.makedirs(frames)
      
          tap_a, tap_b = dur - 0.92, dur - 0.60
          def img_at(t):
              for i, (lo, hi) in enumerate(bounds):
                  if lo <= t < hi:
                      return i
              return len(seq) - 1
          for f in range(N):
              t = f / FPS; idx = img_at(t)
              k = 1.0 + 0.05 * (t / dur); zw, zh = int(BW * k), int(BH * k)
              fr = bgs[idx].resize((zw, zh), Image.LANCZOS)
              cxx, cyy = (zw - W) // 2, (zh - H) // 2
              fr = fr.crop((cxx, cyy, cxx + W, cyy + H)).convert("RGBA")
              p = ease_out_back(t / a.slide) if t < a.slide else 1.0
              yoff = int(round((1.0 - p) * H))
              tap = a.chrome_pressed and idx == len(seq) - 1 and tap_a <= t <= tap_b
              dy = 3 if tap else 0
              arr = final_pressed if tap else variants[idx]
              a_mul = min(t / 0.33, 1.0)
              shp = shadow
              if a_mul < 1.0:
                  sa = np.asarray(shadow).copy(); sa[..., 3] = (sa[..., 3] * a_mul).astype(np.uint8)
                  shp = Image.fromarray(sa, "RGBA")
              fr.alpha_composite(shp, (0, yoff + 22 + dy))
              lay = arr
              if a_mul < 1.0:
                  lay = arr.copy(); lay[..., 3] = (lay[..., 3] * a_mul).astype(np.uint8)
              fr.alpha_composite(Image.fromarray(lay, "RGBA"), (0, yoff + dy))
              fr.convert("RGB").save(f"{frames}/f{f:04d}.png")
      
          silent = a.out + ".silent.mp4"
          subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-framerate", str(FPS),
                          "-i", f"{frames}/f%04d.png", "-c:v", "libx264", "-pix_fmt", "yuv420p",
                          "-crf", "18", "-movflags", "+faststart", silent], check=True)
          if a.no_audio:
              shutil.move(silent, a.out)
          else:
              aud = a.out + ".aud.wav"
              synth_audio(aud, dur, swaps, (tap_a + 0.05) if a.chrome_pressed else None)
              subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", silent, "-i", aud,
                              "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy", "-c:a", "aac",
                              "-b:a", "192k", "-shortest", a.out], check=True)
              os.remove(silent); os.remove(aud)
          shutil.rmtree(frames)
          print(f"wrote {a.out}  ({dur:.2f}s, {N} frames, {len(imgs)} images + final)")
      
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 1.2 KB
      {
        "_comment": "Brand-neutral example. The recipe binds a real brand's own line, product photos, and payoff here. Every path is an absolute-path placeholder to REPLACE — never a repo-relative or /Users path. Duration is DERIVED = first_hold + (N-1)*per + final_hold; add images or raise `per` to lengthen (never trim the audio short). Use 6-16 REAL product photos; end on a lineup/range payoff. Chroma contract is load-bearing: neither #00e000 green nor #ff00ff magenta may appear in the card art or any product photo.",
        "brand": "acme.",
        "message": "would like to share a candle",
        "tagline": "The bestselling collection - 4.9 stars",
        "wordmark_svg": "/abs/path/brand-wordmark.svg",
        "accent": "#d98695",
        "band_color": "#f5e9da",
        "width": 1080,
        "height": 1920,
        "images": [
          "/abs/path/products/hero-01.png",
          "/abs/path/products/hero-02.png",
          "/abs/path/products/hero-03.png",
          "/abs/path/products/hero-04.png",
          "/abs/path/products/hero-05.png",
          "/abs/path/products/hero-06.png",
          "/abs/path/products/hero-07.png",
          "/abs/path/products/hero-08.png"
        ],
        "final_image": "/abs/path/products/lineup-family.jpg",
        "timing": { "per": 0.34, "first_hold": 1.0, "final_hold": 2.1, "slide": 0.72 }
      }
      
    • one_shot.py 4.3 KB
      #!/usr/bin/env python3
      """one_shot.py — end-to-end AirDrop product-carousel ad from a single config.
      
      Reads a JSON config, then: build_card.py -> headless-Chrome screenshot of both
      card states -> compose_carousel.py. One call, one MP4. Portable: every path comes
      from --config, everything is written under --work-dir (defaults next to --out).
      
      Config schema (see scripts/config.example.json):
      {
        "brand": "acme.",
        "message": "would like to share a candle",
        "tagline": "The bestselling collection - 4.9 stars",
        "wordmark_svg": "/abs/path/logo.svg",     # optional; else text wordmark = brand
        "accent": "#d98695",                       # Accept-button / brand accent
        "band_color": "#f5e9da",
        "images": ["/abs/p1.png", "/abs/p2.png", ...],   # ordered carousel (real product photos)
        "final_image": "/abs/lineup.jpg",          # payoff held at the end
        "width": 1080, "height": 1920,             # optional (default 9:16 1080x1920)
        "timing": {"per": 0.34, "first_hold": 1.0, "final_hold": 2.1, "slide": 0.72}
      }
      
      Duration is DERIVED: first_hold + (N-1)*per + final_hold. Add images or raise
      `per` to lengthen; never trim the audio short.
      
      The screenshot step uses Playwright if installed (`pip install playwright &&
      playwright install chromium`). If Playwright is unavailable, run build_card.py
      yourself, screenshot chrome.html / chrome-pressed.html (fullPage) to
      chrome-green.png / chrome-green-pressed.png via the chrome-devtools MCP, then call
      compose_carousel.py directly — see SKILL.md Phase 3.
      """
      import argparse, json, os, subprocess, sys
      
      HERE = os.path.dirname(os.path.abspath(__file__))
      
      
      def shoot(html_path, out_png, width, height):
          """Screenshot a card HTML (fullPage) via Playwright chromium."""
          try:
              from playwright.sync_api import sync_playwright
          except ImportError:
              sys.exit("Playwright not installed. Either `pip install playwright && playwright "
                       "install chromium`, or screenshot the HTML via the chrome-devtools MCP "
                       "(see SKILL.md Phase 3) and run compose_carousel.py directly.")
          with sync_playwright() as pw:
              b = pw.chromium.launch()
              pg = b.new_page(viewport={"width": width, "height": height}, device_scale_factor=2)
              pg.goto("file://" + os.path.abspath(html_path))
              pg.wait_for_timeout(300)
              pg.screenshot(path=out_png, full_page=True)
              b.close()
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--config", required=True)
          ap.add_argument("--out", required=True)
          ap.add_argument("--work-dir", default="")
          ap.add_argument("--no-audio", action="store_true")
          a = ap.parse_args()
          cfg = json.load(open(a.config))
          work = a.work_dir or os.path.join(os.path.dirname(a.out) or ".", "airdrop_work")
          os.makedirs(work, exist_ok=True)
          W, H = int(cfg.get("width", 1080)), int(cfg.get("height", 1920))
      
          # 1. card HTML
          cmd = [sys.executable, os.path.join(HERE, "build_card.py"),
                 "--brand", cfg["brand"], "--message", cfg["message"],
                 "--tagline", cfg.get("tagline", ""), "--accent", cfg.get("accent", "#d98695"),
                 "--band-color", cfg.get("band_color", "#f5e9da"), "--out-dir", work]
          if cfg.get("wordmark_svg"):
              cmd += ["--wordmark-svg", cfg["wordmark_svg"]]
          if cfg.get("wordmark_text"):
              cmd += ["--wordmark-text", cfg["wordmark_text"]]
          subprocess.run(cmd, check=True)
      
          # 2. screenshot both states
          shoot(os.path.join(work, "chrome.html"), os.path.join(work, "chrome-green.png"), W, H)
          shoot(os.path.join(work, "chrome-pressed.html"), os.path.join(work, "chrome-green-pressed.png"), W, H)
      
          # 3. compose
          tm = cfg.get("timing", {})
          cmd = [sys.executable, os.path.join(HERE, "compose_carousel.py"),
                 "--chrome", os.path.join(work, "chrome-green.png"),
                 "--chrome-pressed", os.path.join(work, "chrome-green-pressed.png"),
                 "--images", ",".join(cfg["images"]), "--final-image", cfg["final_image"],
                 "--out", a.out, "--width", str(W), "--height", str(H),
                 "--per", str(tm.get("per", 0.34)), "--first-hold", str(tm.get("first_hold", 1.0)),
                 "--final-hold", str(tm.get("final_hold", 2.1)), "--slide", str(tm.get("slide", 0.72))]
          if a.no_audio:
              cmd.append("--no-audio")
          subprocess.run(cmd, check=True)
          print(f"DONE -> {a.out}")
      
      
      if __name__ == "__main__":
          main()
      
  • tests
    • smoke-test.md 4.2 KB
      # Smoke test — render-airdrop-carousel
      
      Verifies the free deterministic assembly end-to-end. No paid calls. Needs: Python 3
      with `numpy` + `Pillow`, `ffmpeg`/`ffprobe`, and (for the screenshot step) Playwright
      chromium. If Playwright/browsers aren't installed, skip Phase A and validate the
      FFmpeg/PIL assembly (Phase B) with the two bundled HTML files rendered via the
      chrome-devtools MCP, or with synthetic card PNGs.
      
      ## Setup
      
      ```bash
      cd scripts
      python3 -m pip install pillow numpy            # if not present
      mkdir -p /tmp/airdrop-smoke
      # Copy the example config and REPLACE every /abs/path/... placeholder:
      #  - "images": 6-16 real product photos (absolute paths)
      #  - "final_image": a lineup/family payoff shot
      #  - "wordmark_svg": a brand wordmark SVG (or delete the key for a text wordmark)
      cp config.example.json /tmp/airdrop-smoke/config.json
      ```
      
      ## Phase A — one-shot (Playwright present)
      
      ```bash
      python3 one_shot.py --config /tmp/airdrop-smoke/config.json \
                          --work-dir /tmp/airdrop-smoke \
                          --out /tmp/airdrop-smoke/final.mp4 --no-audio
      ```
      
      Writes `chrome.html` + `chrome-pressed.html`, screenshots both to
      `chrome-green.png` / `chrome-green-pressed.png`, then composes `final.mp4`.
      
      ## Phase B — manual (no Playwright / browsers not installed)
      
      ```bash
      # 1. Build the two card HTML files.
      python3 build_card.py --brand "acme." --message "would like to share a candle" \
                            --tagline "The bestselling collection - 4.9 stars" \
                            --accent "#d98695" --band-color "#f5e9da" \
                            --out-dir /tmp/airdrop-smoke
      
      # 2. Screenshot chrome.html / chrome-pressed.html (fullPage) to
      #    chrome-green.png / chrome-green-pressed.png via the chrome-devtools MCP
      #    (load file:///tmp/airdrop-smoke/chrome.html, fullPage screenshot).
      #    -- OR, to validate the assembly ONLY, synthesize a card PNG: a 1080x1920
      #    green (#00e000) frame with a solid magenta (#ff00ff) rectangle where the
      #    preview window sits, saved as chrome-green.png (+ a copy chrome-green-pressed.png).
      
      # 3. Compose from the screenshots + real product images.
      python3 compose_carousel.py \
        --chrome /tmp/airdrop-smoke/chrome-green.png \
        --chrome-pressed /tmp/airdrop-smoke/chrome-green-pressed.png \
        --images "/abs/p1.png,/abs/p2.png,/abs/p3.png" \
        --final-image "/abs/lineup.jpg" \
        --out /tmp/airdrop-smoke/final.mp4 --no-audio
      ```
      
      ## Expect
      
      - `build_card.py` writes `chrome.html` + `chrome-pressed.html`. The card is centered
        on a pure-green page with a magenta preview window and a brand band (wordmark +
        uppercase tagline); the `Accept` button is the accent color; `chrome-pressed.html`
        shades the Accept cell.
      - `compose_carousel.py` reports `wrote … (<dur>s, <N> frames, <k> images + final)`.
        `final.mp4` — 1080×1920, ~6–8s. The AirDrop card springs UP (ease-out-back), the
        preview window hard-cuts through each product on rhythm, and it lands on the payoff
        image with a visible Accept tap-highlight. NO green or magenta leaks anywhere in-frame.
      - Drop `--no-audio` to get the synth track — a chime on card-land, a tick per swap, a
        pop on the tap (no external audio files needed).
      - `ffprobe` confirms dimensions/duration; run the `watch` skill on the master to confirm
        every product FULLY fills the window, the card text + wordmark are crisp, the carousel
        reads and lands on the payoff, and the audio ticks track the swaps.
      
      ## Fail signals
      
      - **Magenta shows inside the preview window** → a product image didn't cover-crop the
        window (too small / wrong aspect), or `window_mask` missed the magenta region.
      - **Green haze / halo around the card** → the green key threshold is off, or the card
        art itself contains near-`#00e000` green (violates the chroma contract — recolor it).
      - **"no magenta preview window found"** → the screenshot lost the `#ff00ff` window
        (wrong HTML, JPEG-compressed screenshot). Re-screenshot chrome.html as PNG, fullPage.
      - **UI text looks smeared/warped** → something AI-rendered the card; this engine is
        HTML→PNG + PIL only, never a video model.
      - **Video ends abruptly / audio cut short** → someone trimmed duration; it is DERIVED
        from `first_hold + (N-1)·per + final_hold` — add images or raise `per` instead.
      
  • SKILL.md 4.9 KB
    ---
    name: render-airdrop-carousel
    description: Assemble a viral iOS "AirDrop" notification-carousel video ad (≈6–8s, 9:16) from a brand line plus 6–16 real product photos — a native AirDrop share-sheet card ("Brand would like to share a ___ · Decline / Accept") springs up and its preview window CYCLES through the products, landing on a range/lineup payoff with an Accept tap; a chime + soft per-swap ticks track the swaps. DETERMINISTIC assembly — an HTML card (real DOM text) rendered to PNG via headless Chrome, chroma-keyed, its magenta window refilled per-product in PIL, then animated + audio-synthed with FFmpeg. FREE (no paid model calls); the recipe supplies the brand line, product images, and payoff and gates the only optional paid step (a hero shot when the brand has NO usable photo → create-image-fal). Use for the airdrop-notification-carousel format.
    status: active
    ---
    
    # render-airdrop-carousel
    
    The free, deterministic renderer for the **airdrop-notification-carousel** video ad
    format — the viral iOS "AirDrop" trend where a native share-sheet card
    ("**Brand** would like to share a *candle* · Decline / Accept") springs up on the
    phone and its preview window flips through a **carousel of real product photos**,
    landing on a range/lineup payoff with an Accept tap.
    
    This is a DETERMINISTIC composite — **no generative video, no AI-rendered product**.
    A real-DOM AirDrop card is rendered once to PNG (headless Chrome), chroma-keyed, and
    its preview window is refilled per-product in PIL, then animated + audio-synthed with
    FFmpeg. The whole point of the format is crisp system-UI text and **real** product
    photography, both of which a video model would smear. This capability makes **no paid
    calls**; the recipe gates the only optional paid step — generating a hero product shot
    when the brand has NO usable photo at all (→ `create-image-fal`).
    
    Default output ≈ **6–8s**, 1080×1920, h264 + aac. Duration is DERIVED, not trimmed —
    `first_hold + (N-1)·per + final_hold`. Add images or raise `per` to lengthen.
    
    ## Scripts (free)
    
    - `scripts/build_card.py` — brand params → `chrome.html` + `chrome-pressed.html`: the
      AirDrop card on a **green page** (`#00e000`) with a **magenta preview window**
      (`#ff00ff`) and a solid brand band (wordmark SVG or text + tagline). Real DOM text —
      `AirDrop`, `Decline`, `Accept`, and the brand line are DOM/SVG, never AI-rendered.
    - `scripts/one_shot.py` — glue: `build_card` → headless-Chrome (Playwright) fullPage
      screenshot of both card states → `compose_carousel`. One `--config`, one MP4.
    - `scripts/compose_carousel.py` — the render engine: green-key the card → detect the
      magenta window → fill it per-product (cover-crop) → blurred per-product backdrop +
      push-in → card spring-up (iOS ease-out-back) + carousel + Accept tap → synth audio
      (whoosh on entry, chime on land, a tick per swap, a pop on the tap) → encode h264+aac.
    - `scripts/config.example.json` — the shape of the brand `config` the recipe binds
      (brand-neutral worked defaults; replace every `/abs/path/...` placeholder).
    
    ## Chroma contract (load-bearing — build_card.py and compose_carousel.py MUST share it)
    
    `#00e000` **green** = page background, keyed to the card's alpha. `#ff00ff` **magenta**
    = preview window, replaced per product. **Neither color may appear in the card art or
    any product photo** — a product image containing near-pure green or magenta gets
    keyed/misread. Swap the image or tighten the `window_mask` thresholds if it bleeds.
    
    ## Craft rules (faithful to the source molecule)
    
    - **Real product photos only.** The format's credibility is that these look like real
      AirDropped items — no AI-generated products in the carousel.
    - **Never AI-render text** — the card UI, wordmark, and band are DOM/SVG composited so
      they stay pixel-crisp. Cover-crop the window; white-bg PDP shots reading as "product
      in a white tile" is on-brand and fine.
    - **6–16 images**, ordered; **end on the range** — the payoff (`final_image`) should
      read as "the whole line" (lineup / family / all-shades).
    - **Hard cuts on rhythm, a tick per swap** — do NOT crossfade.
    - **Never invent proof** — a `1M+ sold` tagline must be the brand's OWN stated figure,
      not filled from a brand-kit proof section.
    - **Duration is derived, not trimmed** — add images or raise `per`; never cut the audio.
    
    ## Requires
    
    - **Python 3** with `numpy` + `Pillow`, and **ffmpeg/ffprobe** on PATH.
    - **Playwright chromium** for the screenshot step (`pip install playwright && playwright
      install chromium`). If Playwright is unavailable, run `build_card.py`, screenshot
      `chrome.html` / `chrome-pressed.html` (fullPage) → `chrome-green.png` /
      `chrome-green-pressed.png` via the chrome-devtools MCP, then call `compose_carousel.py`
      directly.
    - `watch` (QC the final master). The recipe gates `create-image-fal` (optional hero shot
      when the brand has NO usable product photo) — the only paid, proxy-routed step.
    
  • skill.meta.json 323 B
    {
      "slug": "render-airdrop-carousel",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-airdrop-carousel",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related