Claude Skill

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

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

Full trust report

Download gooseworks-ai-goose-skills-skills_ads_capabilities_render-offer-ad-e1592ee.zip · 27 KB
Part of gooseworks-ai/goose-skills — 44 skills

Install

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

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

Skill manifest

render-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

# 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.
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.

No comments yet.

Reviews (0)

No reviews yet.

Related