Claude Skill

render-3d-character-explainer

Assemble 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

LLM Mart · 0 points · 3 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-3d-character-explainer-e1592ee.zip · 12 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-3d-character-explainer
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-3d-character-explainer

The free, deterministic renderer for the 3d-character-explainer video ad format — the glossy Pixar-style 3D spot built on an "N types of X" listicle spine, where a recurring human protagonist plus a locked cast of N persona characters (one per list item) carry a hook → "deeper story" → cast-reveal → one beat per list item → kicker → product test → relieved payoff. This capability is the FREE assembly stage only. All generative work (Nano-Banana cast anchors + per-scene keyframes, Kling-V3 i2v clips, ElevenLabs VO + music, or a source ad's audio reused verbatim) happens upstream in the recipe and is handed to this capability as files.

It ports the validated compose recipe from the Bristle "Six Types" restyle run (_render_full.sh — per-scene trim → normalize 1080×1920/fps30 → concat -c copy → mux the source audio, with a static-still fallback on any failed clip). The assembly is deterministic — iterate the cut for free, re-roll only the offending paid beat.

Two modes

  • Restyle mode (audio_mode: "restyle", the reference run) — re-tell a finished source ad, beat for beat, as 3D character comedy. The source ad's audio mix (VO + music bed) is reused VERBATIM (source_audio), and the per-scene target_sec table is inherited from the source's scene timing. No new VO or music is rendered. The trims must sum to the source audio length.
  • Original mode (audio_mode: "original") — the ad authors its own narration. Each scene carries a measured VO cue (scenes[].vo, target_sec = the ffprobe'd VO duration) which is concatenated into a VO track (loudnorm I=-14) and optionally mixed under a music_bed (loudnorm I=-26 then volume, amix normalize=0).

What it does (the deterministic recipe)

  1. Per-scene retime. Each i2v clip is trimmed to its scene target_sec and normalized to identical dims/fps/SAR (scale=W:H:force_original_aspect_ratio=decrease,pad=W:H:(ow-iw)/2:(oh-ih)/2:color=<pad>,fps=30,setsar=1). A clip shorter than its window is extended with tpad=stop_mode=clone; a longer one is -t trimmed. Decrease+pad (never crop) preserves the full 9:16 keyframe framing.
  2. Static-still fallback. For any scene whose clip is missing or failed to render, the scene's keyframe PNG is looped (-loop 1) for target_sec, so the master always assembles. Fallback scenes are printed at the end.
  3. Identical re-encode + concat. Every segment is re-encoded libx264 -crf 18 -pix_fmt yuv420p -r 30 even if already correct — a dims/framerate mismatch makes the concat demuxer silently drop frames — then concatenated via the concat demuxer (-c copy).
  4. Audio. Restyle: source_audio muxed verbatim (-map 0:v -map 1:a), clamped to the video length. Original: per-scene VO track (optional atempo, apad, -t clamp) → loudnorm → optionally mixed under the music bed.
  5. Captions last. make_captions.py emits a libass .ass (one cue per scene, start = scene_start + 0.08s, suppressed on any scene with no caption — e.g. a product/end-card beat carrying its own typeset copy). compose.py burns it as the final filter so captions sit on top. Word-level energy-pop captions (Whisper on the narration) are the recipe's upstream option — produce that .ass externally and point captions_ass at it; compose burns whatever .ass it's handed.

Scripts (free — Python + ffmpeg, no bash, no paid calls)

  • scripts/make_captions.py — emits the per-scene libass .ass from the SAME scene table compose reads, so caption windows stay in lockstep with the cut. Run before compose.py (or leave captions_ass unset / pointing at nothing to skip captions).
  • scripts/compose.py — the assembler: per-scene trim + identical 1080×1920/30fps re-encode (static-still fallback on missing clips) → concat → audio (restyle verbatim / original mix) → burn captions → master mp4.
  • scripts/config.example.json — the shape of the config the recipe binds (the brand-neutralised "Six Types" restyle values as a worked reference).

Inputs (all via --config + a runtime work dir — NO hardcoded paths)

config.json carries: audio_mode (restyle | original), scenes[] (each {id, clip, keyframe, target_sec, caption?, vo?, atempo?} where target_sec is the source-inherited window in restyle mode or the measured VO window in original mode, and keyframe is the static-still fallback source), source_audio (restyle), music_bed + music_volume + atempo (original), width/height (default 1080×1920), pad_color (letterbox colour), captions_ass, and caption_style. See config.example.json.

Craft rules (load-bearing — faithful to the source molecule + reference run)

  • Restyle inherits the source timing. A restyle reuses the source ad's exact audio, scene order, and per-beat durations verbatim; only an original-mode remix authors its own VO + timing table. Merge any sub-1.5s flash scene into a neighbour upstream to avoid a dead micro-cut (the reference folded scene 7 into scene 8).
  • Normalize decrease+pad, never crop — the listicle's cast-reveal + per-persona framing must not lose edges; letterbox-pad to the canvas colour instead. Re-encode every segment to 30fps before concat, even if already correct, or the concat demuxer silently drops frames.
  • Static-still fallback is mandatory — Kling can 403 mid-run (a billing wall after a burst of successes, not a rate limit). Any failed clip loops its keyframe so the master still assembles; re-roll only the missing beat and recompose (free).
  • generate_audio was false upstream — Kling would otherwise invent its own dialog track; the real narration is muxed here separately. (This is the recipe's upstream call, not this capability.)
  • No AI-rendered brand text — the product-beat keyframe shows a BLANK-label box; the real wordmark/end-card copy is composited upstream, never AI-drawn. Suppress captions on any product/end-card beat (its typeset copy carries the message — two text layers at one spot are both unreadable).
  • Caption start = scene_start + 0.08s (avoids the caption flashing a frame before a cut).

Requires

watch (QC the final master — the human protagonist reads as the SAME person every scene (wardrobe/hair/lighting held), each persona is on-model, the cast-reveal lineup matches the N list items, the product beat shows the REAL box, narration lands beat-for-beat, and duration is within ±0.1s of the summed windows). The recipe gates the paid create-image-fal (cast anchors + keyframes), create-video-fal (Kling-V3 i2v), create-vo-elevenlabs, and create-music-elevenlabs calls to their own capabilities — this capability itself makes NO paid calls.

Files (goose-skills)
  • scripts
    • compose.py 11.5 KB
      #!/usr/bin/env python3
      """compose.py — the deterministic FREE assembler for the 3d-character-explainer ad.
      
      Ports the validated compose recipe from the Bristle "Six Types" restyle run
      (clients/bristle-health/ad-runs/run-02-six-types/generated/_render_full.sh). This is the
      "N types of X" listicle variant of the animated-explainer format: a recurring human
      protagonist plus a locked cast of N persona characters (one per list item) carry a
      narrated spot. Given the per-scene i2v clips + the per-scene target durations + the
      narration track (RESTYLE mode: the source ad's VO+music mix reused verbatim; ORIGINAL
      mode: fresh per-scene VO windows + optional music bed), it renders the master mp4:
      
        1. Per-scene retime  — each clip is trimmed to its scene target duration and normalized
           to identical WxH/fps/SAR:
             scale=W:H:force_original_aspect_ratio=decrease,pad=W:H:(ow-iw)/2:(oh-ih)/2:color=<pad>,fps=30,setsar=1
           tpad=stop_mode=clone extends a clip that is SHORTER than its window (else -t trims).
           Every segment is RE-ENCODED to identical libx264/yuv420p/30fps so the concat demuxer
           never silently drops frames on a dims/framerate mismatch.
        2. Static-still fallback — for any scene whose clip is missing or failed to render, the
           scene's keyframe PNG is looped (-loop 1) for the scene duration, so the master always
           assembles.
        3. Concat           — all segments concatenated via the concat demuxer (-c copy).
        4. Audio            — RESTYLE mode: the source audio (source_audio) is muxed verbatim
           (-map 0:v -map 1:a). ORIGINAL mode: per-scene VO cues are (optionally) atempo'd,
           padded, clamped to each window, concatenated, then optionally mixed under a music bed
           (VO loudnorm I=-14, music loudnorm I=-26 then volume, amix normalize=0), and muxed.
        5. Caption burn     — the libass .ass (from make_captions.py) is burned LAST so captions
           sit on top of the video, then muxed with the audio into the master mp4.
      
      This capability makes NO paid calls. All inputs come via --config + the work dir; the
      recipe (the paid orchestration: cast anchors / keyframes / Kling i2v clips / VO / music)
      hands them off. Captions are burned only if config.captions_ass points at a real file.
      """
      import argparse, json, os, subprocess, sys
      
      # ---- canvas / encode constants (validated on the Bristle reference run) ----
      FPS = 30
      CRF_SEG = 18          # per-scene segment encode
      CRF_MASTER = 19       # final burn+mux encode
      PRESET = "medium"
      PAD_COLOR_DEFAULT = "0x1c2233"   # letterbox pad colour (Bristle deep-navy); config overridable
      
      # ---- audio mix constants (ORIGINAL mode with a music bed; validated on absurdist) ----
      VO_LOUDNORM = "loudnorm=I=-14:TP=-1.5:LRA=11"
      MUSIC_LOUDNORM = "loudnorm=I=-26:TP=-3:LRA=11"
      MUSIC_VOLUME_DEFAULT = 0.62
      FADE_OUT_TAIL = 1.4
      FADE_IN = 0.6
      
      
      def run(cmd):
          r = subprocess.run(cmd, capture_output=True, text=True)
          if r.returncode:
              sys.stderr.write((r.stderr or "")[-2000:] + "\n")
              sys.exit(f"FAILED: {' '.join(str(c) for c in cmd[:6])} ...")
          return r
      
      
      def ffprobe_dur(path):
          r = subprocess.run(
              ["ffprobe", "-v", "error", "-show_entries", "format=duration",
               "-of", "csv=p=0", path],
              capture_output=True, text=True)
          try:
              return float(r.stdout.strip())
          except ValueError:
              return 0.0
      
      
      def main():
          ap = argparse.ArgumentParser(description="Compose the 3d-character-explainer master.")
          ap.add_argument("--config", required=True, help="path to config.json (see config.example.json)")
          ap.add_argument("--work-dir", required=True, help="scratch dir for intermediates (created if missing)")
          ap.add_argument("--out", required=True, help="output master mp4 path")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          work = a.work_dir
          seg_dir = os.path.join(work, "_work")
          os.makedirs(seg_dir, exist_ok=True)
      
          W = int(cfg.get("width", 1080))
          H = int(cfg.get("height", 1920))
          pad_color = cfg.get("pad_color", PAD_COLOR_DEFAULT)
      
          scenes = cfg["scenes"]                        # [{id, clip, keyframe?, target_sec, vo?, caption?, atempo?}, ...]
          mode = cfg.get("audio_mode", "restyle")       # "restyle" | "original"
          source_audio = cfg.get("source_audio")        # RESTYLE: the source ad's VO+music mix reused verbatim
          music_bed = cfg.get("music_bed")              # ORIGINAL: optional instrumental bed
          music_volume = float(cfg.get("music_volume", MUSIC_VOLUME_DEFAULT))
          captions_ass = cfg.get("captions_ass")        # path to a pre-built .ass, or None
          global_atempo = cfg.get("atempo")             # default compose-stage atempo for all VO cues
      
          norm = (f"scale={W}:{H}:force_original_aspect_ratio=decrease,"
                  f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color={pad_color},fps={FPS},setsar=1")
      
          # -------------------------------------------------------------------
          # 1. per-scene video segments (retime -> identical encode; static-still fallback)
          # -------------------------------------------------------------------
          concat = os.path.join(seg_dir, "concat.txt")
          fallbacks = []
          with open(concat, "w") as cf:
              for s in scenes:
                  n = s["id"]
                  tgt = float(s["target_sec"])
                  seg = os.path.join(seg_dir, f"seg-{n}.mp4")
                  clip = s.get("clip")
                  if clip and os.path.exists(clip) and ffprobe_dur(clip) > 0.05:
                      src_dur = ffprobe_dur(clip)
                      vf = norm
                      pad = tgt - src_dur
                      if pad > 0.05:
                          vf = (f"scale={W}:{H}:force_original_aspect_ratio=decrease,"
                                f"pad={W}:{H}:(ow-iw)/2:(oh-ih)/2:color={pad_color},"
                                f"tpad=stop_mode=clone:stop_duration={pad:.3f},fps={FPS},setsar=1")
                      run(["ffmpeg", "-y", "-loglevel", "error", "-i", clip,
                           "-vf", vf, "-t", f"{tgt:.3f}",
                           "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_SEG),
                           "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", seg])
                      print(f"  scene-{n}  clip {src_dur:.2f}s -> {tgt:.2f}s")
                  else:
                      # static-still fallback: loop the scene's keyframe for the window
                      kf = s.get("keyframe")
                      if not (kf and os.path.exists(kf)):
                          sys.exit(f"scene-{n}: no usable clip AND no keyframe fallback "
                                   f"(clip={clip!r}, keyframe={kf!r})")
                      fallbacks.append(n)
                      run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", kf,
                           "-t", f"{tgt:.3f}", "-vf", norm,
                           "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_SEG),
                           "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", seg])
                      print(f"  scene-{n}  STATIC-STILL fallback -> {tgt:.2f}s")
                  cf.write(f"file 'seg-{n}.mp4'\n")
      
          # -------------------------------------------------------------------
          # 2. concat video (all segments identical -> no silent frame drops)
          # -------------------------------------------------------------------
          video = os.path.join(seg_dir, "video.mp4")
          run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
               "-i", concat, "-c", "copy", video])
          total = ffprobe_dur(video)
          print(f"  total runtime: {total:.2f}s  (mode={mode})")
      
          # -------------------------------------------------------------------
          # 3. audio bus
          # -------------------------------------------------------------------
          mix = os.path.join(seg_dir, "mix.wav")
          if mode == "restyle":
              # reuse the source ad's audio mix (VO + bed) VERBATIM, clamped to the video length
              if not (source_audio and os.path.exists(source_audio)):
                  sys.exit(f"restyle mode needs config.source_audio to exist (got {source_audio!r})")
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", source_audio,
                   "-t", f"{total:.3f}", "-ar", "44100", "-ac", "2", mix])
          else:
              # ORIGINAL mode: build a per-scene VO track, optionally mix under a music bed
              voconcat = os.path.join(seg_dir, "voconcat.txt")
              with open(voconcat, "w") as vf:
                  for s in scenes:
                      n = s["id"]
                      tgt = float(s["target_sec"])
                      vo = s.get("vo")
                      wav = os.path.join(seg_dir, f"vo-{n}.wav")
                      atempo = s.get("atempo", global_atempo)
                      if vo and os.path.exists(vo):
                          af = []
                          if atempo:
                              af.append(f"atempo={atempo}")
                          af.append("apad")
                          run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo,
                               "-af", ",".join(af), "-t", f"{tgt:.3f}",
                               "-ar", "44100", "-ac", "2", wav])
                      else:
                          run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi",
                               "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
                               "-t", f"{tgt:.3f}", wav])
                      vf.write(f"file 'vo-{n}.wav'\n")
              vo_track = os.path.join(seg_dir, "vo-track.wav")
              run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
                   "-i", voconcat, "-c", "copy", vo_track])
              vo_total = ffprobe_dur(vo_track)
              if music_bed and os.path.exists(music_bed):
                  music = os.path.join(seg_dir, "music.wav")
                  fade_out_st = max(vo_total - FADE_OUT_TAIL, 0.0)
                  run(["ffmpeg", "-y", "-loglevel", "error", "-i", music_bed,
                       "-af", f"afade=t=in:st=0:d={FADE_IN},afade=t=out:st={fade_out_st:.3f}:d={FADE_OUT_TAIL}",
                       "-t", f"{vo_total:.3f}", "-ar", "44100", "-ac", "2", music])
                  run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo_track, "-i", music,
                       "-filter_complex",
                       f"[0:a]{VO_LOUDNORM}[vo];"
                       f"[1:a]{MUSIC_LOUDNORM},volume={music_volume}[mus];"
                       f"[vo][mus]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]",
                       "-map", "[a]", "-ar", "44100", "-ac", "2", mix])
              else:
                  run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo_track,
                       "-af", VO_LOUDNORM, "-ar", "44100", "-ac", "2", mix])
      
          # -------------------------------------------------------------------
          # 4. burn captions LAST + mux -> master
          # -------------------------------------------------------------------
          if captions_ass and os.path.exists(captions_ass):
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", video, "-i", mix,
                   "-vf", f"ass={_ass_escape(captions_ass)}",
                   "-map", "0:v", "-map", "1:a",
                   "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_MASTER),
                   "-pix_fmt", "yuv420p", "-r", str(FPS),
                   "-c:a", "aac", "-b:a", "192k", "-shortest", a.out])
          else:
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", video, "-i", mix,
                   "-map", "0:v", "-map", "1:a",
                   "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", a.out])
      
          md = ffprobe_dur(a.out)
          expected = sum(float(s["target_sec"]) for s in scenes)
          print(f"WROTE {a.out}  {md:.2f}s (expected ~{expected:.2f}s, delta {md-expected:+.2f}s)")
          if fallbacks:
              print(f"STATIC-STILL FALLBACK SCENES: {' '.join(fallbacks)}")
          else:
              print("all scenes animated OK")
      
      
      def _ass_escape(path):
          # ffmpeg filtergraph escaping for a filename inside ass=...
          return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'")
      
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 3.9 KB
      {
        "_comment": "Illustrative example — a brand-neutralised 'N types of X' listicle restyle (the validated Bristle 'Six Types' run's shape, de-branded). The recipe binds a NEW ad's own clips / keyframes / audio / caption table here. Every path is a placeholder to REPLACE with an absolute path or a runtime working/ path. MODE: 'restyle' reuses a source ad's VO+music mix VERBATIM (source_audio) and inherits its scene timing — no per-scene vo/music fields are read. 'original' authors its own per-scene VO windows (scenes[].vo, target_sec = the MEASURED VO duration) + an optional music_bed. `keyframe` per scene is the static-still FALLBACK: if scenes[].clip is missing/failed, that scene's keyframe PNG is looped for target_sec so the master always assembles.",
      
        "audio_mode": "restyle",
        "source_audio": "/abs/or/working/audio/source-audio.mp3",
      
        "music_bed": null,
        "music_volume": 0.62,
        "atempo": null,
      
        "width": 1080,
        "height": 1920,
        "pad_color": "0x1c2233",
      
        "captions_ass": "/abs/or/working/audio/captions.ass",
        "caption_style": {
          "font": "Arial",
          "size": 64,
          "outline": 6,
          "shadow": 3,
          "marginv": 330
        },
      
        "scenes": [
          { "id": "01", "clip": "/abs/or/working/clips/clip-01.mp4", "keyframe": "/abs/or/working/keyframes/scene-01.png", "target_sec": 4.0, "caption": "Think bad breath is just bad breath?" },
          { "id": "02", "clip": "/abs/or/working/clips/clip-02.mp4", "keyframe": "/abs/or/working/keyframes/scene-02.png", "target_sec": 5.3, "caption": "A mint is just a cover-up." },
          { "id": "03", "clip": "/abs/or/working/clips/clip-03.mp4", "keyframe": "/abs/or/working/keyframes/scene-03.png", "target_sec": 5.3, "caption": "It's a microbial imbalance." },
          { "id": "04", "clip": "/abs/or/working/clips/clip-04.mp4", "keyframe": "/abs/or/working/keyframes/scene-04.png", "target_sec": 4.5, "caption": "Six different types." },
          { "id": "05", "clip": "/abs/or/working/clips/clip-05.mp4", "keyframe": "/abs/or/working/keyframes/scene-05.png", "target_sec": 6.0, "caption": "1. Inflammatory gum bacteria." },
          { "id": "06", "clip": "/abs/or/working/clips/clip-06.mp4", "keyframe": "/abs/or/working/keyframes/scene-06.png", "target_sec": 5.5, "caption": "2. The plaque partiers." },
          { "id": "08", "clip": "/abs/or/working/clips/clip-08.mp4", "keyframe": "/abs/or/working/keyframes/scene-08.png", "target_sec": 5.6, "caption": "3. The tongue-coaters." },
          { "id": "09", "clip": "/abs/or/working/clips/clip-09.mp4", "keyframe": "/abs/or/working/keyframes/scene-09.png", "target_sec": 7.3, "caption": "4. Sneaky opportunistic invaders." },
          { "id": "10", "clip": "/abs/or/working/clips/clip-10.mp4", "keyframe": "/abs/or/working/keyframes/scene-10.png", "target_sec": 4.6, "caption": "5. The fungal type." },
          { "id": "11", "clip": "/abs/or/working/clips/clip-11.mp4", "keyframe": "/abs/or/working/keyframes/scene-11.png", "target_sec": 5.8, "caption": "6. Intermittent bad breath." },
          { "id": "12", "clip": "/abs/or/working/clips/clip-12.mp4", "keyframe": "/abs/or/working/keyframes/scene-12.png", "target_sec": 0.5, "caption": "Here's the kicker." },
          { "id": "13", "clip": "/abs/or/working/clips/clip-13.mp4", "keyframe": "/abs/or/working/keyframes/scene-13.png", "target_sec": 5.2, "caption": "Brush and floss won't work for every type." },
          { "id": "14", "clip": "/abs/or/working/clips/clip-14.mp4", "keyframe": "/abs/or/working/keyframes/scene-14.png", "target_sec": 2.3, "caption": "Each needs a different battle plan." },
          { "id": "15", "clip": "/abs/or/working/clips/clip-15.mp4", "keyframe": "/abs/or/working/keyframes/scene-15.png", "target_sec": 2.6, "caption": "Which enemy are you fighting?" },
          { "id": "16", "clip": "/abs/or/working/clips/clip-16.mp4", "keyframe": "/abs/or/working/keyframes/scene-16.png", "target_sec": 5.1, "caption": null },
          { "id": "17", "clip": "/abs/or/working/clips/clip-17.mp4", "keyframe": "/abs/or/working/keyframes/scene-17.png", "target_sec": 7.2, "caption": null }
        ]
      }
      
    • make_captions.py 3.2 KB
      #!/usr/bin/env python3
      """make_captions.py — emit a libass .ass, one caption pill per scene (deterministic v1).
      
      Reads the SAME config.json compose.py reads, so the per-scene target seconds are the
      single source of truth — caption windows are derived from the compose scene table and
      stay in lockstep with the cut. This is the FREE, deterministic caption path.
      
      NOTE the format's recipe (upstream, paid) can instead burn WORD-LEVEL energy-pop captions
      from Whisper run on the rendered narration audio (see the format's molecule). That word-
      level pass needs a transcription model and is NOT part of this free assembler — point
      config.captions_ass at that externally-produced .ass to use it, or use this per-scene
      fallback. Either way compose.py just burns whatever .ass it is handed, last.
      
      Caption style (validated on the reference runs):
        Arial 64px, white #FFFFFF, 6px outline #141414, 3px shadow, bottom-third MarginV=330.
      
      Rules:
        - Dialogue start = scene_start + 0.08s (avoids the caption flashing a frame before the
          cut).
        - A scene with no `caption` (e.g. an end-card / product beat carrying its own typeset
          copy) is suppressed — two text layers at one spot are both unreadable.
      """
      import argparse, json, os
      
      HEADER = """[Script Info]
      ScriptType: v4.00+
      PlayResX: {resx}
      PlayResY: {resy}
      WrapStyle: 0
      ScaledBorderAndShadow: yes
      
      [V4+ Styles]
      Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding
      Style: Cap,{font},{size},&H00FFFFFF,&H000000FF,&H00141414,&H00000000,-1,0,0,0,100,100,0,0,1,{outline},{shadow},2,90,90,{marginv},1
      
      [Events]
      Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
      """
      
      
      def ts(s):
          h = int(s // 3600)
          m = int((s % 3600) // 60)
          sec = s % 60
          return f"{h}:{m:02d}:{sec:05.2f}"
      
      
      def main():
          ap = argparse.ArgumentParser(description="Emit the per-scene caption .ass.")
          ap.add_argument("--config", required=True)
          ap.add_argument("--out", help="output .ass path (defaults to config.captions_ass)")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          scenes = cfg["scenes"]
          cap_cfg = cfg.get("caption_style", {})
          header = HEADER.format(
              resx=int(cfg.get("width", 1080)),
              resy=int(cfg.get("height", 1920)),
              font=cap_cfg.get("font", "Arial"),
              size=cap_cfg.get("size", 64),
              outline=cap_cfg.get("outline", 6),
              shadow=cap_cfg.get("shadow", 3),
              marginv=cap_cfg.get("marginv", 330),
          )
      
          lines, t = [], 0.0
          for s in scenes:
              dur = float(s["target_sec"])
              cap = s.get("caption")
              if cap:
                  lines.append(f"Dialogue: 0,{ts(t + 0.08)},{ts(t + dur)},Cap,,0,0,0,,{cap}")
              t += dur
      
          out = a.out or cfg.get("captions_ass")
          if not out:
              raise SystemExit("no --out and no config.captions_ass set")
          os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
          with open(out, "w") as f:
              f.write(header + "\n".join(lines) + "\n")
          print(f"captions.ass written — {len(lines)} cues, total {t:.2f}s -> {out}")
      
      
      if __name__ == "__main__":
          main()
      
  • tests
    • smoke-test.md 4.2 KB
      # Smoke test — render-3d-character-explainer
      
      Verifies the free ffmpeg assembly end-to-end. No paid calls. Needs: Python 3 and
      ffmpeg/ffprobe. You supply per-scene clips + a narration track (restyle mode) or per-scene
      VO mp3s (original mode). Any 1080×1920 mp4s and any audio track work for a smoke run; a
      scene with no clip must have a `keyframe` PNG so the static-still fallback fires.
      
      ## Setup
      
      ```bash
      cd scripts
      mkdir -p /tmp/3dchar-smoke/clips /tmp/3dchar-smoke/keyframes /tmp/3dchar-smoke/audio
      # synthetic color clips + a still keyframe + a silent narration track:
      ffmpeg -y -f lavfi -i color=c=red:s=1080x1920:d=3 -r 30 /tmp/3dchar-smoke/clips/clip-01.mp4
      ffmpeg -y -f lavfi -i color=c=green:s=1080x1920:d=3 -r 30 /tmp/3dchar-smoke/clips/clip-02.mp4
      # scene 03 has NO clip -> exercises the static-still fallback from its keyframe
      ffmpeg -y -f lavfi -i color=c=blue:s=1080x1920:d=1 -frames:v 1 /tmp/3dchar-smoke/keyframes/scene-03.png
      # total audio = sum of the three windows (2.0 + 2.5 + 1.5 = 6.0s)
      ffmpeg -y -f lavfi -i anullsrc=r=44100:cl=stereo -t 6.0 /tmp/3dchar-smoke/audio/source-audio.mp3
      ```
      
      Write `/tmp/3dchar-smoke/config.json`:
      
      ```json
      {
        "audio_mode": "restyle",
        "source_audio": "/tmp/3dchar-smoke/audio/source-audio.mp3",
        "captions_ass": "/tmp/3dchar-smoke/captions.ass",
        "scenes": [
          { "id": "01", "clip": "/tmp/3dchar-smoke/clips/clip-01.mp4", "keyframe": "/tmp/3dchar-smoke/keyframes/scene-03.png", "target_sec": 2.0, "caption": "Six types." },
          { "id": "02", "clip": "/tmp/3dchar-smoke/clips/clip-02.mp4", "keyframe": "/tmp/3dchar-smoke/keyframes/scene-03.png", "target_sec": 2.5, "caption": "One per persona." },
          { "id": "03", "clip": "/tmp/3dchar-smoke/clips/missing.mp4", "keyframe": "/tmp/3dchar-smoke/keyframes/scene-03.png", "target_sec": 1.5, "caption": null }
        ]
      }
      ```
      
      ## Run
      
      ```bash
      # 1) per-scene captions (libass) — run BEFORE compose
      python3 make_captions.py --config /tmp/3dchar-smoke/config.json \
                               --out /tmp/3dchar-smoke/captions.ass
      
      # 2) assemble the master
      python3 compose.py --config /tmp/3dchar-smoke/config.json \
                         --work-dir /tmp/3dchar-smoke \
                         --out /tmp/3dchar-smoke/master.mp4
      
      # 3) confirm 9:16, 30fps, duration == sum of the windows (6.00s)
      ffprobe -v error -select_streams v:0 \
        -show_entries stream=width,height,r_frame_rate \
        -show_entries format=duration -of default=nw=1 /tmp/3dchar-smoke/master.mp4
      ```
      
      ## Expect
      
      - `make_captions.py` writes `captions.ass` — one cue per scene WITH a caption (scene 03
        suppressed), `start = scene_start + 0.08s`.
      - `compose.py` prints per-scene retime lines, a `scene-03 STATIC-STILL fallback` line, the
        total runtime, a final `WROTE ... (expected ~6.00s, delta ±...)`, and
        `STATIC-STILL FALLBACK SCENES: 03`.
      - `master.mp4` is `width=1080`, `height=1920`, `r_frame_rate=30/1`, and its duration is
        within ±0.1s of `sum(scenes[].target_sec)` (6.00s here).
      - Run the `watch` skill on `master.mp4` for a real (non-synthetic) run: the human protagonist
        holds across scenes, each persona is on-model, the cast-reveal matches the N list items, the
        product beat shows the REAL box (blank label in the plate, real box composited), narration
        lands beat-for-beat, and captions don't collide with on-screen text.
      
      ## Fail signals
      
      - Concat drops frames / audio desyncs → a segment wasn't re-encoded to 30fps (all segments
        MUST be `libx264 -r 30` before the concat demuxer). compose.py always re-encodes, so this
        means a source clip fed the wrong stream — check the ffprobe output.
      - Duration far off the summed windows → a `target_sec` wasn't the source-inherited window
        (restyle) or the measured VO duration (original); the trims must sum to the audio length.
      - Master has letterbox bars from a mismatched-aspect clip → expected: decrease+pad pads to the
        canvas colour rather than cropping. If the bars are the WRONG colour, set `pad_color`.
      - `scene-NN: no usable clip AND no keyframe fallback` → a scene has neither a valid `clip` nor
        a `keyframe`; every scene needs a keyframe so the static-still fallback can fire.
      - Original mode loudness off (not ~-14 LUFS) → the VO/music busses were bypassed; confirm the
        `loudnorm` filters ran and `amix normalize=0`.
      ```
      
  • SKILL.md 7.9 KB
    ---
    name: render-3d-character-explainer
    description: Assemble 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 narration track, it trims each clip to its scene window, re-encodes every segment to identical 1080x1920/30fps/libx264/yuv420p (decrease+pad, never crop) so the concat demuxer never drops frames, concats, and muxes audio — in RESTYLE mode the source ad's VO+music mix is reused verbatim, in ORIGINAL mode fresh per-scene VO (loudnorm I=-14) is mixed under an optional music bed (loudnorm I=-26). A static-still fallback loops a scene's keyframe when its clip is missing/failed, so the master always assembles; libass captions are burned last. FREE deterministic assembly (Python + ffmpeg, no bash, no paid calls); the recipe supplies the clips, keyframes, VO or source audio, and caption table and gates the paid cast-anchor/keyframe/Kling-i2v/VO/music calls to their own capabilities. Use for the 3d-character-explainer listicle format.
    status: active
    ---
    
    # render-3d-character-explainer
    
    The free, deterministic renderer for the **3d-character-explainer** video ad format — the
    glossy Pixar-style 3D spot built on an **"N types of X" listicle** spine, where a recurring
    human protagonist plus a locked cast of **N persona characters (one per list item)** carry a
    hook → "deeper story" → cast-reveal → one beat per list item → kicker → product test →
    relieved payoff. This capability is the **FREE assembly stage only**. All generative work
    (Nano-Banana cast anchors + per-scene keyframes, Kling-V3 i2v clips, ElevenLabs VO + music,
    or a source ad's audio reused verbatim) happens upstream in the recipe and is handed to this
    capability as files.
    
    It ports the validated compose recipe from the Bristle "Six Types" restyle run
    (`_render_full.sh` — per-scene trim → normalize 1080×1920/fps30 → concat -c copy → mux the
    source audio, with a static-still fallback on any failed clip). The assembly is
    deterministic — iterate the cut for free, re-roll only the offending paid beat.
    
    ## Two modes
    
    - **Restyle mode** (`audio_mode: "restyle"`, the reference run) — re-tell a finished source
      ad, beat for beat, as 3D character comedy. The source ad's **audio mix (VO + music bed) is
      reused VERBATIM** (`source_audio`), and the per-scene `target_sec` table is inherited from
      the source's scene timing. No new VO or music is rendered. The trims must sum to the source
      audio length.
    - **Original mode** (`audio_mode: "original"`) — the ad authors its own narration. Each scene
      carries a measured VO cue (`scenes[].vo`, `target_sec` = the ffprobe'd VO duration) which is
      concatenated into a VO track (loudnorm I=-14) and optionally mixed under a `music_bed`
      (loudnorm I=-26 then `volume`, `amix normalize=0`).
    
    ## What it does (the deterministic recipe)
    
    1. **Per-scene retime.** Each i2v clip is trimmed to its scene `target_sec` and normalized to
       identical dims/fps/SAR
       (`scale=W:H:force_original_aspect_ratio=decrease,pad=W:H:(ow-iw)/2:(oh-ih)/2:color=<pad>,fps=30,setsar=1`).
       A clip **shorter** than its window is extended with `tpad=stop_mode=clone`; a longer one is
       `-t` trimmed. Decrease+pad (never crop) preserves the full 9:16 keyframe framing.
    2. **Static-still fallback.** For any scene whose `clip` is missing or failed to render, the
       scene's `keyframe` PNG is looped (`-loop 1`) for `target_sec`, so the master always
       assembles. Fallback scenes are printed at the end.
    3. **Identical re-encode + concat.** Every segment is re-encoded `libx264 -crf 18 -pix_fmt
       yuv420p -r 30` even if already correct — a dims/framerate mismatch makes the concat demuxer
       silently drop frames — then concatenated via the concat demuxer (`-c copy`).
    4. **Audio.** Restyle: `source_audio` muxed verbatim (`-map 0:v -map 1:a`), clamped to the
       video length. Original: per-scene VO track (optional `atempo`, `apad`, `-t` clamp) → loudnorm
       → optionally mixed under the music bed.
    5. **Captions last.** `make_captions.py` emits a libass `.ass` (one cue per scene, `start =
       scene_start + 0.08s`, suppressed on any scene with no caption — e.g. a product/end-card beat
       carrying its own typeset copy). `compose.py` burns it as the final filter so captions sit on
       top. Word-level energy-pop captions (Whisper on the narration) are the recipe's upstream
       option — produce that `.ass` externally and point `captions_ass` at it; compose burns
       whatever `.ass` it's handed.
    
    ## Scripts (free — Python + ffmpeg, no bash, no paid calls)
    
    - `scripts/make_captions.py` — emits the per-scene libass `.ass` from the SAME scene table
      compose reads, so caption windows stay in lockstep with the cut. Run before `compose.py`
      (or leave `captions_ass` unset / pointing at nothing to skip captions).
    - `scripts/compose.py` — the assembler: per-scene trim + identical 1080×1920/30fps re-encode
      (static-still fallback on missing clips) → concat → audio (restyle verbatim / original
      mix) → burn captions → master mp4.
    - `scripts/config.example.json` — the shape of the `config` the recipe binds (the
      brand-neutralised "Six Types" restyle values as a worked reference).
    
    ## Inputs (all via `--config` + a runtime work dir — NO hardcoded paths)
    
    `config.json` carries: `audio_mode` (`restyle` | `original`), `scenes[]` (each `{id, clip,
    keyframe, target_sec, caption?, vo?, atempo?}` where `target_sec` is the source-inherited
    window in restyle mode or the **measured** VO window in original mode, and `keyframe` is the
    static-still fallback source), `source_audio` (restyle), `music_bed` + `music_volume` +
    `atempo` (original), `width`/`height` (default 1080×1920), `pad_color` (letterbox colour),
    `captions_ass`, and `caption_style`. See `config.example.json`.
    
    ## Craft rules (load-bearing — faithful to the source molecule + reference run)
    
    - **Restyle inherits the source timing.** A restyle reuses the source ad's exact audio, scene
      order, and per-beat durations verbatim; only an original-mode remix authors its own VO +
      timing table. Merge any sub-1.5s flash scene into a neighbour upstream to avoid a dead
      micro-cut (the reference folded scene 7 into scene 8).
    - **Normalize decrease+pad, never crop** — the listicle's cast-reveal + per-persona framing
      must not lose edges; letterbox-pad to the canvas colour instead. Re-encode every segment to
      30fps before concat, even if already correct, or the concat demuxer silently drops frames.
    - **Static-still fallback is mandatory** — Kling can 403 mid-run (a billing wall after a burst
      of successes, not a rate limit). Any failed clip loops its keyframe so the master still
      assembles; re-roll only the missing beat and recompose (free).
    - **`generate_audio` was false upstream** — Kling would otherwise invent its own dialog track;
      the real narration is muxed here separately. (This is the recipe's upstream call, not this
      capability.)
    - **No AI-rendered brand text** — the product-beat keyframe shows a BLANK-label box; the real
      wordmark/end-card copy is composited upstream, never AI-drawn. Suppress captions on any
      product/end-card beat (its typeset copy carries the message — two text layers at one spot are
      both unreadable).
    - **Caption `start = scene_start + 0.08s`** (avoids the caption flashing a frame before a cut).
    
    ## Requires
    
    `watch` (QC the final master — the human protagonist reads as the SAME person every scene
    (wardrobe/hair/lighting held), each persona is on-model, the cast-reveal lineup matches the N
    list items, the product beat shows the REAL box, narration lands beat-for-beat, and duration is
    within ±0.1s of the summed windows). The recipe gates the paid `create-image-fal` (cast
    anchors + keyframes), `create-video-fal` (Kling-V3 i2v), `create-vo-elevenlabs`, and
    `create-music-elevenlabs` calls to their own capabilities — this capability itself makes NO
    paid calls.
    
  • skill.meta.json 335 B
    {
      "slug": "render-3d-character-explainer",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-3d-character-explainer",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related