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
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-imessage-cascade
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-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 fromconfig: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) viaset_variation_by_namefor 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 overlayyexpressions) → 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 brandconfigthe 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.
Reviews (0)
No reviews yet.
No comments yet.