Claude Skill

render-absurdist-explainer

Assemble an absurdist animated-explainer video ad (~38s, 9:16) from per-scene i2v clips + their measured VO windows — retime each clip to its VO, re-encode every segment to identical 30fps/libx264/yuv420p so the concat demuxer never drops frames, concat, build a REAL-product PIL

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-absurdist-explainer-e1592ee.zip · 13 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-absurdist-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-absurdist-explainer

The free, deterministic renderer for the absurdist-explainer video ad format — the bright Pixar/Disney 3D spot where a personified villain (the problem) narrates the whole ad in one voice, teaches the product's ownable mechanism through cartoon biology, lists the damage, then watches its own scheme collapse when the product arrives. This capability is the FREE assembly stage only. All generative work (nano-banana keyframes, Seedance i2v clips, ElevenLabs VO + music) happens upstream in the recipe and is handed to this capability as files.

It ports the validated compose recipe from two reference runs (HUM "Big Chill" cortisol absurdism and Soteri "Eczema, the pH villain"). The recipe is deterministic — iterate the cut for free, re-roll only the offending paid beat.

What it does (the deterministic recipe)

  1. Per-scene retime. Each i2v clip is retimed to its measured VO window (scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1, then tpad=stop_mode=clone if the VO is longer than the clip, else -t trim).
  2. Identical re-encode. Every segment is re-encoded libx264 -crf 18 -pix_fmt yuv420p -r 30 even if already correct — a framerate mismatch makes the concat demuxer silently drop frames.
  3. Concat all scene segments + the end card via the concat demuxer (-c copy).
  4. Real-product end card. build_endcard.py composites the REAL retail product photo over the brand palette (flat, or sampled from the photo's own edge pixel) with a typeset wordmark + claim rows + CTA pill in PIL ImageDraw.text — never an AI cartoon bottle, never AI-rendered brand text. compose.py Ken-Burnses it 1.00 → 1.04 over the dwell.
  5. Mix. VO bus loudnorm I=-14 TP=-1.5, music bus loudnorm I=-26 TP=-3 then volume=0.62, amix inputs=2 duration=first normalize=0 → master lands at -14.5..-13.5 LUFS with the music ducked under the VO.
  6. Captions last. make_captions.py emits a libass .ass (one cue per scene, Arial 64 white / 6px outline / MarginV=330, start = scene_start + 0.08s, suppressed on the end card). compose.py burns it as the final filter so captions sit on top.

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

  • scripts/build_endcard.py — PIL composite of the real product photo + typeset brand layer (wordmark / product line / claim rows / accent CTA pill). Reads the same config.json. Run this FIRST so end_card.image exists before compose.py.
  • 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 point config.captions_ass at nothing to skip captions).
  • scripts/compose.py — the assembler: per-scene retime + identical 30fps re-encode → concat → Ken-Burns end card → VO/music loudnorm mix → burn captions → master mp4.
  • scripts/config.example.json — the shape of the config the recipe binds (the brand-neutralised Soteri values as a worked reference).

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

config.json carries: scenes[] (each {id, clip, target_sec, vo, caption, atempo?} where target_sec is the measured VO window), end_card{product_image, image, dwell_sec, zoom_to, wordmark, product_line, claims[], cta, background?}, brand_palette {primary, primary_lite, accent, grey}, music_bed, music_volume (default 0.62), atempo (compose-stage VO speed-up, default off; the reference runs used 1.3 when the VO read slow), captions_ass, and caption_style. See config.example.json.

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

  • The end card is the REAL product photo, composited — never an AI cartoon bottle. Both reference runs shipped an AI bottle first and had to re-shoot with the real photo.
  • No AI-rendered brand text anywhere. Wordmark, claims, CTA, motif — all PIL ImageDraw.text. AI draws the world + characters only.
  • Re-encode every segment to 30fps before concat, even if already correct, or the concat demuxer silently drops frames.
  • target_sec is the MEASURED VO duration (ffprobe each VO mp3), never a planned word count — VO drives the per-scene timing.
  • Mix constants are validated — VO -14 LUFS, music -26 LUFS then volume≈0.62 (Soteri) to 0.70 (Big Chill), amix normalize=0. Master target -14.5..-13.5 LUFS, true-peak ≤ -1.5 dBFS.
  • Caption start = scene_start + 0.08s, suppressed on the end card (its typeset copy carries the message — two text layers at one spot are both unreadable).

Requires

watch (QC the final master — confirm the villain silhouette holds, the single voice carries the whole spot, the motif lands ≥3×, no AI brand text leaked into a cartoon background, the end card is the real product, and duration is within ±0.1s of the summed windows). The recipe gates the paid create-image-fal (keyframes), create-video-fal (Seedance i2v), create-vo-elevenlabs, and create-music-elevenlabs calls to their own capabilities — this capability itself makes NO paid calls.

Files (goose-skills)
  • scripts
    • build_endcard.py 4.4 KB
      #!/usr/bin/env python3
      """build_endcard.py — the REAL-product end card (1080x1920), composited in PIL.
      
      Ports the validated end-card recipe from the Soteri "pH villain" run
      (generated/endcard/build_endcard.py). The end card is ALWAYS a composite of the real
      retail product photo — NEVER an AI-rendered cartoon bottle (both reference runs shipped
      an AI bottle first and had to re-shoot with the real photo). ALL brand text is typeset
      here with PIL ImageDraw.text — never AI-rendered.
      
      Layout (top -> bottom):
        - background: the brand's primary palette colour, OR (default) sampled from the product
          photo's own edge pixel for a seamless paste.
        - the real product photo, scaled to ~1015px tall, centred, offset y=88.
        - brand wordmark (large, brand primary colour).
        - product line (medium).
        - claim rows (small, grey).
        - a CTA pill (rounded-rect in the brand accent colour, white text).
      
      Reads a config.json; writes endcard.png into --out (or the config's end_card.image).
      """
      import argparse, json, os
      from PIL import Image, ImageDraw, ImageFont
      
      W, H = 1080, 1920
      
      # Portable font fallback chain: DejaVu (ships with Pillow / most Linux), then macOS
      # Arial, then Pillow's built-in. Bold + regular variants each.
      _BOLD_CANDS = [
          "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
          "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
          "/Library/Fonts/Arial Bold.ttf",
          "DejaVuSans-Bold.ttf",
      ]
      _REG_CANDS = [
          "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
          "/System/Library/Fonts/Supplemental/Arial.ttf",
          "/Library/Fonts/Arial.ttf",
          "DejaVuSans.ttf",
      ]
      
      
      def font(bold, size):
          for c in (_BOLD_CANDS if bold else _REG_CANDS):
              try:
                  return ImageFont.truetype(c, size)
              except OSError:
                  continue
          return ImageFont.load_default()
      
      
      def _hex(s, default=(0, 0, 0)):
          if not s:
              return default
          s = s.lstrip("#")
          if len(s) == 3:
              s = "".join(ch * 2 for ch in s)
          try:
              return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4))
          except ValueError:
              return default
      
      
      def main():
          ap = argparse.ArgumentParser(description="Build the real-product end card PNG.")
          ap.add_argument("--config", required=True)
          ap.add_argument("--out", help="output PNG path (defaults to config.end_card.image)")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          ec = cfg["end_card"]
          palette = cfg.get("brand_palette", {})
      
          primary = _hex(palette.get("primary"), (46, 111, 94))
          primary_lite = _hex(palette.get("primary_lite") or palette.get("primary"), primary)
          accent = _hex(palette.get("accent"), (232, 103, 76))
          grey = _hex(palette.get("grey"), (107, 111, 105))
          white = (255, 255, 255)
      
          prod = Image.open(ec["product_image"]).convert("RGB")
      
          # background: explicit brand bg, else sample the product photo's own edge pixel
          if ec.get("background"):
              bg = _hex(ec["background"], (255, 255, 255))
          else:
              bg = prod.getpixel((6, 6))
      
          img = Image.new("RGB", (W, H), bg)
          d = ImageDraw.Draw(img)
      
          # product photo, scaled to height ~1015, centred, offset y=88
          ph = int(ec.get("product_height", 1015))
          pw = int(prod.width * ph / prod.height)
          img.paste(prod.resize((pw, ph), Image.LANCZOS), ((W - pw) // 2, int(ec.get("product_y", 88))))
      
          def line(y, text, fnt, fill):
              d.text((W // 2, y), text, font=fnt, fill=fill, anchor="ma")
      
          # typeset copy block
          y = int(ec.get("copy_y", 1190))
          line(y, ec["wordmark"], font(True, 100), primary); y += 128
          if ec.get("product_line"):
              line(y, ec["product_line"], font(True, 46), primary_lite); y += 96
          for claim in ec.get("claims", []):
              line(y, claim, font(False, 35), grey); y += 56
          y += 36
      
          # CTA pill (rounded-rect in the accent colour, white text)
          cta = ec.get("cta")
          if cta:
              cf = font(True, 43)
              bb = d.textbbox((0, 0), cta, font=cf)
              cw, chh = bb[2] - bb[0], bb[3] - bb[1]
              padx, pady = 50, 30
              pw2, ph2 = cw + 2 * padx, chh + 2 * pady
              px = (W - pw2) // 2
              d.rounded_rectangle([px, y, px + pw2, y + ph2], radius=ph2 // 2, fill=accent)
              d.text((W // 2, y + ph2 // 2), cta, font=cf, fill=white, anchor="mm")
      
          out = a.out or ec["image"]
          os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
          img.save(out)
          print("wrote", out, img.size)
      
      
      if __name__ == "__main__":
          main()
      
    • compose.py 11.1 KB
      #!/usr/bin/env python3
      """compose.py — the deterministic FREE assembler for the absurdist-explainer ad.
      
      Ports the validated compose recipe from the two reference runs (HUM "Big Chill" and
      Soteri "Eczema, the pH villain"). Given the per-scene i2v clips + the per-scene VO
      windows + the VO track + the music bed + a built end-card PNG + a caption .ass file,
      it renders the master mp4:
      
        1. Per-scene retime  — each clip is retimed to its MEASURED VO window:
             scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1
             then tpad=stop_mode=clone (if the VO is longer than the clip) else -t (trim).
           Every segment is RE-ENCODED to identical libx264/crf18/yuv420p/30fps so the concat
           demuxer never silently drops frames on a framerate mismatch.
        2. End card         — the pre-built endcard.png (real product composite, see
           build_endcard.py) is Ken-Burnsed (slow 1.00 -> 1.04 zoom) over its dwell window and
           appended as the final scene.
        3. Concat           — all segments concatenated via the concat demuxer (-c copy).
        4. VO track         — each VO cue is (optionally) atempo-compressed, padded, clamped to
           its window, and concatenated into one wav.
        5. Music bed        — fit to the total runtime with a fade in/out tail.
        6. Mix              — VO bus loudnorm I=-14 TP=-1.5, music bus loudnorm I=-26 TP=-3 then
           volume (default 0.62), amix inputs=2 duration=first normalize=0. This lands the
           master at -14.5..-13.5 LUFS with the music ducked under the VO.
        7. Caption burn     — the libass .ass is burned LAST so captions sit on top of the
           video, then muxed with the mix into the master mp4.
      
      This capability makes NO paid calls. All inputs come via --config + the work dir; the
      recipe (the paid orchestration: keyframes / clips / VO / music) hands them off.
      """
      import argparse, json, os, subprocess, sys, tempfile
      
      # ---- canvas / encode constants (validated on both reference runs) ----
      W, H = 1080, 1920
      FPS = 30
      CRF_SEG = 18          # per-scene segment encode
      CRF_MASTER = 19       # final burn+mux encode
      PRESET = "medium"
      
      # ---- audio mix constants (validated) ----
      VO_LOUDNORM = "loudnorm=I=-14:TP=-1.5:LRA=11"
      MUSIC_LOUDNORM = "loudnorm=I=-26:TP=-3:LRA=11"
      MUSIC_VOLUME_DEFAULT = 0.62   # Soteri 0.62 / Big Chill 0.70
      FADE_OUT_TAIL = 1.4           # music out-fade length
      FADE_IN = 0.6                 # music in-fade length
      
      
      def run(cmd, quiet=True):
          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)
          return float(r.stdout.strip())
      
      
      def main():
          ap = argparse.ArgumentParser(description="Compose the absurdist-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)
      
          scenes = cfg["scenes"]                       # [{id, clip, target_sec, vo, atempo?}, ...]
          endcard = cfg["end_card"]                    # {image, dwell_sec, zoom_to?}
          music_bed = cfg.get("music_bed")             # path or None
          music_volume = float(cfg.get("music_volume", MUSIC_VOLUME_DEFAULT))
          captions_ass = cfg.get("captions_ass")       # path to pre-built .ass, or None
          global_atempo = cfg.get("atempo")            # default compose-stage atempo for all VO cues
      
          # -------------------------------------------------------------------
          # 1. per-scene video segments (retime -> identical 30fps encode)
          # -------------------------------------------------------------------
          concat = os.path.join(seg_dir, "concat.txt")
          with open(concat, "w") as cf:
              for s in scenes:
                  n = s["id"]
                  clip = s["clip"]
                  tgt = float(s["target_sec"])
                  seg = os.path.join(seg_dir, f"seg-{n}.mp4")
                  src_dur = ffprobe_dur(clip)
                  vf = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1"
                  pad = tgt - src_dur
                  if pad > 0.05:
                      vf += f",tpad=stop_mode=clone:stop_duration={pad:.3f}"
                  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])
                  cf.write(f"file 'seg-{n}.mp4'\n")
                  print(f"  scene-{n}  clip {src_dur:.2f}s -> {tgt:.2f}s")
      
              # end card: Ken-Burns the real-product PIL composite (never AI)
              ec_img = endcard["image"]
              ec_dwell = float(endcard.get("dwell_sec", 4.0))
              zoom_to = float(endcard.get("zoom_to", 1.04))
              frames = int(round(ec_dwell * FPS))
              ec_seg = os.path.join(seg_dir, "seg-endcard.mp4")
              # slow continuous 1.00 -> zoom_to over the dwell. Feed a SINGLE image frame
              # (-loop 1 -frames:v 1 into the graph via zoompan d=<frames>) so zoompan emits
              # exactly `frames` output frames — the whole-clip Ken-Burns. -t clamps the output.
              zstep = (zoom_to - 1.0) / max(frames, 1)
              run(["ffmpeg", "-y", "-loglevel", "error",
                   "-loop", "1", "-i", ec_img,
                   "-vf", (f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1,"
                           f"zoompan=z='min(zoom+{zstep:.6f}\\,{zoom_to})':d={frames}:"
                           f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{H}:fps={FPS}"),
                   "-t", f"{ec_dwell:.3f}",
                   "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_SEG),
                   "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", ec_seg])
              cf.write("file 'seg-endcard.mp4'\n")
              print(f"  end-card  {ec_dwell:.2f}s  zoom->{zoom_to}")
      
          # -------------------------------------------------------------------
          # 2. concat video (all segments are 30fps -> 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])
      
          # -------------------------------------------------------------------
          # 3. VO track (atempo optional, padded + clamped per scene, concatenated)
          # -------------------------------------------------------------------
          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:
                      # no VO for this scene -> silence for the window
                      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")
      
              # end-card window: silence so the audio spans the full video (the end card has no
              # VO). Without this, -shortest would truncate the master and drop the end card.
              ec_wav = os.path.join(seg_dir, "vo-endcard.wav")
              run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi",
                   "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
                   "-t", f"{float(endcard.get('dwell_sec', 4.0)):.3f}", ec_wav])
              vf.write("file 'vo-endcard.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])
          total = ffprobe_dur(vo_track)
          print(f"  total runtime: {total:.2f}s")
      
          # -------------------------------------------------------------------
          # 4. + 5. music bed (fit + fade) and mix
          # -------------------------------------------------------------------
          mix = os.path.join(seg_dir, "mix.wav")
          if music_bed and os.path.exists(music_bed):
              music = os.path.join(seg_dir, "music.wav")
              fade_out_st = max(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"{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:
              # VO only — still loudnorm to the -14 LUFS target
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo_track,
                   "-af", VO_LOUDNORM, "-ar", "44100", "-ac", "2", mix])
      
          # -------------------------------------------------------------------
          # 6. burn captions LAST + mux -> master
          # -------------------------------------------------------------------
          burn_in = video
          if captions_ass and os.path.exists(captions_ass):
              # ass= filter needs an escaped path; use a work-relative copy to dodge colons/spaces
              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", burn_in, "-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) + float(endcard.get("dwell_sec", 4.0))
          print(f"WROTE {a.out}  {md:.2f}s (expected ~{expected:.2f}s, "
                f"delta {md-expected:+.2f}s)")
      
      
      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.4 KB
      {
        "_comment": "Illustrative example — the values are the validated Soteri 'Eczema, the pH villain' run, brand-neutralised. The recipe binds a NEW brand's own clips / VO / product / palette / copy here. Every path is a placeholder to REPLACE with an absolute path or a runtime working/ path. `target_sec` per scene is the MEASURED VO window (ffprobe each VO mp3 — never a planned word count). Scene order is the 12-scene spine (villain intro -> teach -> damage list -> hero -> CLIMAX -> payoff); the end card is a SEPARATE block (built by build_endcard.py), not a scene.",
      
        "atempo": 1.3,
        "music_bed": "/abs/or/working/audio/music-bed.mp3",
        "music_volume": 0.62,
        "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/scene-01.mp4", "target_sec": 3.13, "vo": "/abs/or/working/audio/vo/vo-01-intro.mp3",   "caption": "I'm Eczema." },
          { "id": "02", "clip": "/abs/or/working/clips/scene-02.mp4", "target_sec": 5.41, "vo": "/abs/or/working/audio/vo/vo-02-barrier.mp3", "caption": "Meet the moisture barrier." },
          { "id": "03", "clip": "/abs/or/working/clips/scene-03.mp4", "target_sec": 1.40, "vo": "/abs/or/working/audio/vo/vo-03-break.mp3",   "caption": "So I break it." },
          { "id": "04", "clip": "/abs/or/working/clips/scene-04.mp4", "target_sec": 3.42, "vo": "/abs/or/working/audio/vo/vo-04-secret.mp3",  "caption": "It's really a pH problem." },
          { "id": "05", "clip": "/abs/or/working/clips/scene-05.mp4", "target_sec": 4.56, "vo": "/abs/or/working/audio/vo/vo-05-healthy.mp3", "caption": "Healthy skin = pH 4.9" },
          { "id": "06", "clip": "/abs/or/working/clips/scene-06.mp4", "target_sec": 3.94, "vo": "/abs/or/working/audio/vo/vo-06-pushup.mp3",  "caption": "Push the pH up — the wall cracks." },
          { "id": "07", "clip": "/abs/or/working/clips/scene-07.mp4", "target_sec": 3.39, "vo": "/abs/or/working/audio/vo/vo-07-skin.mp3",    "caption": "Dry. Red. Itchy." },
          { "id": "08", "clip": "/abs/or/working/clips/scene-08.mp4", "target_sec": 2.41, "vo": "/abs/or/working/audio/vo/vo-08-night.mp3",   "caption": "Nobody sleeps tonight." },
          { "id": "09", "clip": "/abs/or/working/clips/scene-09.mp4", "target_sec": 2.59, "vo": "/abs/or/working/audio/vo/vo-09-arrive.mp3",  "caption": "Until Soteri Skin shows up." },
          { "id": "10", "clip": "/abs/or/working/clips/scene-10.mp4", "target_sec": 4.64, "vo": "/abs/or/working/audio/vo/vo-10-lock.mp3",    "caption": "pH/LOCK snaps it back to 4.9" },
          { "id": "11", "clip": "/abs/or/working/clips/scene-11.mp4", "target_sec": 6.27, "vo": "/abs/or/working/audio/vo/vo-11-defeat.mp3",  "caption": "Calm baby. No more eczema." }
        ],
      
        "end_card": {
          "product_image": "/abs/or/working/raw-materials/product-endcard.jpg",
          "image": "/abs/or/working/generated/endcard/endcard.png",
          "dwell_sec": 3.97,
          "zoom_to": 1.04,
          "background": null,
          "product_height": 1015,
          "product_y": 88,
          "copy_y": 1190,
          "wordmark": "Soteri Skin",
          "product_line": "Baby Eczema Relief Cream",
          "claims": [
            "Powered by pH/LOCK® Technology",
            "Steroid-Free   ·   Fragrance-Free   ·   Ages 0–5"
          ],
          "cta": "soteriskin.com"
        },
      
        "brand_palette": {
          "primary": "#2E6F5E",
          "primary_lite": "#3E8A73",
          "accent": "#E8674C",
          "grey": "#6B6F69"
        }
      }
      
    • make_captions.py 2.8 KB
      #!/usr/bin/env python3
      """make_captions.py — emit a libass .ass, one caption pill per scene (v1).
      
      Ports the validated caption recipe from the Soteri run (production/make_captions.py).
      Reads the SAME config.json compose.py reads, so the per-scene target seconds are the
      single source of truth — the caption windows are derived from the compose scene table,
      guaranteeing they stay in lockstep with the cut.
      
      Caption style (validated on both 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 — see the molecule's Failure Modes).
        - A scene with no `caption` (e.g. the end card) is suppressed — its own typeset copy
          carries the message, and two text layers at the same spot are both unreadable.
      """
      import argparse, json, os
      
      HEADER = """[Script Info]
      ScriptType: v4.00+
      PlayResX: 1080
      PlayResY: 1920
      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(
              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
          # end card window has no caption (its typeset copy carries it)
      
          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 3.5 KB
      # Smoke test — render-absurdist-explainer
      
      Verifies the free PIL + ffmpeg assembly end-to-end. No paid calls. Needs: Python 3 with
      Pillow, ffmpeg/ffprobe, and a sans-serif TrueType font (DejaVu ships with Pillow on most
      Linux; macOS uses Arial). You supply per-scene clips + VO mp3s + a music bed + a real
      product photo (any 1080×1920 mp4s and a ≥1000px product jpg work for a smoke run).
      
      ## Setup
      
      ```bash
      cd scripts
      python3 -m pip install pillow            # if not present
      mkdir -p /tmp/absurdist-smoke
      # Copy config.example.json -> config.json and point every path at real files:
      #   - scenes[].clip  : your per-scene i2v mp4s (1080x1920)
      #   - scenes[].vo    : the per-scene VO mp3s (target_sec = ffprobe of each)
      #   - end_card.product_image : a real retail product photo (>=1000px)
      #   - music_bed      : an instrumental bed (or set to null to run VO-only)
      cp config.example.json /tmp/absurdist-smoke/config.json
      ```
      
      ## Run
      
      ```bash
      # 1) real-product end card (PIL) — must run BEFORE compose
      python3 build_endcard.py --config /tmp/absurdist-smoke/config.json \
                               --out /tmp/absurdist-smoke/endcard.png
      # (then set end_card.image in config.json to /tmp/absurdist-smoke/endcard.png)
      
      # 2) per-scene captions (libass) — must run BEFORE compose
      python3 make_captions.py --config /tmp/absurdist-smoke/config.json \
                               --out /tmp/absurdist-smoke/captions.ass
      # (then set captions_ass in config.json to /tmp/absurdist-smoke/captions.ass)
      
      # 3) assemble the master
      python3 compose.py --config /tmp/absurdist-smoke/config.json \
                         --work-dir /tmp/absurdist-smoke \
                         --out /tmp/absurdist-smoke/master.mp4
      ```
      
      ## Expect
      
      - `build_endcard.py` writes `endcard.png` (1080×1920): the REAL product photo centred over
        the brand palette, with a typeset wordmark + claim rows + accent CTA pill. NOT an AI
        bottle; NO smeared/AI-rendered text.
      - `make_captions.py` writes `captions.ass` — one cue per scene with a caption, the end card
        suppressed, `start = scene_start + 0.08s`.
      - `compose.py` prints per-scene retime lines, the total runtime, and a final
        `WROTE ... (expected ~Xs, delta ±...)`. `master.mp4` is 1080×1920, 30fps; its duration is
        within ±0.1s of `sum(scenes[].target_sec) + end_card.dwell_sec`.
      - Run the `watch` skill on `master.mp4`: the villain silhouette holds across scenes, the
        single villain voice carries the whole spot, the motif word lands ≥3×, no AI brand text
        leaked into a cartoon background, captions don't collide with on-screen text, and the end
        card is the real product with legible copy.
      
      ## 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.
      - Master loudness is off (not ~-14 LUFS) → the mix busses were bypassed; confirm both
        `loudnorm` filters ran and `amix normalize=0`.
      - End card shows a cartoon/AI bottle or smeared text → `end_card.product_image` points at
        an AI render, or a font failed to load (build_endcard falls back to DejaVu → Arial →
        Pillow default; a Pillow-default fallback looks bitmapped — install DejaVu/Arial).
      - Caption flashes a frame before a cut → the +0.08s offset was removed from make_captions.
      - Duration far off the summed windows → a `target_sec` wasn't the measured VO duration
        (ffprobe each VO mp3; don't use planned word counts).
      ```
      
  • SKILL.md 5.9 KB
    ---
    name: render-absurdist-explainer
    description: Assemble an absurdist animated-explainer video ad (~38s, 9:16) from per-scene i2v clips + their measured VO windows — retime each clip to its VO, re-encode every segment to identical 30fps/libx264/yuv420p so the concat demuxer never drops frames, concat, build a REAL-product PIL end card (never AI) with a slow Ken-Burns, mix VO (loudnorm I=-14) under music (loudnorm I=-26, volume 0.62, amix normalize=0), and burn libass captions last. FREE deterministic assembly (bash-free, Python + ffmpeg + PIL); the recipe supplies the clips, VO, music, product photo, palette, and caption table and gates the paid keyframe/clip/VO/music calls to their own capabilities. Use for the absurdist-explainer format.
    status: active
    ---
    
    # render-absurdist-explainer
    
    The free, deterministic renderer for the **absurdist-explainer** video ad format — the
    bright Pixar/Disney 3D spot where a personified villain (the problem) narrates the whole
    ad in one voice, teaches the product's ownable mechanism through cartoon biology, lists
    the damage, then watches its own scheme collapse when the product arrives. This
    capability is the **FREE assembly stage only**. All generative work (nano-banana
    keyframes, Seedance i2v clips, ElevenLabs VO + music) happens upstream in the recipe and
    is handed to this capability as files.
    
    It ports the validated compose recipe from two reference runs (HUM "Big Chill" cortisol
    absurdism and Soteri "Eczema, the pH villain"). The recipe is deterministic — iterate the
    cut for free, re-roll only the offending paid beat.
    
    ## What it does (the deterministic recipe)
    
    1. **Per-scene retime.** Each i2v clip is retimed to its **measured** VO window
       (`scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1`,
       then `tpad=stop_mode=clone` if the VO is longer than the clip, else `-t` trim).
    2. **Identical re-encode.** Every segment is re-encoded `libx264 -crf 18 -pix_fmt yuv420p
       -r 30` even if already correct — a framerate mismatch makes the concat demuxer silently
       drop frames.
    3. **Concat** all scene segments + the end card via the concat demuxer (`-c copy`).
    4. **Real-product end card.** `build_endcard.py` composites the REAL retail product photo
       over the brand palette (flat, or sampled from the photo's own edge pixel) with a typeset
       wordmark + claim rows + CTA pill in PIL `ImageDraw.text` — **never an AI cartoon bottle,
       never AI-rendered brand text**. `compose.py` Ken-Burnses it 1.00 → 1.04 over the dwell.
    5. **Mix.** VO bus `loudnorm I=-14 TP=-1.5`, music bus `loudnorm I=-26 TP=-3` then
       `volume=0.62`, `amix inputs=2 duration=first normalize=0` → master lands at
       -14.5..-13.5 LUFS with the music ducked under the VO.
    6. **Captions last.** `make_captions.py` emits a libass `.ass` (one cue per scene, Arial 64
       white / 6px outline / MarginV=330, `start = scene_start + 0.08s`, suppressed on the end
       card). `compose.py` burns it as the final filter so captions sit on top.
    
    ## Scripts (free — Python + ffmpeg + PIL, no bash, no paid calls)
    
    - `scripts/build_endcard.py` — PIL composite of the real product photo + typeset brand
      layer (wordmark / product line / claim rows / accent CTA pill). Reads the same
      `config.json`. Run this FIRST so `end_card.image` exists before `compose.py`.
    - `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 point `config.captions_ass` at nothing to skip captions).
    - `scripts/compose.py` — the assembler: per-scene retime + identical 30fps re-encode →
      concat → Ken-Burns end card → VO/music loudnorm mix → burn captions → master mp4.
    - `scripts/config.example.json` — the shape of the `config` the recipe binds (the
      brand-neutralised Soteri values as a worked reference).
    
    ## Inputs (all via `--config` + a runtime work dir — NO hardcoded paths)
    
    `config.json` carries: `scenes[]` (each `{id, clip, target_sec, vo, caption, atempo?}`
    where `target_sec` is the **measured** VO window), `end_card{product_image, image,
    dwell_sec, zoom_to, wordmark, product_line, claims[], cta, background?}`, `brand_palette
    {primary, primary_lite, accent, grey}`, `music_bed`, `music_volume` (default 0.62),
    `atempo` (compose-stage VO speed-up, default off; the reference runs used 1.3 when the VO
    read slow), `captions_ass`, and `caption_style`. See `config.example.json`.
    
    ## Craft rules (load-bearing — faithful to the source molecule)
    
    - **The end card is the REAL product photo, composited — never an AI cartoon bottle.**
      Both reference runs shipped an AI bottle first and had to re-shoot with the real photo.
    - **No AI-rendered brand text anywhere.** Wordmark, claims, CTA, motif — all PIL
      `ImageDraw.text`. AI draws the world + characters only.
    - **Re-encode every segment to 30fps before concat**, even if already correct, or the
      concat demuxer silently drops frames.
    - **`target_sec` is the MEASURED VO duration** (ffprobe each VO mp3), never a planned word
      count — VO drives the per-scene timing.
    - **Mix constants are validated** — VO -14 LUFS, music -26 LUFS then `volume≈0.62` (Soteri)
      to `0.70` (Big Chill), `amix normalize=0`. Master target -14.5..-13.5 LUFS,
      true-peak ≤ -1.5 dBFS.
    - **Caption `start = scene_start + 0.08s`**, suppressed on the end card (its typeset copy
      carries the message — two text layers at one spot are both unreadable).
    
    ## Requires
    
    `watch` (QC the final master — confirm the villain silhouette holds, the single voice
    carries the whole spot, the motif lands ≥3×, no AI brand text leaked into a cartoon
    background, the end card is the real product, and duration is within ±0.1s of the summed
    windows). The recipe gates the paid `create-image-fal` (keyframes), `create-video-fal`
    (Seedance i2v), `create-vo-elevenlabs`, and `create-music-elevenlabs` calls to their own
    capabilities — this capability itself makes NO paid calls.
    
  • skill.meta.json 329 B
    {
      "slug": "render-absurdist-explainer",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-absurdist-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