render-podcast-skit
Assemble a two-host fake-podcast skit ad from a config — per-line lipsync clips hard-concatenated in script order, scaled/padded to 1080×1920, WHITE bottom-center captions (up to 5 words per cue, broken on sentence punctuation, word-wrapped to stay in-frame, held at least 0.9s) b
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-podcast-skit
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-podcast-skit
Assemble a two-host fake-podcast skit ad from a config: a skeptic and a believer at an absurd themed podcast desk do a snappy back-and-forth about the product (the set is deliberately unrelated — that is the joke). Each line is its own lipsync clip so the edit can cut on the dialogue beat (~1.8s avg); this capability is the FREE, deterministic assembly that concatenates those clips, renders the WHITE captions, and appends the brand end card.
scripts/config.example.json is the worked example (Ladder run-02 "Laundromat 2am", ~49s
1080×1920 9:16, ~22 lines); scripts/PIPELINE.md maps every config block to its source step
and scripts/README.md documents the free assembly.
Run
This is the FREE, deterministic assembly stage — it spends nothing. The paid inputs are
separate capabilities: one ElevenLabs with-timestamps VO per line (one voice per host) via
create-vo-elevenlabs; two photoreal base stills at the themed desk plus ~10 expression variants
(mouths NEUTRAL/CLOSED, gpt-image-2 quality=high, not nano-banana) via
create-image-gpt-image-fal; and one lipsync clip per (still, VO) pair via create-video-fal.
Given the per-line clips + their VO timestamps + the brand wordmark SVG, render-podcast-skit
walks the scenes in script order, builds the global caption timeline, renders the WHITE captions,
hard-concats the clips, auto-appends the end card, and final-encodes crf28 → the master. Re-cuts
reuse the existing VOs / stills / clips and cost $0.
Contract (the free assembly)
- Dialogue-carried, no music bed by default. The per-line VO is the audio; a podcast skit needs no music (an optional low ambience is a taste call, off by default).
- One line = one scene = one hard cut, in script order. Hard-concat the per-line clips in order (scale/pad to 1080×1920, re-encode) — no dissolves.
- Captions from the VO's OWN char-level timestamps, not Whisper (script-window). Build a
global
words.jsonby offsetting each line's char-level word timings by the cumulative clip start, group into ≤5-word cues broken on sentence-final punctuation, and render WHITE#FFFFFFbottom-center captions (black outline), word-wrapped to stay in-frame and held ≥0.9s — PIL PNG overlays when the host ffmpeg lacks libass (common), else ASS. (Yellow 3-word karaoke was the old style, rejected in testing.) Whisper on the rendered clips mistimes; the VO timestamps are ground truth. - End card via Playwright/PIL from the real wordmark — never AI-render brand text. The lockup is a deterministic HTML → PNG → 2.5s mp4 from the brand's real wordmark SVG (black bg, brand wordmark, CTA pill, URL), auto-appended after the last line. A diffusion model garbles a wordmark.
- FFmpeg composite, deterministic, FREE. Concat the clips, overlay the WHITE caption PNGs (or
burn ASS via libass), append the end-card mp4, and final-encode
-preset slow -crf 28+ aac 96k → a 1080×1920 h264+aac master (~6MB for ~28s; the old-crf 20produced ~16MB). No paid calls, no keys.
Files (goose-skills)
-
scripts
-
config.example.json 15 KB
{ "_comment": "Ladder run-02 'Laundromat 2am' podcast-skit — the worked example. Copy to config.json and edit. A ~49s two-host (skeptic Brittney + believer Brad) 9:16 fake-podcast skit at a 24hr laundromat set (unrelated to fitness — that's the joke). Pipeline: (1) ElevenLabs with-timestamps VO per line, one voice per host; (2) two base stills (gpt-image-2 quality=high, photoreal, mouths closed) + ~10 edit-anchored expression variants on the bases; (3) Veed Fabric 1.0 lipsync per (still, VO) pair @ 720p; (4) WHITE captions from the VO's char-level timestamps, <=5 words/cue, sentence-broken, word-wrapped; (5) hard-cut concat + a 2.5s Playwright/PIL brand end card, final-encode crf28. Master 1080x1920 h264+aac. NOTE: this Ladder example is worked-reference — write a FRESH script + hook for each brand, do not reuse these lines. See scripts/PIPELINE.md for the source scripts.", "brand_name": "Ladder", "campaign": "podcast-skit — laundromat 2am", "title": "Ladder Podcast Ad run-02 — Laundromat 2am", "width": 1080, "height": 1920, "fps": 24, "duration_sec": 49, "duration_target_sec": 42, "aspect_ratio": "9:16", "set_description": "24hr laundromat at 2am. Two hosts at a wedged-in podcast desk between two front-load dryers. Broadcast mic stands, over-ear headphones, fluorescent overhead light, faded WASH DRY FOLD sign behind. Lone sock tumbling in a back dryer. The set is deliberately unrelated to the product — that is the joke.", "brand_guardrails": [ "No transformation claims ('lose 20 lbs'). Ladder sells STRUCTURE, not OUTCOME.", "No celebrity name-drops outside the actual coach roster.", "Plain-spoken voice — 'Get stronger, with a plan' beats hype.", "Lime/black palette only when product surfaces appear.", "The believer never mocks the user — the villain is the friction, not the buyer." ], "intonation_legend": { "_note": "Written into script.json so ElevenLabs delivers the intended prosody. Applied inline in each scene's `text`.", "...": "trailing thought / dramatic pause (~0.7-1.2s)", " — ": "crisp em-dash pause, sharper than comma (~0.4s)", "?": "rising intonation", ".": "hard stop, drops pitch", ",": "natural breath beat", "CAPS": "emphasized word (slightly louder + slower)" }, "voices": { "_note": "Exactly TWO hosts, one voice each, held across the whole skit. Real ElevenLabs voice_ids. HER = skeptic (validated as the Ramp v1.2 skeptic); HIM = believer (Ramp v1 believer-bro). eleven_multilingual_v2 with-timestamps.", "HER": { "role": "skeptic", "name": "Brittney", "voice_id": "kPzsL2i3teMYv0FxEYQ6", "provider": "elevenlabs", "model": "eleven_multilingual_v2", "endpoint": "with-timestamps", "settings": { "stability": 0.45, "similarity_boost": 0.78, "style": 0.45, "use_speaker_boost": true } }, "HIM": { "role": "believer", "name": "Brad", "voice_id": "T4x5CtnhOiichhcqFzgg", "provider": "elevenlabs", "model": "eleven_multilingual_v2", "endpoint": "with-timestamps", "settings": { "stability": 0.45, "similarity_boost": 0.78, "style": 0.35, "use_speaker_boost": true } } }, "characters": { "_note": "REALISM: generate with gpt-image-2 quality=high, NOT nano-banana (nano faces read as AI-stock, rejected in testing). ALWAYS append realism_suffix. Two bases first (9:16, tight bust, mic foreground, headphones, MOUTH CLOSED). Skeptic base first, then believer via gpt-image-2/edit anchored on the skeptic's still for set consistency. Then fan out variants — each edit on its host's base as the SOLE reference, prompt describes ONLY the pose/expression change so the identity + background stay across all cuts. QC every still for hallucinations (asymmetric long hair-lock, extra fingers, morphed ears) before lipsync.", "engine": { "provider": "fal", "model_t2i": "openai/gpt-image-2", "model_edit": "openai/gpt-image-2/edit", "image_size": { "width": 1024, "height": 1792 }, "quality_base": "high", "quality_variant": "high", "submit": "sequential" }, "realism_suffix": " PHOTOREALISM IS CRITICAL: a real candid documentary photograph of a real human — NOT illustration/3D/CGI/AI-stock. Real skin with pores, fine lines, uneven tone, slight asymmetry; no smoothing/retouching/waxy skin/perfect teeth. HAIR MUST BE COHERENT: one consistent hairstyle all around — no single long lock on one side that mismatches the top (a common gpt-image hallucination). No extra hands/fingers, no deformed ears.", "still_rules": "Tight bust framing, broadcast mic foreground, over-ear headphones. MOUTH NEUTRAL/CLOSED (open-mouth still breaks lipsync). State the hairstyle EXPLICITLY and coherently (e.g. 'short neatly tousled hair above the collar, no long strands'). Photorealistic, 35mm lens, shallow background blur.", "variant_template": "Use the reference image as the EXACT base — the SAME real person. Keep the face, skin texture, hair (same length + shape), headphones, clothing, background, microphone, and lighting IDENTICAL. Change ONLY: {change} Lips remain closed naturally, no open mouth, no smile showing teeth.", "bases": [ { "who": "HER", "file": "brittney-base.png", "_note": "generate FIRST; sets the laundromat set" }, { "who": "HIM", "file": "brad-base.png", "_note": "generate SECOND with brittney-base.png as a bg reference so the set matches" } ], "expression_variants": [ { "out_name": "brittney-eyebrow-up.png", "who": "HER", "change": "her left eyebrow raised slightly, mildly skeptical look, eyes narrowed just a touch.", "_note": "'narrowed eyes' NSFW-rejected first pass — if it fails swap to 'curious questioning look, eyes wide open and bright'" }, { "out_name": "brittney-shrug.png", "who": "HER", "change": "her shoulders raised slightly in a small shrug, head tilted a few degrees to her left, palms upturned just visible at bottom edge." }, { "out_name": "brittney-hand-ear.png", "who": "HER", "change": "her right hand lifted to touch the right headphone cup, fingers visible at side of head, focused listening expression." }, { "out_name": "brittney-half-smile.png", "who": "HER", "change": "a subtle closed-lip half-smile, the corner of her mouth lifted slightly, eyes warm and amused." }, { "out_name": "brittney-deal-close.png", "who": "HER", "change": "a slight lean forward toward the microphone, attentive engaged look, eyes locked on camera." }, { "out_name": "brad-explain.png", "who": "HIM", "change": "head turned a few degrees more toward the camera, attentive engaged look, his right hand opening upward in a small explanatory gesture just visible at bottom-right edge." }, { "out_name": "brad-grin.png", "who": "HIM", "change": "a subtle confident closed-lip grin starting, corner of mouth lifted slightly, eyes amused, the trace of a gotcha-look." }, { "out_name": "brad-listing.png", "who": "HIM", "change": "his right hand raised with three fingers extended as if counting items, hand visible at bottom-right edge, head turned slightly." }, { "out_name": "brad-phone-up.png", "who": "HIM", "change": "his right hand visible holding a smartphone vertically, the phone screen showing a generic dark-blue workout app interface with white text bars (no logos), held just below his chin, raised slightly toward camera.", "_note": "generic app UI only — NO real logos/screens" }, { "out_name": "brad-leans-back.png", "who": "HIM", "change": "leaning slightly back from the microphone, satisfied confident posture, head tilted back a few degrees, eyes relaxed." } ] }, "lipsync_engine": { "_note": "Each (still, VO) pair -> one clip. Default veed-fal (veed/fabric-1.0) @ 720p, concurrency 4, idempotent. Hedra Character-3 fallback if fal.ai balance exhausted. Empty text-prompt on Veed Fabric — a text prompt interferes with lipsync.", "gateway": "veed-fal", "model": "veed/fabric-1.0", "resolution": "720p", "aspect_ratio": "9:16", "concurrency": 4, "text_prompt": "", "fallback": { "gateway": "hedra", "model": "character-3", "model_id": "d1dd37a3-e39a-4854-a298-6510289f9cf2" } }, "captions": { "_note": "WHITE captions in the add-captions-veed-fal whisper house style (yellow karaoke was rejected in testing). Words come from the VO's OWN char-level timestamps (scene-NN-<who>.timestamps.json), NOT Whisper, offset by cumulative clip start. Cue = up to 5 words, FLUSHED on sentence-final punctuation (. ? !). Each cue word-wraps to stay INSIDE the frame (never off the edges) and stays on screen >= min_dur_sec. Rendered as PIL PNG overlays (libass-free) or ASS if libass is available; bottom-anchored. `caption` per scene is the phrase intent; the actual burned text comes from the VO timings.", "style": "white_bottom_center", "source": "vo_timestamps", "font": "Avenir Next Bold", "font_size": 66, "color_hex": "#FFFFFF", "stroke_hex": "#000000", "stroke_width": 6, "max_words_per_cue": 5, "break_on_sentence_punctuation": true, "min_dur_sec": 0.9, "wrap_max_width_px": 900, "position": "bottom_center", "bottom_anchor_px": 1720 }, "scenes": [ { "scene": 1, "who": "HER", "time": "0:00-0:02", "still": "brittney-base.png", "caption": "what app today", "text": "So... what app are we ad-reading today?" }, { "scene": 2, "who": "HIM", "time": "0:02-0:03", "still": "brad-explain.png", "caption": "called LADDER", "text": "It's called LADDER." }, { "scene": 3, "who": "HER", "time": "0:03-0:05", "still": "brittney-eyebrow-up.png", "caption": "thing you climb?", "text": "Like... the thing you climb?" }, { "scene": 4, "who": "HIM", "time": "0:05-0:07", "still": "brad-explain.png", "caption": "number one app", "text": "Strength training app. Number one in the App Store." }, { "scene": 5, "who": "HER", "time": "0:07-0:08", "still": "brittney-shrug.png", "caption": "I go to gym", "text": "OK but I already go to the gym?" }, { "scene": 6, "who": "HIM", "time": "0:08-0:10", "still": "brad-grin.png", "caption": "workout tomorrow?", "text": "Cool. What workout are you doing tomorrow?" }, { "scene": 7, "who": "HER", "time": "0:10-0:12", "still": "brittney-base.png", "caption": "I don't know", "text": "I DON'T KNOW... I'll figure it out when I get there.", "_note": "v2 re-render — v1 read too clean for a skeptic" }, { "scene": 8, "who": "HIM", "time": "0:12-0:14", "still": "brad-explain.png", "caption": "LADDER fixes that", "text": "Right — that's the part LADDER fixes." }, { "scene": 9, "who": "HIM", "time": "0:14-0:17", "still": "brad-explain.png", "caption": "do what it says", "text": "Open the app. It tells you what to do. You do it." }, { "scene": 10, "who": "HER", "time": "0:17-0:19", "still": "brittney-eyebrow-up.png", "caption": "insultingly simple", "text": "That sounds... almost insultingly simple." }, { "scene": 11, "who": "HIM", "time": "0:19-0:20", "still": "brad-base.png", "caption": "it is", "text": "It is." }, { "scene": 12, "who": "HER", "time": "0:20-0:21", "still": "brittney-base.png", "caption": "who writes these", "text": "Who's writing these workouts?" }, { "scene": 13, "who": "HIM", "time": "0:21-0:23", "still": "brad-phone-up.png", "caption": "nineteen coaches", "text": "Actual coaches. NINETEEN of them." }, { "scene": 14, "who": "HIM", "time": "0:23-0:25", "still": "brad-listing.png", "caption": "pilates kettlebell yoga","text": "Strength... pilates... kettlebell... yoga." }, { "scene": 15, "who": "HER", "time": "0:25-0:27", "still": "brittney-eyebrow-up.png", "caption": "new every day?", "text": "Wait — so it's new every day?", "_note": "v2 re-render — more natural skeptic phrasing" }, { "scene": 16, "who": "HIM", "time": "0:27-0:30", "still": "brad-phone-up.png", "caption": "coach in ear", "text": "New every day. Coach in your ear. Timer built in." }, { "scene": 17, "who": "HER", "time": "0:30-0:32", "still": "brittney-hand-ear.png", "caption": "coach in my ear?", "text": "Wait — coach in my ear?" }, { "scene": 18, "who": "HIM", "time": "0:32-0:34", "still": "brad-listing.png", "caption": "counts your reps", "text": "Like a real one. Counts your reps, talks you through it." }, { "scene": 19, "who": "HER", "time": "0:34-0:36", "still": "brittney-half-smile.png", "caption": "stop thinking", "text": "...so I just stop thinking?" }, { "scene": 20, "who": "HIM", "time": "0:36-0:37", "still": "brad-base.png", "caption": "you just lift", "text": "You just lift." }, { "scene": 21, "who": "HER", "time": "0:37-0:39", "still": "brittney-deal-close.png", "caption": "seven-day free trial?", "text": "Seven-day free trial?" }, { "scene": 22, "who": "HIM", "time": "0:39-0:41", "still": "brad-leans-back.png", "caption": "seven-day free trial", "text": "Seven-day free trial. Go." } ], "end_card": { "_note": "Playwright HTML -> PNG (1080x1920) -> 2.5s silent mp4, appended after scene 22 by stitch.py (auto-appends clips/end-card.mp4). NEVER AI-rendered text — the wordmark is the brand's REAL SVG (Ladder wordmark pulled from run-01-app-sizzle). Black bg, lime wordmark center, lime CTA pill, URL at bottom.", "engine": "playwright_html", "script": "build_end_card.py", "duration_sec": 2.5, "background": "#000000", "wordmark_svg": "run-01-app-sizzle/source/brand/ladder-wordmark-white.svg", "accent_hex": "#DBFF00", "cta_text": "Start free trial", "url_text": "joinladder.com" }, "assembly": { "_note": "stitch.py: walk scenes in order, ffprobe each clip, build a GLOBAL words.json (each word offset by cumulative clip start), group into <=5-word cues broken on sentence punctuation, render WHITE bottom-center captions word-wrapped to stay in-frame (PIL PNG overlays if the host ffmpeg lacks libass — the common case — else an ASS burn), concat all clips (scale/pad 1080x1920), auto-append clips/end-card.mp4. FINAL ENCODE libx264 -preset slow -crf 28 + aac 96k (keeps a ~28s master near ~6MB; crf20 produced ~16MB).", "concat": "ffmpeg_concat_reencode", "scale_pad": "scale=1080:1920:force_original_aspect_ratio=decrease,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black,setsar=1", "caption_burn": "pil_png_overlay_or_libass", "final_encode": "libx264 -preset slow -crf 28 -pix_fmt yuv420p -c:a aac -b:a 96k -movflags +faststart", "append_end_card": true }, "post_production": { "music": { "default": "off", "note": "podcast skit is dialogue-carried; the per-line VO is the audio. No music bed (optional low ambience is a taste call, off by default)." }, "captions": { "default": "on", "note": "WHITE #FFFFFF bottom-center from the VO's own char-level timestamps, <=5 words per cue, cue breaks on sentence punctuation, word-wrapped to stay in-frame, >=0.9s on screen (NOT yellow karaoke)" }, "end_card": { "default": "on", "note": "Playwright/PIL end card from the brand's real wordmark SVG, default on" } } } -
PIPELINE.md 7.2 KB
# Pipeline — podcast-skit How `config.example.json` maps to the real production steps. This molecule ships a **config + this map**, not a bundled runner: the worked example (Ladder run-02 "Laundromat 2am") was produced by per-step driver scripts that live in `clients/ladder/ad-runs/run-02-podcast-skit/working/`. Reference those scripts directly, or drive the whole run via `video-orchestrator-with-control-plane` (variant mode — this format has no specialised orchestrator). The steps run **in order** because each depends on the last: the script sets the timeline, the VO gives the per-line timings, the timings + set drive the stills, the stills seed the lipsync clips, the VO's timestamps drive the captions, and the stitch stitches all of it + the end card. ## Field → script → phase → paid? | Config field | Source step | Script (in the run's `working/`) | Phase | Paid? | |---|---|---|---|---| | `voices.HER` / `voices.HIM` | one voice per host, `eleven_multilingual_v2` with-timestamps | `render_vo.py` | 1 | **PAID** (ElevenLabs, ~22 lines) | | `scenes[].text` | the per-line VO copy (intonation-marked) | `render_vo.py` | 1 | **PAID** | | `set_description` | the themed set fed into the base-still prompts | (hand-generated bases) | 2 | — | | `characters.bases[]` (2) | two base stills, mouth closed, at the desk | (skeptic first, believer on skeptic's still as ref) | 2 | **PAID** (`gpt-image-2` quality=high, 2 imgs — NOT nano-banana, which reads AI-stock) | | `characters.variant_template` + `expression_variants[]` | ~10 edit-anchored expression variants, base as SOLE ref | `render_variants.py` | 2 | **PAID** (`gpt-image-2/edit` quality=high, ~10 imgs, sequential; append `realism_suffix`, QC for hallucinated hair-lock/hands) | | `scenes[].still` | which still each line lipsyncs from | `render_clips.py` (reads `script.json`) | 3 | — | | `lipsync_engine` | Veed Fabric 1.0 via fal.ai per (still, VO) pair | `render_clips.py --gateway veed-fal --resolution 720p` | 3 | **PAID** (fal.ai, ~22 × 720p) | | `captions` | WHITE captions from the VO char-level timestamps (≤5-word cues, sentence-broken, word-wrapped, ≥0.9s) | `stitch.py` (PIL PNG overlays, or ASS if libass present) | 4 | free | | `end_card` | Playwright HTML → PNG → 2.5s mp4 from the real wordmark SVG | `build_end_card.py` | 4 | free | | `assembly` | concat + scale/pad + WHITE captions + append end card + final-encode crf28 | `stitch.py` | 4 | free | ## 1. Voiceover → ElevenLabs with-timestamps (config: `voices`, `scenes[].text`) [PAID] `working/render_vo.py` reads `script.json` and, per scene, POSTs to `/v1/text-to-speech/<voice_id>/with-timestamps?output_format=mp3_44100_128` with the host's `model` + `settings`. It saves `voiceovers/scene-NN-<who>.mp3`, the char-level `voiceovers/scene-NN-<who>.timestamps.json`, and a `manifest.json` summarising durations. One voice per host, held across the whole skit. **The char-level timestamps are what the captions sync to — do NOT Whisper.** Lines are intonation-marked (`...` pause, ` — ` em-dash, `?` rising, `CAPS` emphasis, `.` hard stop), ≤10 words, **no acronyms** (ElevenLabs reads them letter-by-letter). ## 2. Base stills + variants → gpt-image-2 (quality=high) (config: `characters`) [PAID] - **Two bases** (hand-generated): tight bust, broadcast mic foreground, over-ear headphones, at the themed desk, **mouth NEUTRAL/CLOSED** (an open-mouth still breaks the lipsync driver). Generate the skeptic base FIRST, then the believer using the skeptic's still as a background reference so the set matches. Generate with **gpt-image-2 quality=high** and append `characters.realism_suffix` — NOT nano-banana (its faces read as smooth "AI-stock" and were rejected in testing). State the hairstyle explicitly and coherently to avoid the asymmetric-long-lock hallucination. - **~10 expression variants** — `working/render_variants.py` edit-anchors each variant on its host's base as the **SOLE reference** (`gpt-image-2/edit`, prompt = `characters.variant_template` with `{change}` describing ONLY the pose/expression change — so identity + background stay across all cuts). Submitted **sequentially**. Idempotent (skips existing). `"narrowed eyes"` NSFW-rejects → swap to `"curious questioning look, eyes wide open and bright"`. Review all ~12 stills before step 3: every mouth closed, one continuous set, and **NO hallucinations** — a long hair-lock on one side that mismatches the top, extra/warped hands, morphed ears. If seen, regenerate the BASE (variants inherit its artifacts) then re-make its variants. ## 3. Lipsync clips → Veed Fabric 1.0 via fal.ai (config: `lipsync_engine`, `scenes[]`) [PAID] `working/render_clips.py --gateway veed-fal --resolution 720p` reads each scene's `(still, VO)` pair from `script.json` and feeds them to the fal.ai Veed Fabric generate script (model `veed/fabric-1.0`), writing `clips/scene-NN-<who>.mp4`. Concurrency 4; idempotent (skips existing — safe to re-run partial failures with `--only 20,21,22`). Empty text-prompt (a text prompt interferes with lipsync). **Hedra Character-3** (`--gateway hedra --model character-3`) is the fallback if the fal.ai balance is exhausted (server: "User is locked. Reason: Exhausted balance."). Its `--stills-dir` / `--clips-dir` are relative to the script's own `working/` root — pass bare `characters` / `clips`, not `working/characters` (else it double-prefixes). ## 4. Captions + end card + assembly → `stitch.py` + `build_end_card.py` (config: `captions`, `end_card`, `assembly`) - `working/build_end_card.py` renders the brand lockup via **Playwright** from the REAL brand wordmark SVG (Ladder wordmark pulled from `run-01-app-sizzle/source/brand/ladder-wordmark-white.svg`): black background + lime (`#DBFF00`) wordmark + lime "Start free trial" pill + `joinladder.com` → an HTML file → screenshot to a 1080×1920 PNG → a **2.5s** silent mp4 at `clips/end-card.mp4`. **Never AI-render brand text.** - `working/stitch.py` walks the scenes in `script.json` order. For each scene it ffprobes the clip's duration and builds a **global `words.json`** — every word's time = its local char-level time (parsed from `scene-NN-<who>.timestamps.json`) **+ the cumulative clip start** (sum of the preceding clips' durations). It groups words into **≤5-word cues broken on sentence-final punctuation** and renders **WHITE `#FFFFFF`** bottom-center captions (black outline), **word-wrapped to stay in-frame** and held **≥0.9s** — as **PIL PNG overlays** when the host ffmpeg lacks libass (the common case), else an ASS burn. (Yellow 3-word karaoke was the old style, rejected in testing.) Then it concats all clips (`-f concat`, `scale=1080:1920:…:black,setsar=1`), **auto-appends `clips/end-card.mp4`**, and **final-encodes `libx264 -preset slow -crf 28` + aac 96k** → the 1080×1920 h264+aac master (~6MB for a ~28s cut; the old `-crf 20` produced ~16MB). Re-cuts (new caption chunking, re-timed clips, swapped end card) reuse the existing VOs/stills/clips and cost **$0** — only steps 1–3 spend. The v2 master re-rendered only scenes 7 + 15 (more natural skeptic reads) and re-stitched — a targeted `render_vo.py` + `render_clips.py --only 7,15` + a free re-stitch. -
README.md 3.1 KB
# render-podcast-skit scripts — the FREE assembly `render-podcast-skit` is the **deterministic, $0 assembly stage** of the fake-podcast skit format. The paid stages — the per-line ElevenLabs with-timestamps VOs, the two base stills, the ~10 expression variants, and the per-line lipsync clips — are separate capabilities (`create-vo-elevenlabs`, `create-image-fal`, `create-video-fal`). This capability spends nothing: it takes the per-line clips + their VO timestamps + the brand wordmark and stitches the finished master. Re-cuts (new caption chunking, a re-timed slice, a swapped end card) reuse the existing clips and cost **$0**. `config.example.json` is the worked example (Ladder run-02 "Laundromat 2am", ~49s 1080×1920). `PIPELINE.md` maps every config block to its source step. This README documents the FREE assembly pieces that `render-podcast-skit` owns. ## 1. WHITE captions — from the VO's OWN char-level timestamps (script-window, NOT Whisper) Captions come from each line's ElevenLabs with-timestamps response — the char-level word timings returned **with** the VO — never from Whisper (Whisper on the rendered clips mistimes). The assembler walks the scenes in script order and builds a **global `words.json`**: each word's time is its local char-level time **+ the cumulative clip start** (the sum of the preceding clips' durations). It groups words into **≤5-word cues broken on sentence-final punctuation** and renders **WHITE `#FFFFFF`** bottom-center captions (black outline), **word-wrapped to stay inside the frame** (never off the edges) and held **≥0.9s** each. Yellow 3-word karaoke was the old style and was **rejected in testing** — match the `add-captions-veed-fal --preset whisper` house style. Render as **PIL PNG overlays** when the host ffmpeg lacks libass (the common case — check `ffmpeg -filters | grep subtitle`), else an ASS burn. ## 2. Per-line clip assembly, hard-concat in script order One line = one scene = one clip. The clips are hard-concatenated in script order (scale/pad to 1080×1920, re-encode `libx264 -preset veryfast -crf 20`), so the edit cuts on the dialogue beat. No dissolves. Anchoring every still on one base upstream keeps the set pixel-identical across all ~22 cuts, so the many cuts read as one continuous podcast. ## 3. End card — Playwright/PIL from the real wordmark, no AI text The brand lockup is composited via **Playwright** from the brand's REAL wordmark SVG: black background + the brand wordmark + a CTA pill + the URL → an HTML file → a screenshot to a 1080×1920 PNG → a **2.5s** silent mp4. The brand text is **never** AI-rendered — a diffusion model garbles a wordmark. ## 4. FFmpeg composite FFmpeg stitches the master: concat the per-line clips (scale/pad 1080×1920), overlay the WHITE caption PNGs (or burn the ASS via libass), and auto-append the 2.5s end-card mp4. The VOs carry the audio (no music bed by default). **Final-encode `libx264 -preset slow -crf 28` + aac 96k** so a ~28s master lands near **~6MB** (the old `-crf 20` produced oversized ~16MB files). Output is a 1080×1920 h264 + aac master. Deterministic, no paid calls, no keys.
-
-
tests
-
smoke-test.md 1.1 KB
# Smoke Test Given the per-line lipsync clips (one per script line), the character stills, the caption timing from the VO's char-level timestamps, and the brand's real wordmark, `render-podcast-skit` assembles the master: concat the line clips in script order, render the WHITE captions, composite the end card, final-encode crf28 → 1080×1920 h264+aac. Pass when the assembly runs to a valid MP4 and: - the dialogue lines play in script order with the two hosts alternating; - captions are WHITE, ≤5 words per cue (broken on sentence punctuation), word-wrapped to stay in-frame, held ≥0.9s, offset by each clip's cumulative start (tracking the spoken word), not Whisper-derived; - speakers' mouths are closed when not speaking (carried from the source clips); - the end card is composited from the real brand wordmark (no AI-rendered brand text); - **no paid call is made** — the VO, stills, and lipsync clips come from the paid capabilities (create-vo-elevenlabs / create-image-gpt-image-fal / create-video-fal); this assembly is $0 and a re-cut reuses the existing assets.
-
-
SKILL.md 3.9 KB
--- name: render-podcast-skit description: Assemble a two-host fake-podcast skit ad from a config — per-line lipsync clips hard-concatenated in script order, scaled/padded to 1080×1920, WHITE bottom-center captions (up to 5 words per cue, broken on sentence punctuation, word-wrapped to stay in-frame, held at least 0.9s) built from each line's OWN ElevenLabs char-level timestamps (offset by cumulative clip start, never Whisper), and closed on a Playwright/PIL brand end card composited from the real wordmark — never AI-rendered text. This is the FREE deterministic assembly stage (concat + white captions + end card + crf28 encode); the per-line VOs, photoreal gpt-image-2 base stills, expression variants, and lipsync clips come from create-vo-elevenlabs / create-image-gpt-image-fal / create-video-fal. Use for the podcast-skit format. status: active --- # render-podcast-skit Assemble a **two-host fake-podcast skit** ad from a config: a skeptic and a believer at an absurd themed podcast desk do a snappy back-and-forth about the product (the set is deliberately unrelated — that is the joke). Each line is its own lipsync clip so the edit can cut on the dialogue beat (~1.8s avg); this capability is the **FREE, deterministic assembly** that concatenates those clips, renders the WHITE captions, and appends the brand end card. `scripts/config.example.json` is the worked example (Ladder run-02 "Laundromat 2am", ~49s 1080×1920 9:16, ~22 lines); `scripts/PIPELINE.md` maps every config block to its source step and `scripts/README.md` documents the free assembly. ## Run This is the **FREE, deterministic** assembly stage — it spends nothing. The paid inputs are separate capabilities: one ElevenLabs **with-timestamps** VO per line (one voice per host) via `create-vo-elevenlabs`; two photoreal base stills at the themed desk plus ~10 expression variants (mouths NEUTRAL/CLOSED, **gpt-image-2 quality=high**, not nano-banana) via `create-image-gpt-image-fal`; and one lipsync clip per (still, VO) pair via `create-video-fal`. Given the per-line clips + their VO timestamps + the brand wordmark SVG, `render-podcast-skit` walks the scenes in script order, builds the global caption timeline, renders the WHITE captions, hard-concats the clips, auto-appends the end card, and final-encodes crf28 → the master. Re-cuts reuse the existing VOs / stills / clips and cost **$0**. ## Contract (the free assembly) - **Dialogue-carried, no music bed by default.** The per-line VO is the audio; a podcast skit needs no music (an optional low ambience is a taste call, off by default). - **One line = one scene = one hard cut, in script order.** Hard-concat the per-line clips in order (scale/pad to 1080×1920, re-encode) — no dissolves. - **Captions from the VO's OWN char-level timestamps, not Whisper (script-window).** Build a global `words.json` by offsetting each line's char-level word timings by the cumulative clip start, group into **≤5-word cues broken on sentence-final punctuation**, and render **WHITE `#FFFFFF`** bottom-center captions (black outline), **word-wrapped to stay in-frame** and held **≥0.9s** — PIL PNG overlays when the host ffmpeg lacks libass (common), else ASS. (Yellow 3-word karaoke was the old style, rejected in testing.) Whisper on the rendered clips mistimes; the VO timestamps are ground truth. - **End card via Playwright/PIL from the real wordmark — never AI-render brand text.** The lockup is a deterministic HTML → PNG → 2.5s mp4 from the brand's real wordmark SVG (black bg, brand wordmark, CTA pill, URL), auto-appended after the last line. A diffusion model garbles a wordmark. - **FFmpeg composite, deterministic, FREE.** Concat the clips, overlay the WHITE caption PNGs (or burn ASS via libass), append the end-card mp4, and **final-encode `-preset slow -crf 28` + aac 96k** → a 1080×1920 h264+aac master (~6MB for ~28s; the old `-crf 20` produced ~16MB). No paid calls, no keys. -
skill.meta.json 315 B
{ "slug": "render-podcast-skit", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-podcast-skit", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.