render-offer-ad
Render a punchy ~12s vertical (9:16) music-only direct-response OFFER ad as a 4-beat kinetic-typography film — HEADLINE slam → real PRODUCT drop → CLAIM/proof → CTA pill — from one config of copy slots, a real product photo, a brand palette, fonts, bpm, and beat split. DETERMINIS
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-offer-ad
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-offer-ad
The free, deterministic renderer for the motion-graphics-offer-ad format — the punchy ~12s vertical, music-only, direct-response offer ad built as a 4-beat kinetic -typography film: a HEADLINE slams in word-by-word → the real PRODUCT drops in → the CLAIM/proof lands → a CTA pill resolves. No character, no VO, no captions — the on-screen typeset text IS the message.
This is a bundled Remotion project (project/) driven by a thin Python driver. The
shipping master is 100% engine-rendered (springs + interpolate): backgrounds are
gradient divs off the brand_palette, props are inline SVG, and the ONLY composited
bitmap is the REAL product photo (objectFit:contain, never stretched). ALL
headline/claim/CTA/URL/wordmark text is typeset in the engine — never AI-rendered;
that is the format's credibility guard. Render cost is ~$0; the only paid step is an
optional music bed, gated upstream in the recipe to create-music-elevenlabs.
The whole ad is data: copy strings, product photo, palette, fonts, bpm, and beat
split all arrive as config.json and are bound to Remotion input props — nothing is
hardcoded in the scenes (the source run's Spoiled Child strings are generalised into
project/src/props.ts). Deterministic → iterate the cut for free.
The 4 beats (the spine)
- HEADLINE — primary-color radial ground;
headline_wordsslam in WORD-BY-WORD (slamIn, ~7-frame stagger, scale-overshoot + motion-blur smear, settling on the downbeat);subline+ an animated bobbing down-arrow drop in. - PRODUCT — light radial ground; the REAL product photo drops in (
dropIn) and idle-bobs (bob),objectFit:contain(never stretch); themotif_chippops in (popIn); optional GENERIC competitor shape + strike-through (wipe) — never a named competitor. - CLAIM — light radial ground; the
mechanism_propslides in from a frame edge (flyInovershoot, ~20% from the bottom) to add motion AND show the mechanism; the 3-line claim drops in staggered; product held bottom-right. - CTA — primary-color radial ground; the
wordmarkslams (slamIn); the CTA pill pops in as the motif-chip handoff resolving (popIn) with an arrow nudge; thecta_urlfades up.
Scene boundaries are derived from beat_split_sec (default [3.0, 3.5, 3.0, 2.5]s →
cuts at frames 0/90/195/285/360 @30fps) so the hard cuts land on the beat downbeats. The
motion kit — slamIn / dropIn / bob / flyIn / popIn / wipe — lives in
project/src/lib/anim.ts and is kept intact from the source run.
Two gating checks (run before any render — from the recipe)
- (a) CLAIM-MATCHES-FORMAT — the claim/proof verbs MUST match the product's physical
format: liquid →
spoon / sip / drink / 0 mess; powder →scoop / mix / no clumps; capsule →1 a day; gummy →chew.render.pyrejects foreign-format vocabulary (e.g. a liquid product must not borrow powder grammar likescoop / no clumps). Setproduct_formatin the config to arm this gate. - (b) CLAIM-BEAT MECHANISM PROP — the claim beat must include an edge-entry
mechanism_prop(spoonfor drinkable liquids, oraccentfor a neutral edge-entry accent bar) that supplies motion AND shows the mechanism. A text-only claim beat is too static —render.pyrejects an unsupported/missing prop.
Run
# 1) install the bundled Remotion project's deps (one-time; render.py auto-runs
# this if node_modules is absent).
cd project && npm install && cd ..
# 2) bind config → Remotion input props, render the 9:16 master, derive the 1:1 crop.
python3 scripts/render.py \
--config path/to/config.json \
--work-dir <work> \
--out <work>/master
# → <work>/master-9x16.mp4 (1080x1920) and <work>/master-1x1.mp4 (1080x1080 crop)
render.py copies the config's product_hero_image (+ optional music.bed) into
project/public/, maps every config key onto the OfferAdProps shape props.ts reads,
writes the input props to <work>/props.json, runs npx remotion render offer-ad --props <work>/props.json, then center-crops the 9:16 master to 1:1 with ffmpeg (no 2nd
composition). NO hardcoded /Users or clients paths — every asset arrives via the
config and a runtime --work-dir.
Scripts
scripts/render.py— the driver: gating checks → stage assets intoproject/public/→ bind config to Remotion input props →npx remotion render(9:16) → ffmpeg 1:1 center-crop.scripts/config.example.json— the shape of theconfigthe recipe binds (brand-neutral worked defaults from the source run; replace every/abs/...placeholder).project/— the bundled Remotion project.src/props.ts(the input-props schema + beat-layout math + safe defaults),src/lib/anim.ts(theslamIn/dropIn/bob/flyIn/ popIn/wipemotion kit — kept intact),src/scenes/Scene01–04.tsx(the four beats, all copy/palette/fonts from props),src/Main.tsx(the beat spine + audio bed),src/Root.tsx(theoffer-adcomposition; duration derived frombeat_split_sec).
Config → props mapping (what binds to what)
| config key | Remotion prop (props.ts) |
scene that reads it |
|---|---|---|
product_hero_image |
product_image (staged to public/) |
Scene02 (drop), Scene03 (anchor) |
brand_palette{primary_ground,light_ground,ink,highlight_chip} (dict OR ≥4-hex list) |
palette |
all 4 (grounds/type/chip) |
fonts{display,body} |
fonts (resolved in fonts.ts) |
all 4 |
copy.headline_words[] / copy.subline |
copy.headline_words / copy.subline |
Scene01 |
copy.motif_chip |
copy.motif_chip |
Scene02 |
copy.claim_lines[]{big,small} |
copy.claim_lines |
Scene03 |
copy.cta_label / copy.cta_url / copy.wordmark |
same | Scene04 |
mechanism_prop (spoon|accent) |
mechanism_prop |
Scene03 (inline-SVG prop) |
bpm |
bpm |
beat grid |
beat_split_sec [4] |
beat_split_sec |
derives cut frames + total duration |
music.bed / music.fade_out_frames |
music.src (staged) / music.fade_out_frames |
Main audio bed |
show_competitor_strike |
show_competitor_strike |
Scene02 |
aspects |
(driver) | 9:16 always; 1:1 = ffmpeg crop |
Craft rules (load-bearing — faithful to the source run)
- The composited bitmap is the REAL product photo — never an AI packshot. Clean /
transparent bg;
objectFit:contain, never stretched. - Never AI-render text. Headline, claim, CTA, URL, wordmark — all typeset in the engine. AI never draws a letter in this format.
- Never Ken-Burns/zoompan over stills — the engine springs carry the motion; a static/zoompan cut was rejected as "too static" on the source run.
- Music-only, beat-locked — the cuts + big slams land on the 0.0/3.0/6.5/9.5s
downbeats; the bed volume ramps to 0 over the last
music.fade_out_framesinside the render. No VO, no captions. - Never name/jab a competitor — any comparison shape on the product beat is a GENERIC
silhouette (
show_competitor_strike), not a named brand. - The two gating checks are non-optional — a liquid product must not borrow powder grammar; the claim beat must have real prop motion, not a static text stack.
Requires
- Node.js (≥18) + Remotion (
@remotion/cli≥4.0, installed via the bundledproject/package.json—render.pyrunsnpm installinproject/on first use). This capability legitimately needs a node runtime — the renderer IS a Remotion project. - ffmpeg on PATH (for the 1:1 center-crop).
watch(QC the final master — confirm the headline slams WORD-BY-WORD on the beat, the real product photo drops in + idle-bobs and is NOT stretched, the mechanism prop enters from a frame edge on the claim beat, the claim lines drop in staggered, the CTA label + URL are readable at 1080×1920, the hard cuts land on the downbeats, and the music ramps out; re-confirm both gating checks visually). The recipe gates the only optional paid step (music bed →create-music-elevenlabs) — this capability itself makes NO paid calls.
Files (goose-skills)
-
project
-
public
-
.gitkeep 0 B · in bundle
-
-
src
-
lib
-
anim.ts 4.8 KB
import { interpolate, spring, Easing } from "remotion"; // ---- Motion kit ----------------------------------------------------------- // The reusable spring/interpolate primitives that give this format its // signature kinetic-typography feel. Ported 1:1 from the worked run — do NOT // rewrite these; the whole format's rhythm depends on them. Colors/copy are // injected via props (see props.ts), NOT hardcoded here. // ---- Beat grid ------------------------------------------------------------ // The big slams land ON the scene-start downbeats so type hits with the kick. // FPS is fixed at 30; BEAT_FRAMES is derived from bpm at runtime (props.ts). export const FPS = 30; export function beatFrames(bpm: number): number { // frames per beat @ `bpm`, 30fps. (60 / bpm) * 30 return (60 / bpm) * FPS; } // ---- Slam-in with motion blur --------------------------------------------- // Signature kinetic entrance: word punches in oversized with a vertical // motion-blur smear, overshoots, then settles sharp on the beat. export interface SlamOpts { damping?: number; stiffness?: number; mass?: number; fromScale?: number; blurFrames?: number; blurAmount?: number; fromY?: number; } export function slamIn(local: number, fps: number, opts: SlamOpts = {}) { const { damping = 11, stiffness = 220, mass = 0.7, fromScale = 1.7, blurFrames = 6, blurAmount = 22, fromY = 0, } = opts; const s = spring({ frame: local, fps, config: { damping, stiffness, mass } }); const scale = fromScale + (1 - fromScale) * s; const opacity = interpolate(local, [0, 3], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const blur = interpolate(local, [0, blurFrames], [blurAmount, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.quad), }); const y = interpolate(local, [0, 8], [fromY, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.16, 1, 0.3, 1), }); return { transform: `translateY(${y}px) scale(${scale})`, opacity, filter: blur > 0.3 ? `blur(${blur}px)` : undefined, } as React.CSSProperties; } // Drop-in from above with settle bounce (the product bottle, drop-style words). export function dropIn( local: number, fps: number, fromY: number, opts: { damping?: number; stiffness?: number; mass?: number; blur?: number } = {} ) { const { damping = 13, stiffness = 170, mass = 0.9, blur = 18 } = opts; const s = spring({ frame: local, fps, config: { damping, stiffness, mass } }); const y = fromY * (1 - s); const opacity = interpolate(local, [0, 4], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const mb = interpolate(local, [0, 7], [blur, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.out(Easing.quad), }); return { y, opacity, blur: mb }; } // Gentle continuous bob (idle float for a settled hero element). export function bob(local: number, amp = 10, periodFrames = 70) { return Math.sin((local / periodFrames) * Math.PI * 2) * amp; } // Fly-in from a screen edge with overshoot (mechanism prop, columns, chips). export function flyIn(local: number, fps: number, fromX: number, fromYExtra = 0) { const s = spring({ frame: local, fps, config: { damping: 16, stiffness: 200, mass: 0.7 } }); const x = fromX * (1 - s); const y = fromYExtra * (1 - s); const opacity = interpolate(local, [0, 5], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); return { x, y, opacity }; } // Sticker-pop: scale-bounce from tiny with overshoot, for chips/pills. export function popIn( local: number, fps: number, opts: { damping?: number; stiffness?: number; mass?: number; rotate?: number } = {} ) { const { damping = 9, stiffness = 240, mass = 0.6, rotate = 0 } = opts; const s = spring({ frame: local, fps, config: { damping, stiffness, mass } }); const scale = interpolate(s, [0, 1], [0, 1]); const opacity = interpolate(local, [0, 3], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", }); const rot = rotate * (1 - s); return { scale, opacity, rot }; } // Horizontal wipe progress 0->1 over `len` frames (strike-through reveal). export function wipe(local: number, startAt: number, len: number) { return interpolate(local, [startAt, startAt + len], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.bezier(0.16, 1, 0.3, 1), }); } // Quick exit smear: scales up + blurs out right before a hard cut. export function exitSmear(local: number, outAt: number, len = 5) { const t = interpolate(local, [outAt - len, outAt], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp", easing: Easing.in(Easing.quad), }); return { opacity: 1 - t, blur: t * 16, scaleAdd: t * 0.25 }; } export const SNAP = Easing.bezier(0.16, 1, 0.3, 1);
-
-
scenes
-
Scene01.tsx 5 KB · in bundle
-
Scene02.tsx 4.9 KB · in bundle
-
Scene03.tsx 6.5 KB · in bundle
-
Scene04.tsx 3.5 KB · in bundle
-
-
fonts.ts 1.4 KB
// Font loading. @remotion/google-fonts needs STATIC imports (one module per // family), so we can't `import(props.fonts.display)` dynamically. Instead we // pre-load a small set of format-appropriate families and resolve the config's // requested id to a loaded family at runtime. Default display = a heavy black // slam sans (Archivo Black); default body = a clean sans (Inter). // // To add another display/body option: add its `@remotion/google-fonts/<Name>` // import here and register it in the FONTS map. The config passes the map KEY // (e.g. "ArchivoBlack") in fonts.display / fonts.body. import { loadFont as loadArchivoBlack } from "@remotion/google-fonts/ArchivoBlack"; import { loadFont as loadInter } from "@remotion/google-fonts/Inter"; import { loadFont as loadAnton } from "@remotion/google-fonts/Anton"; import { loadFont as loadMontserrat } from "@remotion/google-fonts/Montserrat"; const archivoBlack = loadArchivoBlack(); const inter = loadInter(); const anton = loadAnton(); const montserrat = loadMontserrat(); const FONTS: Record<string, string> = { ArchivoBlack: archivoBlack.fontFamily, Anton: anton.fontFamily, Inter: inter.fontFamily, Montserrat: montserrat.fontFamily, }; // Resolve a config font id to a loaded family, falling back sensibly. export function resolveFont(id: string | undefined, role: "display" | "body"): string { if (id && FONTS[id]) return FONTS[id]; return role === "display" ? FONTS.ArchivoBlack : FONTS.Inter; } -
index.ts 108 B
import { registerRoot } from "remotion"; import { RemotionRoot } from "./Root"; registerRoot(RemotionRoot); -
Main.tsx 1.5 KB · in bundle
-
props.ts 4.1 KB
import { FPS, beatFrames } from "./lib/anim"; // --------------------------------------------------------------------------- // OfferAdProps — the entire ad is data. Everything the four beat-scenes render // (copy strings, palette, fonts, product photo, mechanism prop, bpm, beat // split, music) arrives here via Remotion input props (`--props config.json`). // NOTHING is hardcoded in the scenes. See scripts/config.example.json for a // worked, brand-neutral example. // --------------------------------------------------------------------------- export interface Palette { primary_ground: string; // beats 1 & 4 ground (the "rust" role in the source run) light_ground: string; // beats 2 & 3 ground (the "cream" role) ink: string; // dark type on light grounds highlight_chip: string; // chip/pill border accent (the "soft pink" role) } export interface Copy { headline_words: string[]; // beat 1 — slams in word-by-word (e.g. ["COLLAGEN","POWDER","IS DEAD."]) subline: string; // beat 1 — the smaller line under the headline (e.g. "the new way") motif_chip: string; // beat 2 — the sticker-chip over the product (e.g. "DRINKABLE") claim_lines: { big: string; small: string }[]; // beat 3 — 3 proof rows (e.g. {big:"10g",small:"collagen"}) cta_label: string; // beat 4 — CTA pill text (e.g. "Try E27") cta_url: string; // beat 4 — URL under the pill (e.g. "spoiledchild.com") wordmark: string; // beat 4 — brand wordmark that slams first } // The mechanism prop for beat 3 (the claim beat). MUST enter from a frame edge // (flyIn) so the claim beat has motion AND shows the mechanism. "spoon" is the // bundled worked default (a metal tablespoon w/ amber liquid, for "drinkable"). // "accent" is the neutral edge-entry fallback shape when no specific prop fits. export type MechanismProp = "spoon" | "accent"; export interface Music { src: string | null; // public/ filename of the bed, or null to render silent fade_out_frames: number; // ramp volume to 0 over the last N frames (default 18) } export interface OfferAdProps { palette: Palette; copy: Copy; product_image: string; // public/ filename of the REAL product photo (objectFit:contain) mechanism_prop: MechanismProp; bpm: number; // drives the beat-locked cut frames (default 115) beat_split_sec: [number, number, number, number]; // [headline, product, claim, cta] seconds fonts: { display: string; body: string }; // @remotion/google-fonts family ids resolved in fonts.ts music: Music; // strike-through generic competitor shape on beat 2 (never a named brand). show_competitor_strike: boolean; } // ---- Beat-frame math ------------------------------------------------------ // The four hard cuts land on the beat_split boundaries, rounded to whole frames. // Returns { starts, durs, total } in frames. export function beatLayout(beat_split_sec: [number, number, number, number]) { const durs = beat_split_sec.map((s) => Math.round(s * FPS)); const starts: number[] = []; let acc = 0; for (const d of durs) { starts.push(acc); acc += d; } return { starts, durs, total: acc }; } // Re-exported so scenes can pull the beat grid off the current bpm if needed. export { FPS, beatFrames }; // Safe fallback used when no input props are supplied (Remotion Studio preview // with an empty --props). Mirrors config.example.json (brand-neutral). export const DEFAULT_PROPS: OfferAdProps = { palette: { primary_ground: "#C96E2F", light_ground: "#FAFAFA", ink: "#0A0B0D", highlight_chip: "#F2C9C2", }, copy: { headline_words: ["POWDER", "IS", "OVER."], subline: "the new way", motif_chip: "DRINKABLE", claim_lines: [ { big: "10g", small: "protein" }, { big: "1", small: "spoon" }, { big: "0", small: "mess" }, ], cta_label: "Try it", cta_url: "yourbrand.com", wordmark: "your brand", }, product_image: "product.png", mechanism_prop: "spoon", bpm: 115, beat_split_sec: [3.0, 3.5, 3.0, 2.5], fonts: { display: "ArchivoBlack", body: "Inter" }, music: { src: null, fade_out_frames: 18 }, show_competitor_strike: true, }; -
Root.tsx 1 KB · in bundle
-
-
.gitignore 143 B · in bundle
-
package.json 503 B
{ "name": "offer-ad", "version": "1.0.0", "private": true, "scripts": { "render": "remotion render offer-ad" }, "dependencies": { "@remotion/bundler": "^4.0.468", "@remotion/cli": "^4.0.468", "@remotion/google-fonts": "^4.0.468", "@remotion/renderer": "^4.0.468", "react": "^19.2.6", "react-dom": "^19.2.6", "remotion": "^4.0.468" }, "devDependencies": { "@types/react": "^19.2.15", "@types/react-dom": "^19.2.3", "typescript": "^6.0.3" } } -
remotion.config.ts 116 B
import { Config } from "@remotion/cli/config"; Config.setVideoImageFormat("jpeg"); Config.setOverwriteOutput(true); -
tsconfig.json 293 B
{ "compilerOptions": { "target": "ES2022", "module": "commonjs", "jsx": "react-jsx", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true }, "include": ["src"] }
-
-
scripts
-
config.example.json 2.1 KB
{ "_comment": "Illustrative worked example — brand-neutralised from the validated 'drinkable collagen' offer-ad run. The recipe binds a NEW brand's own product photo / palette / copy / music here. Replace every /abs/or/working/... path with a REAL absolute path or a runtime working/ path. ALL text is typeset in the engine (never AI-rendered). The ONLY composited bitmap is the REAL product photo (objectFit:contain, never stretched) — an AI-rendered packshot is an instant fail.", "_notes": { "cta_label": "Do NOT end cta_label with an arrow — Scene04 appends its own '→' (render.py also strips a stray trailing arrow, but keep the source clean).", "mechanism_prop": "One of TWO engine presets only: 'spoon' (drinkable/liquid) or 'accent' (neutral bar; use for SaaS/software & anything non-CPG). Not arbitrary SVG.", "product_format": "Optional. Physical: liquid/powder/capsule/gummy/cream/serum. Digital: software/saas/app. Omit for a product that fits none → the claim-matches-format gate skips.", "saas": "For a SaaS/software product: product_hero_image = a REAL app UI screenshot (keeps its own bg — fine), product_format = 'saas', mechanism_prop = 'accent'." }, "product_hero_image": "/abs/or/working/raw-materials/product-hero.webp", "product_format": "liquid", "brand_palette": { "primary_ground": "#C96E2F", "light_ground": "#FAFAFA", "ink": "#0A0B0D", "highlight_chip": "#F2C9C2" }, "fonts": { "display": "ArchivoBlack", "body": "Inter" }, "copy": { "headline_words": ["POWDER", "IS", "OVER."], "subline": "the new way", "motif_chip": "DRINKABLE", "claim_lines": [ { "big": "10g", "small": "collagen" }, { "big": "1", "small": "spoon" }, { "big": "0", "small": "mess" } ], "cta_label": "Try it", "cta_url": "yourbrand.com", "wordmark": "your brand" }, "mechanism_prop": "spoon", "show_competitor_strike": true, "bpm": 115, "beat_split_sec": [3.0, 3.5, 3.0, 2.5], "aspects": ["9:16", "1:1"], "music": { "bed": "/abs/or/working/audio/music-bed.mp3", "fade_out_frames": 18 } } -
render.py 11.8 KB
#!/usr/bin/env python3 """render.py — the DRIVER for the render-offer-ad capability. Free, deterministic. Reads a brand `--config config.json` (the shape the recipe binds — see config.example.json), binds it into the bundled Remotion project's input props, renders the 9:16 master with `npx remotion render`, then derives a 1:1 center-crop with ffmpeg. NO paid model calls, NO hardcoded /Users or clients paths — every asset arrives via the config and is staged under a runtime working dir. Two GATING CHECKS run before any render (both from the recipe): (a) CLAIM-MATCHES-FORMAT — the claim/proof verbs must match the product's physical format (liquid → spoon/sip/drink/0 mess; powder → scoop/mix/no clumps; capsule → 1 a day; gummy → chew). Reject category-default vocab that describes a DIFFERENT format. (b) CLAIM-BEAT MECHANISM PROP — the claim beat must carry an edge-entry mechanism_prop (a text-only claim beat is too static). Usage: python3 render.py --config config.json --work-dir <dir> --out <dir/master> [--aspects 9:16,1:1] [--no-crop] [--skip-gates] Outputs (under --out's directory, basename from --out): <out>-9x16.mp4 (the 1080x1920 master; always) <out>-1x1.mp4 (1080x1080 center-crop; when 1:1 in aspects) """ import argparse import json import os import shutil import subprocess import sys HERE = os.path.dirname(os.path.abspath(__file__)) def _resolve_project_dir() -> str: """Locate the bundled Remotion project/ dir robustly. Works whether render.py runs from the REPO layout (capability/scripts/render.py → ../project) OR from a FETCHED layout: `gooseworks fetch` flattens the scripts/ prefix, so render.py lands at the capability root and project/ is a sibling (→ ./project). Try both, then a short upward walk; match on package.json. """ candidates = [ os.path.join(HERE, "project"), # fetched: render.py at cap root os.path.join(os.path.dirname(HERE), "project"), # repo: render.py in scripts/ ] d = HERE for _ in range(4): d = os.path.dirname(d) candidates.append(os.path.join(d, "project")) for c in candidates: if os.path.isfile(os.path.join(c, "package.json")): return c return os.path.join(os.path.dirname(HERE), "project") # repo-layout default PROJECT_DIR = _resolve_project_dir() COMPOSITION_ID = "offer-ad" # ---- physical-format → allowed claim vocabulary (gating check a) ----------- FORMAT_VOCAB = { "liquid": {"spoon", "sip", "drink", "drinkable", "pour", "mess", "shot", "swig"}, "powder": {"scoop", "mix", "stir", "clump", "clumps", "shake", "blend", "powder"}, "capsule": {"capsule", "pill", "swallow", "daily", "day", "caps"}, "gummy": {"chew", "gummy", "gummies", "bite"}, "cream": {"apply", "rub", "spread", "dab", "massage"}, "serum": {"drop", "apply", "dropper", "absorb"}, # digital products (SaaS / software / app) — the mechanism is the workflow, not a # physical action. Recognized so the gate doesn't WARN "unknown"; no forbidden set. "software": {"search", "prompt", "click", "type", "sync", "one"}, "saas": {"search", "prompt", "click", "type", "sync", "one"}, "app": {"search", "prompt", "click", "tap", "type", "sync"}, } # vocab that belongs to a DIFFERENT format and must NOT appear (mismatch flag) FORMAT_FORBIDDEN = { "liquid": {"scoop", "clump", "clumps", "chew", "gummy"}, "powder": {"sip", "drink", "chew", "gummy", "swallow"}, "capsule": {"scoop", "sip", "chew"}, "gummy": {"scoop", "sip", "swallow"}, "cream": {"drink", "sip", "chew", "swallow"}, "serum": {"drink", "chew", "swallow"}, # digital products have no physical-format vocabulary to collide with. "software": set(), "saas": set(), "app": set(), } def die(msg: str) -> None: print(f"[render-offer-ad] ERROR: {msg}", file=sys.stderr) sys.exit(1) def gate_claim_matches_format(cfg: dict) -> None: """(a) claim verbs must match the product's physical format.""" fmt = (cfg.get("product_format") or "").strip().lower() if not fmt: print( "[render-offer-ad] WARN: no product_format set — skipping " "claim-matches-format gate. Set product_format (liquid/powder/" "capsule/gummy/cream/serum) to enable it." ) return if fmt not in FORMAT_FORBIDDEN: print(f"[render-offer-ad] WARN: unknown product_format '{fmt}' — gate skipped.") return claim_words = set() for row in cfg.get("copy", {}).get("claim_lines", []): for field in ("big", "small"): claim_words |= {w.strip(".,!").lower() for w in str(row.get(field, "")).split()} # also scan the motif chip (it often names the mechanism, e.g. DRINKABLE) claim_words |= {w.strip(".,!").lower() for w in str(cfg.get("copy", {}).get("motif_chip", "")).split()} bad = claim_words & FORMAT_FORBIDDEN[fmt] if bad: die( f"CLAIM-MATCHES-FORMAT gate failed: product_format='{fmt}' but the " f"claim/motif copy uses foreign-format verbs {sorted(bad)}. Rewrite the " f"claim to the product's real format (e.g. liquid → 'spoon / sip / 0 mess', " f"powder → 'scoop / mix / no clumps'). Use --skip-gates only if intentional." ) def gate_mechanism_prop(cfg: dict) -> None: """(b) the claim beat needs an edge-entry mechanism prop.""" prop = (cfg.get("mechanism_prop") or "").strip().lower() if prop not in ("spoon", "accent"): die( "CLAIM-BEAT MECHANISM PROP gate failed: mechanism_prop must be a " "supported edge-entry prop ('spoon' for drinkable liquids, or 'accent' " "for a neutral edge-entry accent bar). A text-only claim beat is too " "static. Got: " + repr(cfg.get("mechanism_prop")) ) def clean_cta_label(label: str) -> str: """Scene04 ALWAYS appends its own '→' arrow. Strip a trailing arrow (and whitespace) the operator may have typed into cta_label so it never renders a double arrow ('Try it → →').""" s = str(label).rstrip() for arrow in ("→", "->", "➔", "➜", "»", "▶", "➡"): if s.endswith(arrow): s = s[: -len(arrow)].rstrip() break return s def stage_asset(src: str, public_dir: str, dest_name: str) -> str: """Copy an asset into the project's public/ and return the basename.""" if not src or not os.path.isfile(src): die(f"asset not found: {src!r} (must be an existing file)") ext = os.path.splitext(src)[1] or "" base = f"{dest_name}{ext}" shutil.copy2(src, os.path.join(public_dir, base)) return base def build_props(cfg: dict, public_dir: str) -> dict: """Map the brand config onto the OfferAdProps shape props.ts reads.""" pal = cfg["brand_palette"] # brand_palette may be a dict (roles) or a >=4-hex list (recipe allows both). if isinstance(pal, list): if len(pal) < 4: die("brand_palette list needs >=4 hexes [primary_ground, light_ground, ink, highlight_chip]") palette = { "primary_ground": pal[0], "light_ground": pal[1], "ink": pal[2], "highlight_chip": pal[3], } else: palette = { "primary_ground": pal["primary_ground"], "light_ground": pal["light_ground"], "ink": pal["ink"], "highlight_chip": pal["highlight_chip"], } product_base = stage_asset(cfg["product_hero_image"], public_dir, "product") music_cfg = cfg.get("music") or {} music_src = music_cfg.get("bed") or music_cfg.get("src") music_base = None if music_src: music_base = stage_asset(music_src, public_dir, "bed") fonts = cfg.get("fonts") or {} beat_split = cfg.get("beat_split_sec") or [3.0, 3.5, 3.0, 2.5] if len(beat_split) != 4: die("beat_split_sec must be exactly 4 values [headline, product, claim, cta]") copy = cfg["copy"] return { "palette": palette, "copy": { "headline_words": copy["headline_words"], "subline": copy.get("subline", ""), "motif_chip": copy.get("motif_chip", ""), "claim_lines": copy["claim_lines"], "cta_label": clean_cta_label(copy["cta_label"]), "cta_url": copy["cta_url"], "wordmark": copy["wordmark"], }, "product_image": product_base, "mechanism_prop": cfg.get("mechanism_prop", "accent"), "bpm": cfg.get("bpm", 115), "beat_split_sec": beat_split, "fonts": { "display": fonts.get("display", "ArchivoBlack"), "body": fonts.get("body", "Inter"), }, "music": { "src": music_base, "fade_out_frames": int(music_cfg.get("fade_out_frames", 18)), }, "show_competitor_strike": bool(cfg.get("show_competitor_strike", True)), } def run(cmd, cwd=None): print("[render-offer-ad] $", " ".join(cmd)) subprocess.run(cmd, cwd=cwd, check=True) def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--config", required=True, help="brand config.json") ap.add_argument("--work-dir", required=True, help="runtime working dir (staged props + intermediates)") ap.add_argument("--out", required=True, help="output master path prefix (e.g. <dir>/master)") ap.add_argument("--aspects", default=None, help="comma list, e.g. '9:16,1:1' (defaults to config.aspects or '9:16,1:1')") ap.add_argument("--no-crop", action="store_true", help="skip the 1:1 crop even if requested") ap.add_argument("--skip-gates", action="store_true", help="skip the two gating checks (use only when intentional)") args = ap.parse_args() with open(args.config) as f: cfg = json.load(f) if not args.skip_gates: gate_claim_matches_format(cfg) gate_mechanism_prop(cfg) aspects = (args.aspects.split(",") if args.aspects else cfg.get("aspects") or ["9:16", "1:1"]) aspects = [a.strip() for a in aspects] os.makedirs(args.work_dir, exist_ok=True) public_dir = os.path.join(PROJECT_DIR, "public") os.makedirs(public_dir, exist_ok=True) props = build_props(cfg, public_dir) props_path = os.path.join(args.work_dir, "props.json") with open(props_path, "w") as f: json.dump(props, f, indent=2) print(f"[render-offer-ad] wrote input props -> {props_path}") out_dir = os.path.dirname(os.path.abspath(args.out)) or "." os.makedirs(out_dir, exist_ok=True) base = os.path.basename(args.out) master_9x16 = os.path.join(out_dir, f"{base}-9x16.mp4") # ensure the project has deps (npx remotion needs node_modules present). if not os.path.isdir(os.path.join(PROJECT_DIR, "node_modules")): print("[render-offer-ad] installing Remotion deps (one-time)...") run(["npm", "install"], cwd=PROJECT_DIR) # 9:16 master via Remotion, input props from the config. run( [ "npx", "remotion", "render", COMPOSITION_ID, master_9x16, "--props", props_path, ], cwd=PROJECT_DIR, ) print(f"[render-offer-ad] WROTE 9:16 master -> {master_9x16}") # 1:1 center-crop derived from the 9:16 master (no 2nd composition). if "1:1" in aspects and not args.no_crop: master_1x1 = os.path.join(out_dir, f"{base}-1x1.mp4") run( [ "ffmpeg", "-y", "-i", master_9x16, "-vf", "crop=1080:1080:0:(1920-1080)/2", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-c:a", "aac", "-b:a", "192k", master_1x1, ] ) print(f"[render-offer-ad] WROTE 1:1 crop -> {master_1x1}") print("[render-offer-ad] NOTE: /watch the 1:1 — confirm no key text/product clips in the crop.") print("[render-offer-ad] done.") if __name__ == "__main__": main()
-
-
tests
-
smoke-test.md 4.9 KB
# Smoke test — render-offer-ad Verifies the free, deterministic Remotion assembly end-to-end. No paid calls. Needs: **Node.js ≥18** and the bundled Remotion project's deps (`cd project && npm install` — `render.py` auto-runs this if `node_modules` is absent), plus **ffmpeg** on PATH for the 1:1 crop. If node/Remotion aren't available, do the validation-only Phase B (config binding + render-command wiring) and NOTE that a full render needs node/Remotion installed. ## Setup ```bash cd skills/ads/capabilities/render-offer-ad mkdir -p /tmp/offer-ad-smoke # Copy the example config and REPLACE the placeholder paths: # - product_hero_image : a REAL product photo (clean/transparent bg, any png/webp) # - music.bed : an instrumental bed (or delete the "music" block to render silent) cp scripts/config.example.json /tmp/offer-ad-smoke/config.json # For a fast 2-second synthetic render, edit the copied config: # - beat_split_sec : [0.5, 0.5, 0.5, 0.5] (→ 60 frames total ≈ 2s) # - product_hero_image : any small png with a transparent bg # - delete the "music" block (silent) so no audio asset is needed ``` ## Phase A — full render (node + Remotion present) ```bash python3 scripts/render.py \ --config /tmp/offer-ad-smoke/config.json \ --work-dir /tmp/offer-ad-smoke \ --out /tmp/offer-ad-smoke/master ``` Runs the two gating checks, stages the product photo into `project/public/`, writes the input props to `/tmp/offer-ad-smoke/props.json`, renders `offer-ad --props …` to `master-9x16.mp4`, then ffmpeg center-crops to `master-1x1.mp4`. ## Phase B — validation only (no node / Remotion not installed) ```bash # 1) Gating checks + config→props binding, WITHOUT rendering. Point product_hero_image # at any existing file (a placeholder is fine for the bind check). This exercises the # gates and writes props.json; the render step will then fail only at `npx remotion`. python3 scripts/render.py \ --config /tmp/offer-ad-smoke/config.json \ --work-dir /tmp/offer-ad-smoke \ --out /tmp/offer-ad-smoke/master ; echo "exit=$?" # Inspect the bound input props: cat /tmp/offer-ad-smoke/props.json # 2) Confirm the render-command wiring is correct (no hardcoded /Users or clients paths): grep -n "remotion render offer-ad" scripts/render.py grep -n "crop=1080:1080" scripts/render.py ``` `props.json` must contain the config's copy slots / palette / bpm / beat_split mapped onto the `OfferAdProps` keys (`palette`, `copy`, `product_image`, `mechanism_prop`, `bpm`, `beat_split_sec`, `fonts`, `music`). NOTE in the run log that a full render needs node/Remotion installed. ## Expect - The two GATING CHECKS pass for the example config (`product_format: liquid` with `spoon / 0 mess` claim vocab; `mechanism_prop: spoon`). Flip a claim line to a powder verb (e.g. `scoop`) → `render.py` exits non-zero with a CLAIM-MATCHES-FORMAT failure. Set `mechanism_prop` to something unsupported → a CLAIM-BEAT MECHANISM PROP failure. - `render.py` writes `/tmp/offer-ad-smoke/props.json` (the bound Remotion input props). - Phase A writes `master-9x16.mp4` (1080×1920, 30fps) and, when `1:1` is in `aspects`, `master-1x1.mp4` (1080×1080). Duration = `sum(beat_split_sec) × 30` frames (the 2s synthetic config → 60 frames). - Run the `watch` skill on `master-9x16.mp4`: the headline slams WORD-BY-WORD on the beat; the real product photo drops in + idle-bobs and is NOT stretched (aspect preserved); the mechanism prop enters from a frame edge on the claim beat; the claim lines drop in staggered; the CTA label + URL are readable; the hard cuts land on the beat downbeats; music (if supplied) is present and ramps out. ## Fail signals - **`CLAIM-MATCHES-FORMAT gate failed`** → the claim/motif copy uses foreign-format verbs for the declared `product_format` (a liquid using powder grammar). Rewrite the claim to the product's real format. Use `--skip-gates` only if the mismatch is intentional. - **`CLAIM-BEAT MECHANISM PROP gate failed`** → `mechanism_prop` isn't `spoon` or `accent`. A text-only claim beat is too static — supply an edge-entry prop. - **Product photo looks stretched / wrong aspect** → the source image was pre-distorted; the engine uses `objectFit:contain` and never stretches. Supply a clean product photo. - **Text looks smeared/warped** → something AI-rendered a letter; this engine typesets ALL text in Remotion (springs + DOM), never a video model. Never route copy through image-gen. - **Duration is wrong / cuts don't land on the beat** → `beat_split_sec` doesn't sum to the intended length or doesn't match the `bpm`; the cut frames are DERIVED from `beat_split_sec` (`Math.round(sec × 30)`), so re-time the split, don't trim the video. - **`npx remotion` not found** → node/Remotion isn't installed; run `cd project && npm install` (this capability legitimately requires a node runtime — the renderer is a Remotion project). ```
-
-
SKILL.md 9.1 KB
--- name: render-offer-ad description: Render a punchy ~12s vertical (9:16) music-only direct-response OFFER ad as a 4-beat kinetic-typography film — HEADLINE slam → real PRODUCT drop → CLAIM/proof → CTA pill — from one config of copy slots, a real product photo, a brand palette, fonts, bpm, and beat split. DETERMINISTIC + FREE (a bundled Remotion project; springs + interpolate, no AI-gen for visuals). Backgrounds are engine gradient divs off the palette, props are inline SVG, the ONLY composited bitmap is the REAL product photo (objectFit:contain, never stretched), and ALL headline/claim/CTA/URL/wordmark text is typeset in the engine — never AI-rendered (the format's credibility guard). A driver binds the config to Remotion input props, renders the 9:16 master, and derives a 1:1 center-crop with ffmpeg. Two gating checks run before render (claim verbs must match the product's physical format; the claim beat needs an edge-entry mechanism prop). Use for the motion-graphics-offer-ad format. status: active --- # render-offer-ad The free, deterministic renderer for the **motion-graphics-offer-ad** format — the punchy ~12s vertical, music-only, direct-response offer ad built as a **4-beat kinetic -typography film**: a HEADLINE slams in word-by-word → the real PRODUCT drops in → the CLAIM/proof lands → a CTA pill resolves. No character, no VO, no captions — the on-screen **typeset** text IS the message. This is a bundled **Remotion project** (`project/`) driven by a thin Python driver. The shipping master is **100% engine-rendered** (springs + `interpolate`): backgrounds are gradient divs off the `brand_palette`, props are inline SVG, and the ONLY composited bitmap is the REAL product photo (`objectFit:contain`, **never stretched**). ALL headline/claim/CTA/URL/wordmark text is typeset in the engine — **never AI-rendered**; that is the format's credibility guard. Render cost is **~$0**; the only paid step is an optional music bed, gated upstream in the recipe to `create-music-elevenlabs`. The whole ad is **data**: copy strings, product photo, palette, fonts, bpm, and beat split all arrive as `config.json` and are bound to Remotion **input props** — nothing is hardcoded in the scenes (the source run's Spoiled Child strings are generalised into `project/src/props.ts`). Deterministic → **iterate the cut for free**. ## The 4 beats (the spine) 1. **HEADLINE** — primary-color radial ground; `headline_words` slam in WORD-BY-WORD (`slamIn`, ~7-frame stagger, scale-overshoot + motion-blur smear, settling on the downbeat); `subline` + an animated bobbing down-arrow drop in. 2. **PRODUCT** — light radial ground; the REAL product photo drops in (`dropIn`) and idle-bobs (`bob`), `objectFit:contain` (never stretch); the `motif_chip` pops in (`popIn`); optional GENERIC competitor shape + strike-through (`wipe`) — never a named competitor. 3. **CLAIM** — light radial ground; the `mechanism_prop` slides in from a frame edge (`flyIn` overshoot, ~20% from the bottom) to add motion AND show the mechanism; the 3-line claim drops in staggered; product held bottom-right. 4. **CTA** — primary-color radial ground; the `wordmark` slams (`slamIn`); the CTA pill pops in as the motif-chip handoff resolving (`popIn`) with an arrow nudge; the `cta_url` fades up. Scene boundaries are **derived** from `beat_split_sec` (default `[3.0, 3.5, 3.0, 2.5]`s → cuts at frames 0/90/195/285/360 @30fps) so the hard cuts land on the beat downbeats. The motion kit — `slamIn / dropIn / bob / flyIn / popIn / wipe` — lives in `project/src/lib/anim.ts` and is kept intact from the source run. ## Two gating checks (run before any render — from the recipe) - **(a) CLAIM-MATCHES-FORMAT** — the claim/proof verbs MUST match the product's physical format: liquid → `spoon / sip / drink / 0 mess`; powder → `scoop / mix / no clumps`; capsule → `1 a day`; gummy → `chew`. `render.py` rejects foreign-format vocabulary (e.g. a liquid product must not borrow powder grammar like `scoop / no clumps`). Set `product_format` in the config to arm this gate. - **(b) CLAIM-BEAT MECHANISM PROP** — the claim beat must include an edge-entry `mechanism_prop` (`spoon` for drinkable liquids, or `accent` for a neutral edge-entry accent bar) that supplies motion AND shows the mechanism. A text-only claim beat is too static — `render.py` rejects an unsupported/missing prop. ## Run ```bash # 1) install the bundled Remotion project's deps (one-time; render.py auto-runs # this if node_modules is absent). cd project && npm install && cd .. # 2) bind config → Remotion input props, render the 9:16 master, derive the 1:1 crop. python3 scripts/render.py \ --config path/to/config.json \ --work-dir <work> \ --out <work>/master # → <work>/master-9x16.mp4 (1080x1920) and <work>/master-1x1.mp4 (1080x1080 crop) ``` `render.py` copies the config's `product_hero_image` (+ optional `music.bed`) into `project/public/`, maps every config key onto the `OfferAdProps` shape `props.ts` reads, writes the input props to `<work>/props.json`, runs `npx remotion render offer-ad --props <work>/props.json`, then center-crops the 9:16 master to 1:1 with ffmpeg (no 2nd composition). NO hardcoded `/Users` or `clients` paths — every asset arrives via the config and a runtime `--work-dir`. ## Scripts - `scripts/render.py` — the driver: gating checks → stage assets into `project/public/` → bind config to Remotion input props → `npx remotion render` (9:16) → ffmpeg 1:1 center-crop. - `scripts/config.example.json` — the shape of the `config` the recipe binds (brand-neutral worked defaults from the source run; replace every `/abs/...` placeholder). - `project/` — the bundled Remotion project. `src/props.ts` (the input-props schema + beat-layout math + safe defaults), `src/lib/anim.ts` (the `slamIn/dropIn/bob/flyIn/ popIn/wipe` motion kit — kept intact), `src/scenes/Scene01–04.tsx` (the four beats, all copy/palette/fonts from props), `src/Main.tsx` (the beat spine + audio bed), `src/Root.tsx` (the `offer-ad` composition; duration derived from `beat_split_sec`). ## Config → props mapping (what binds to what) | config key | Remotion prop (`props.ts`) | scene that reads it | |---|---|---| | `product_hero_image` | `product_image` (staged to `public/`) | Scene02 (drop), Scene03 (anchor) | | `brand_palette{primary_ground,light_ground,ink,highlight_chip}` (dict OR ≥4-hex list) | `palette` | all 4 (grounds/type/chip) | | `fonts{display,body}` | `fonts` (resolved in `fonts.ts`) | all 4 | | `copy.headline_words[]` / `copy.subline` | `copy.headline_words` / `copy.subline` | Scene01 | | `copy.motif_chip` | `copy.motif_chip` | Scene02 | | `copy.claim_lines[]{big,small}` | `copy.claim_lines` | Scene03 | | `copy.cta_label` / `copy.cta_url` / `copy.wordmark` | same | Scene04 | | `mechanism_prop` (`spoon`\|`accent`) | `mechanism_prop` | Scene03 (inline-SVG prop) | | `bpm` | `bpm` | beat grid | | `beat_split_sec` [4] | `beat_split_sec` | derives cut frames + total duration | | `music.bed` / `music.fade_out_frames` | `music.src` (staged) / `music.fade_out_frames` | `Main` audio bed | | `show_competitor_strike` | `show_competitor_strike` | Scene02 | | `aspects` | (driver) | 9:16 always; 1:1 = ffmpeg crop | ## Craft rules (load-bearing — faithful to the source run) - **The composited bitmap is the REAL product photo — never an AI packshot.** Clean / transparent bg; `objectFit:contain`, never stretched. - **Never AI-render text.** Headline, claim, CTA, URL, wordmark — all typeset in the engine. AI never draws a letter in this format. - **Never Ken-Burns/zoompan over stills** — the engine springs carry the motion; a static/zoompan cut was rejected as "too static" on the source run. - **Music-only, beat-locked** — the cuts + big slams land on the 0.0/3.0/6.5/9.5s downbeats; the bed volume ramps to 0 over the last `music.fade_out_frames` inside the render. No VO, no captions. - **Never name/jab a competitor** — any comparison shape on the product beat is a GENERIC silhouette (`show_competitor_strike`), not a named brand. - **The two gating checks are non-optional** — a liquid product must not borrow powder grammar; the claim beat must have real prop motion, not a static text stack. ## Requires - **Node.js** (≥18) + **Remotion** (`@remotion/cli` ≥4.0, installed via the bundled `project/package.json` — `render.py` runs `npm install` in `project/` on first use). This capability legitimately needs a node runtime — the renderer IS a Remotion project. - **ffmpeg** on PATH (for the 1:1 center-crop). - `watch` (QC the final master — confirm the headline slams WORD-BY-WORD on the beat, the real product photo drops in + idle-bobs and is NOT stretched, the mechanism prop enters from a frame edge on the claim beat, the claim lines drop in staggered, the CTA label + URL are readable at 1080×1920, the hard cuts land on the downbeats, and the music ramps out; re-confirm both gating checks visually). The recipe gates the only optional paid step (music bed → `create-music-elevenlabs`) — this capability itself makes NO paid calls. -
skill.meta.json 307 B
{ "slug": "render-offer-ad", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-offer-ad", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.