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
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-airdrop-carousel
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
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 brandconfigthe 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+ soldtagline 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, runbuild_card.py, screenshotchrome.html/chrome-pressed.html(fullPage) →chrome-green.png/chrome-green-pressed.pngvia the chrome-devtools MCP, then callcompose_carousel.pydirectly. watch(QC the final master). The recipe gatescreate-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)", "®").replace(" - ", " · ") 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.
Reviews (0)
No reviews yet.
No comments yet.