$npx -y skills add gooseworks-ai/goose-skills --skill render-3d-character-explainerAssemble a glossy 3D-character animated-explainer video ad (~77s, 9:16) built on an "N types of X" listicle spine — a recurring human protagonist plus a locked cast of N persona characters, one per list item. Given the per-scene i2v clips + a per-scene target-duration table + a n
| 1 | # render-3d-character-explainer |
| 2 | |
| 3 | The free, deterministic renderer for the **3d-character-explainer** video ad format — the |
| 4 | glossy Pixar-style 3D spot built on an **"N types of X" listicle** spine, where a recurring |
| 5 | human protagonist plus a locked cast of **N persona characters (one per list item)** carry a |
| 6 | hook → "deeper story" → cast-reveal → one beat per list item → kicker → product test → |
| 7 | relieved payoff. This capability is the **FREE assembly stage only**. All generative work |
| 8 | (Nano-Banana cast anchors + per-scene keyframes, Kling-V3 i2v clips, ElevenLabs VO + music, |
| 9 | or a source ad's audio reused verbatim) happens upstream in the recipe and is handed to this |
| 10 | capability as files. |
| 11 | |
| 12 | It ports the validated compose recipe from the Bristle "Six Types" restyle run |
| 13 | (`_render_full.sh` — per-scene trim → normalize 1080×1920/fps30 → concat -c copy → mux the |
| 14 | source audio, with a static-still fallback on any failed clip). The assembly is |
| 15 | deterministic — iterate the cut for free, re-roll only the offending paid beat. |
| 16 | |
| 17 | ## Two modes |
| 18 | |
| 19 | - **Restyle mode** (`audio_mode: "restyle"`, the reference run) — re-tell a finished source |
| 20 | ad, beat for beat, as 3D character comedy. The source ad's **audio mix (VO + music bed) is |
| 21 | reused VERBATIM** (`source_audio`), and the per-scene `target_sec` table is inherited from |
| 22 | the source's scene timing. No new VO or music is rendered. The trims must sum to the source |
| 23 | audio length. |
| 24 | - **Original mode** (`audio_mode: "original"`) — the ad authors its own narration. Each scene |
| 25 | carries a measured VO cue (`scenes[].vo`, `target_sec` = the ffprobe'd VO duration) which is |
| 26 | concatenated into a VO track (loudnorm I=-14) and optionally mixed under a `music_bed` |
| 27 | (loudnorm I=-26 then `volume`, `amix normalize=0`). |
| 28 | |
| 29 | ## What it does (the deterministic recipe) |
| 30 | |
| 31 | 1. **Per-scene retime.** Each i2v clip is trimmed to its scene `target_sec` and normalized to |
| 32 | identical dims/fps/SAR |
| 33 | (`scale=W:H:force_original_aspect_ratio=decrease,pad=W:H:(ow-iw)/2:(oh-ih)/2:color=<pad>,fps=30,setsar=1`). |
| 34 | A clip **shorter** than its window is extended with `tpad=stop_mode=clone`; a longer one is |
| 35 | `-t` trimmed. Decrease+pad (never crop) preserves the full 9:16 keyframe framing. |
| 36 | 2. **Static-still fallback.** For any scene whose `clip` is missing or failed to render, the |
| 37 | scene's `keyframe` PNG is looped (`-loop 1`) for `target_sec`, so the master always |
| 38 | assembles. Fallback scenes are printed at the end. |
| 39 | 3. **Identical re-encode + concat.** Every segment is re-encoded `libx264 -crf 18 -pix_fmt |
| 40 | yuv420p -r 30` even if already correct — a dims/framerate mismatch makes the concat demuxer |
| 41 | silently drop frames — then concatenated via the concat demuxer (`-c copy`). |
| 42 | 4. **Audio.** Restyle: `source_audio` muxed verbatim (`-map 0:v -map 1:a`), clamped to the |
| 43 | video length. Original: per-scene VO track (optional `atempo`, `apad`, `-t` clamp) → loudnorm |
| 44 | → optionally mixed under the music bed. |
| 45 | 5. **Captions last.** `make_captions.py` emits a libass `.ass` (one cue per scene, `start = |
| 46 | scene_start + 0.08s`, suppressed on any scene with no caption — e.g. a product/end-card beat |
| 47 | carrying its own typeset copy). `compose.py` burns it as the final filter so captions sit on |
| 48 | top. Word-level energy-pop captions (Whisper on the narration) are the recipe's upstream |
| 49 | option — produce that `.ass` externally and point `captions_ass` at it; compose burns |
| 50 | whatever `.ass` it's handed. |
| 51 | |
| 52 | ## Scripts (free — Python + ffmpeg, no bash, no paid calls) |
| 53 | |
| 54 | - `scripts/make_captions.py` — emits the per-scene libass `.ass` from the SAME scene table |
| 55 | compose reads, so caption windows stay in lockstep with the cut. Run before `compose.py` |
| 56 | (or leave `captions_ass` unset / pointing at nothing to skip captions). |
| 57 | - `scripts/compose.py` — the assembler: per-scene t |