Claude Skill

render-vignette

Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video — birefnet cutout, then a cold-open text card + product carousel + annotated specimen-sheet end card, plus loudnorm + separate-pass music mux. FREE assembly (PIL + rsvg + FFm

LLM Mart · 0 points · 6 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-vignette-e1592ee.zip · 11 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-vignette
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-vignette

Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video. Motion lives in the BG video; the product rides on top as a static cutout layer. Music-led, zero VO, sub-12s, loopable — it reads muted because the product label + on-screen copy carry the message. Defaults to the V-CARD structure: a cold-open text card → a product carousel under one shared BG → an annotated specimen-sheet end card.

Run

  1. strip_product_backgrounds.py — birefnet cutout of each PDP shot to clean hard-edge alpha (no halo/shadow).
  2. render_overlays.py — PIL + rsvg-convert render the cold-open card (Boska Black, dead-center) + the annotated specimen-sheet end card (brand SVG logo + Space Grotesk annotations) as transparent 1080x1920 PNGs. FREE.
  3. composite_variants.py — one FFmpeg filter_complex per BG variant: BG (palette-aware dim) → cold-open overlay → cutouts (width-anchored, vertically centered y=(H-h)/2) → end card. h264 crf20 yuv420p +faststart 30fps. FREE.
  4. music_and_mux.py — instrumental music bed → acompressor + loudnorm I=-18:TP=-2:LRA=9 → muxed into every variant in a SEPARATE pass with explicit -map 0:v:0 -map 1:a:0. The mux is FREE; the music generation is a paid call that in prod routes through create-music-elevenlabs.

Contract

  • FREE assembly: birefnet cutout (see gap below) + PIL/rsvg overlays + FFmpeg composite + mux. No AI-rendered text; the product art/labels and on-screen copy are real, never invented.
  • The template recipe (DB) supplies the per-brand config (products, cold-open text, end-card lines, BG concept, beat timing). This capability is the generic assembler.
  • Craft rules preserved from the source molecule:
    • Cutouts stripped clean (no halo/shadow), height-anchored at vertical-center (y=(H-h)/2) so mixed-shape SKUs share one visual mid-line — never bottom-anchor (squat jars jump). For 9:16 scale by WIDTH (~75% tall bottles, ~65% squat jars).
    • Palette-aware BG dim: high-contrast/chrome BG → push saturation DOWN hard (saturation=0.50); naturally-contrasty BG → lighter dim (saturation=0.85).
    • End card = annotated specimen-sheet (EST year + rule + wordmark + rule + ingredient + positioning + claim), never a bare logo. Use the WHITE logo variant on dark BGs, cream on light.
    • Music-led, NO VO — instrumental only (VO/lyrics would fight the cold-open + end-card text). Loudnorm before the mux.
    • Mux is a SEPARATE FFmpeg pass with explicit -map 0:v:0 -map 1:a:0 (single-pass composite+mux silently ships 1 kbps garbage audio).

Gaps / routing notes

  • birefnet is a paid FAL model, not free. strip_product_backgrounds.py calls fal-ai/birefnet/v2 directly via a fal_helpers shim (sys.path.insert into a shared atoms dir + from fal_helpers import ...). It is bundled here because the cutout is an intrinsic assembly step, but in prod the birefnet cutout should route through create-image-fal (the fal-proxy capability that bills the Ads agent) rather than hitting fal.run directly. Treat the direct-fal path as a gap to close; the recipe gates the cutout to gooseworks fetch create-image-fal.
  • Background sourcing is not in this capability. The kinetic BG is sourced upstream — PEXELS-FIRST (free stock, the key cost lever), falling back to T2V (create-video-fal) only when stock coverage fails — and dropped into source/t2v-outputs/<slug>.mp4 so the composite is source-agnostic. There is no Pexels fetcher bundled here; that lives in the recipe's playbook.
  • music_and_mux.py also uses the fal_helpers shim for the ElevenLabs music generation; in prod that generation routes through create-music-elevenlabs, and only the loudnorm + separate-pass mux run locally as FREE assembly.
Files (goose-skills)
  • scripts
    • composite_variants.py 5.6 KB
      """Build 6 master mp4 variants at 9:16 1080×1920.
      
      v2.1 fixes:
        - Vertical-center cutouts (all 3 visual midpoints align)
        - Per-variant BG dim — alpha (chrome) gets heavier desaturate+darken,
          beta (ink-in-cream) keeps lighter dim
      
      For each T2V BG:
        1. Convert to 9:16 (scale-to-fit-vertical + center-crop horizontal)
        2. Darken/desaturate per variant family
        3. Loop if needed
        4. Overlay cutouts vertically-centered
        5. Overlay cold-open card + annotated end card
      """
      from __future__ import annotations
      
      import subprocess
      from concurrent.futures import ThreadPoolExecutor, as_completed
      from pathlib import Path
      
      PROJECT_ROOT = Path(__file__).resolve().parent.parent
      
      COLD_OPEN = PROJECT_ROOT / "assets" / "text-overlays" / "cold-open-card-9x16.png"
      END_CARD = PROJECT_ROOT / "assets" / "text-overlays" / "end-card-annotated-9x16.png"
      HERO = PROJECT_ROOT / "assets" / "product-cutouts" / "molecular-hero-serum.png"
      GENESIS = PROJECT_ROOT / "assets" / "product-cutouts" / "molecular-genesis.png"
      RETINOL = PROJECT_ROOT / "assets" / "product-cutouts" / "retinol-synergist.png"
      
      OUT = PROJECT_ROOT / "finals"
      OUT.mkdir(parents=True, exist_ok=True)
      
      W, H = 1080, 1920
      DURATION = 10.5
      
      HERO_W = int(W * 0.75)
      GEN_W = int(W * 0.65)
      RET_W = int(W * 0.75)
      
      # Per-variant family BG processing
      BG_PROCESS_ALPHA = (
          "scale=-2:1920:flags=lanczos,"
          "crop=1080:1920:(iw-1080)/2:0,"
          "eq=brightness=-0.30:saturation=0.50:contrast=1.15"  # heavier desaturate to kill chrome metallic
      )
      BG_PROCESS_BETA = (
          "scale=-2:1920:flags=lanczos,"
          "crop=1080:1920:(iw-1080)/2:0,"
          "eq=brightness=-0.18:saturation=0.85:contrast=1.10"  # lighter — ink-on-cream already has contrast
      )
      
      VARIANTS = [
          "alpha-VEO", "alpha-KLING", "alpha-SEED",
          "beta-VEO", "beta-KLING", "beta-SEED",
      ]
      
      
      def composite_one(variant: str) -> dict:
          bg = PROJECT_ROOT / "source" / "t2v-outputs" / f"{variant}.mp4"
          if not bg.exists():
              return {"variant": variant, "status": "BG_MISSING"}
      
          bg_process = BG_PROCESS_ALPHA if variant.startswith("alpha-") else BG_PROCESS_BETA
          out = OUT / f"master-9x16-{variant}.mp4"
      
          # filter_complex: BG processed → cold-open overlay → 3 cutouts (vertically centered) → end card
          # Beat windows are HALF-OPEN [start, next_start): ffmpeg's between(t,a,b) is inclusive on
          # BOTH ends, so consecutive beats that share a boundary (cold-open ends at 3.0, hero starts
          # at 3.0, …) both draw on the single frame at the boundary — a ~1-frame flash of the old
          # beat under the new one (most visible as the cold-open card ghosting behind product 1).
          # gte(t,a)*lt(t,b) makes each beat own [a, b) exactly: no overlap, and no BG-only gap frame.
          filter_complex = (
              f"[0:v]trim=duration={DURATION},setpts=PTS-STARTPTS,"
              f"{bg_process}[bg];"
              f"[bg][1:v]overlay=0:0:enable='gte(t,1.5)*lt(t,3.0)'[v1];"
              # Hero Serum — vertical center
              f"[2:v]scale={HERO_W}:-1[hero];"
              f"[v1][hero]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,3.0)*lt(t,4.6)'[v2];"
              # Genesis — vertical center
              f"[3:v]scale={GEN_W}:-1[gen];"
              f"[v2][gen]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,4.6)*lt(t,6.2)'[v3];"
              # Retinol — vertical center
              f"[4:v]scale={RET_W}:-1[ret];"
              f"[v3][ret]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,6.2)*lt(t,7.8)'[v4];"
              # End card — runs to the end (no upper bound so a rounded duration can't drop the tail)
              f"[v4][5:v]overlay=0:0:enable='gte(t,7.8)'[vout]"
          )
      
          cmd = [
              "ffmpeg", "-y",
              "-stream_loop", "-1", "-t", str(DURATION), "-i", str(bg),
              "-loop", "1", "-t", str(DURATION), "-i", str(COLD_OPEN),
              "-loop", "1", "-t", str(DURATION), "-i", str(HERO),
              "-loop", "1", "-t", str(DURATION), "-i", str(GENESIS),
              "-loop", "1", "-t", str(DURATION), "-i", str(RETINOL),
              "-loop", "1", "-t", str(DURATION), "-i", str(END_CARD),
              "-filter_complex", filter_complex,
              "-map", "[vout]",
              "-c:v", "libx264", "-preset", "medium", "-crf", "20",
              "-pix_fmt", "yuv420p",
              "-movflags", "+faststart",
              "-r", "30",
              "-t", str(DURATION),
              str(out),
          ]
      
          print(f"[{variant}] composing → {out.name}", flush=True)
          r = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
          if r.returncode != 0:
              return {"variant": variant, "status": "FFMPEG_ERROR", "stderr": r.stderr[-2000:]}
          size_mb = out.stat().st_size / 1024 / 1024
          return {"variant": variant, "status": "OK", "path": str(out.relative_to(PROJECT_ROOT)), "size_mb": round(size_mb, 1)}
      
      
      def main():
          print(f"compositing {len(VARIANTS)} variants in parallel (v2.1: vertical center + per-variant BG dim)…")
          results = []
          with ThreadPoolExecutor(max_workers=len(VARIANTS)) as ex:
              futures = {ex.submit(composite_one, v): v for v in VARIANTS}
              for fut in as_completed(futures):
                  v = futures[fut]
                  try:
                      r = fut.result()
                      results.append(r)
                      if r["status"] == "OK":
                          print(f"✓ {r['variant']}: {r['path']} ({r['size_mb']} MB)")
                      else:
                          print(f"✗ {r['variant']}: {r['status']}")
                          if "stderr" in r:
                              print(f"  {r['stderr'][-800:]}")
                  except Exception as e:
                      print(f"✗ {v}: EXCEPTION {e}")
                      results.append({"variant": v, "status": "EXCEPTION", "error": str(e)})
      
          ok = sum(1 for r in results if r.get("status") == "OK")
          print(f"\n→ {ok}/{len(VARIANTS)} variants succeeded")
          return 0 if ok == len(VARIANTS) else 1
      
      
      if __name__ == "__main__":
          import sys
          sys.exit(main())
      
    • music_and_mux.py 5.8 KB
      """Generate one music bed via fal-ai/elevenlabs/music, then mux into all 6 variants.
      
      Per memory:
        feedback_ffmpeg_map_directive: when ffmpeg has two -i inputs, always pass -map 0:v -map 1:a
        feedback_ffmpeg_lcut_endcard_recipe: build video + audio in SEPARATE ffmpeg passes, not one
        feedback_elevenlabs_music_decay: if music tapers in 2nd half, loop-and-flatten instead of regen
      """
      from __future__ import annotations
      
      import subprocess
      import sys
      from concurrent.futures import ThreadPoolExecutor, as_completed
      from pathlib import Path
      
      PROJECT_ROOT = Path(__file__).resolve().parent.parent
      SHARED = PROJECT_ROOT.parent.parent.parent / "skills" / "atoms" / "_shared"
      sys.path.insert(0, str(SHARED))
      
      from fal_helpers import download, load_fal_key, subscribe  # noqa: E402
      
      FINALS = PROJECT_ROOT / "finals"
      MUSIC_DIR = PROJECT_ROOT / "assets" / "music"
      MUSIC_DIR.mkdir(parents=True, exist_ok=True)
      MUSIC_RAW = MUSIC_DIR / "mother-science-bed-raw.mp3"
      MUSIC_FINAL = MUSIC_DIR / "mother-science-bed-final.mp3"
      
      DURATION = 10.5
      TARGET_MUSIC_DUR = 12.0  # generate slightly longer than video
      
      MUSIC_BRIEF = (
          "Clinical-luxury skincare science vignette ad music bed. Slow-tempo ambient "
          "minimal electronic with warm low pads and subtle high-end sparkle. "
          "Instrumental only, no vocals, no lyrics. 90 BPM, sophisticated and quiet. "
          "Begins with soft atmospheric pad and gentle resonant tones. Subtly builds "
          "with a single sparkling synth note in the last 2 seconds for the end-card "
          "brand reveal. Premium, restrained, Augustinus Bader / La Mer luxury "
          "skincare commercial mood. 12 seconds total."
      )
      
      VARIANTS = [
          "alpha-VEO", "alpha-KLING", "alpha-SEED",
          "beta-VEO", "beta-KLING", "beta-SEED",
      ]
      
      
      def generate_music():
          """Fire fal-ai/elevenlabs/music."""
          print("generating music bed via fal-ai/elevenlabs/music…")
          print(f"  brief: {MUSIC_BRIEF[:120]}…")
          result = subscribe(
              "fal-ai/elevenlabs/music",
              {
                  "prompt": MUSIC_BRIEF,
                  "music_length_ms": int(TARGET_MUSIC_DUR * 1000),
                  "output_format": "mp3_44100_192",
              },
              timeout_sec=600,
          )
          if not result or "audio" not in result:
              raise RuntimeError(f"music gen failed: {result}")
          url = result["audio"]["url"]
          download(url, MUSIC_RAW)
          print(f"  ✓ raw music: {MUSIC_RAW.relative_to(PROJECT_ROOT)} ({MUSIC_RAW.stat().st_size // 1024} KB)")
          return MUSIC_RAW
      
      
      def trim_and_normalize(src: Path):
          """Trim music to DURATION + apply gentle compressor + loudnorm so it sits under no-VO video."""
          print(f"trimming + normalizing music to {DURATION}s…")
          cmd = [
              "ffmpeg", "-y", "-i", str(src),
              "-t", str(DURATION),
              "-af", "acompressor=threshold=-12dB:ratio=2:attack=20:release=200,loudnorm=I=-18:TP=-2:LRA=9",
              "-c:a", "mp3", "-b:a", "192k",
              "-loglevel", "error",
              str(MUSIC_FINAL),
          ]
          r = subprocess.run(cmd, capture_output=True, text=True)
          if r.returncode != 0:
              print(f"FAIL: {r.stderr}")
              return False
          print(f"  ✓ final music: {MUSIC_FINAL.relative_to(PROJECT_ROOT)} ({MUSIC_FINAL.stat().st_size // 1024} KB)")
          return True
      
      
      def mux_one(variant: str) -> dict:
          """Mux music into one variant. Use -map 0:v -map 1:a per memory rule."""
          src_video = FINALS / f"master-9x16-{variant}.mp4"
          if not src_video.exists():
              return {"variant": variant, "status": "VIDEO_MISSING"}
      
          # Write to temp then rename, so we don't corrupt the source
          tmp_out = FINALS / f"_tmp_{variant}.mp4"
          cmd = [
              "ffmpeg", "-y",
              "-i", str(src_video),
              "-i", str(MUSIC_FINAL),
              "-c:v", "copy",
              "-c:a", "aac", "-b:a", "192k", "-ar", "44100",
              "-map", "0:v:0", "-map", "1:a:0",
              "-shortest",
              "-movflags", "+faststart",
              "-loglevel", "error",
              str(tmp_out),
          ]
          r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
          if r.returncode != 0:
              return {"variant": variant, "status": "MUX_ERROR", "stderr": r.stderr[-1000:]}
          # Move tmp over original
          tmp_out.replace(src_video)
          # Verify audio is actually there + not 1kbps
          probe = subprocess.run(
              ["ffprobe", "-v", "error", "-select_streams", "a:0",
               "-show_entries", "stream=codec_name,bit_rate,duration", "-of", "default=noprint_wrappers=1",
               str(src_video)],
              capture_output=True, text=True,
          )
          return {"variant": variant, "status": "OK", "audio_probe": probe.stdout.strip()}
      
      
      def main():
          load_fal_key()
      
          # 1. Generate music (skip if cached)
          if not MUSIC_RAW.exists():
              generate_music()
          else:
              print(f"using cached music: {MUSIC_RAW.relative_to(PROJECT_ROOT)}")
      
          # 2. Trim + normalize
          if not trim_and_normalize(MUSIC_RAW):
              return 1
      
          # 3. Mux into 6 variants in parallel
          print(f"\nmuxing music into {len(VARIANTS)} variants in parallel…")
          results = []
          with ThreadPoolExecutor(max_workers=len(VARIANTS)) as ex:
              futures = {ex.submit(mux_one, v): v for v in VARIANTS}
              for fut in as_completed(futures):
                  v = futures[fut]
                  try:
                      r = fut.result()
                      results.append(r)
                      if r["status"] == "OK":
                          print(f"✓ {r['variant']}: muxed")
                          for line in r["audio_probe"].splitlines():
                              print(f"    {line}")
                      else:
                          print(f"✗ {r['variant']}: {r['status']}")
                  except Exception as e:
                      results.append({"variant": v, "status": "EXC", "error": str(e)})
                      print(f"✗ {v}: EXC {e}")
      
          ok = sum(1 for r in results if r.get("status") == "OK")
          print(f"\n→ {ok}/{len(VARIANTS)} variants muxed with music")
          return 0 if ok == len(VARIANTS) else 1
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • render_overlays.py 5.4 KB
      """Render 9:16 (1080×1920) transparent overlay PNGs for the composite:
        - cold-open-card-9x16.png   '100% / PROVEN / RESULTS'  Boska Black, dead-center
        - end-card-annotated-9x16.png  EST 2023 / MOTHER SCIENCE logo / MAL•UH•SAY•ZIN / 10× tagline
                                        Pinterest-inspired specimen-sheet style
      """
      from pathlib import Path
      import subprocess
      from PIL import Image, ImageChops, ImageDraw, ImageFont
      
      PROJECT_ROOT = Path(__file__).resolve().parent.parent
      BOSKA = PROJECT_ROOT / "assets" / "fonts" / "Boska-Black.ttf"
      SG_MED = PROJECT_ROOT / "assets" / "fonts" / "SpaceGrotesk-Medium.ttf"
      SG_SB = PROJECT_ROOT / "assets" / "fonts" / "SpaceGrotesk-SemiBold.ttf"
      OUT = PROJECT_ROOT / "assets" / "text-overlays"
      OUT.mkdir(parents=True, exist_ok=True)
      
      W, H = 1080, 1920
      CREAM = (249, 247, 239, 255)  # #f9f7ef
      CREAM_DIM = (249, 247, 239, 180)  # 70% opacity cream for small annotations
      
      
      def render_cold_open_card_9x16():
          """3 stacked lines: 100% / PROVEN / RESULTS, dead-center, Boska Black."""
          im = Image.new("RGBA", (W, H), (0, 0, 0, 0))
          draw = ImageDraw.Draw(im)
      
          lines = ["100%", "PROVEN", "RESULTS"]
          font_size = 240
          font = ImageFont.truetype(str(BOSKA), font_size)
      
          line_height_mult = 0.92
          line_height_px = int(font_size * line_height_mult)
          total_height = line_height_px * len(lines)
          start_y = (H - total_height) // 2
      
          for i, line in enumerate(lines):
              bbox = draw.textbbox((0, 0), line, font=font)
              text_w = bbox[2] - bbox[0]
              x = (W - text_w) // 2
              y = start_y + i * line_height_px
              draw.text((x, y), line, font=font, fill=CREAM)
      
          dst = OUT / "cold-open-card-9x16.png"
          im.save(dst, "PNG", optimize=True)
          print(f"✓ cold-open-card-9x16: {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)")
      
      
      def autocrop_alpha(im):
          bbox = im.split()[-1].getbbox()
          return im.crop(bbox) if bbox else im
      
      
      def render_end_card_annotated_9x16():
          """Pinterest-inspired specimen-sheet style end card.
      
          Layout (top → bottom centered):
            EST. 2023                          (small tracking, Space Grotesk Med)
            ─────────────────                  (subtle horizontal rule)
                                                (gap)
            MOTHER SCIENCE                     (large cream wordmark from brand SVG)
                                                (gap)
            ─────────────────                  (subtle horizontal rule)
            MAL · UH · SAY · ZIN               (small tracking, Space Grotesk Med)
            NOVEL MOLECULE                     (smaller still, Space Grotesk Med)
            10× MORE POWERFUL THAN VITAMIN C   (smaller, Space Grotesk Med)
          """
          im = Image.new("RGBA", (W, H), (0, 0, 0, 0))
          draw = ImageDraw.Draw(im)
      
          # ── Render brand logo SVG at target width ──
          svg = PROJECT_ROOT / "assets" / "end-cards" / "mother-science-logo-cream.svg"
          target_logo_w = int(W * 0.80)  # 80% frame width
          tmp = OUT / "_logo_raw.png"
          subprocess.run(
              ["rsvg-convert", "-w", "2400", str(svg), "-o", str(tmp)],
              check=True, capture_output=True,
          )
          logo = autocrop_alpha(Image.open(tmp).convert("RGBA"))
          logo_h = int(logo.height * (target_logo_w / logo.width))
          logo = logo.resize((target_logo_w, logo_h), Image.LANCZOS)
      
          # ── Layout positions ──
          center_y = H // 2
          logo_y = center_y - logo_h // 2
          logo_x = (W - target_logo_w) // 2
      
          # ── Top annotation ──
          f_top = ImageFont.truetype(str(SG_MED), 26)
          top_text = "E S T   2 0 2 3"  # extra-tracking via spaces
          bbox = draw.textbbox((0, 0), top_text, font=f_top)
          top_w = bbox[2] - bbox[0]
          top_y = logo_y - 130
          draw.text(((W - top_w) // 2, top_y), top_text, font=f_top, fill=CREAM_DIM)
      
          # ── Rule line above logo ──
          rule_w = 200
          rule_x = (W - rule_w) // 2
          rule_y_top = logo_y - 60
          draw.line([(rule_x, rule_y_top), (rule_x + rule_w, rule_y_top)], fill=CREAM_DIM, width=2)
      
          # ── Logo ──
          im.paste(logo, (logo_x, logo_y), logo)
      
          # ── Rule line below logo ──
          rule_y_bot = logo_y + logo_h + 60
          draw.line([(rule_x, rule_y_bot), (rule_x + rule_w, rule_y_bot)], fill=CREAM_DIM, width=2)
      
          # ── Annotation block below ──
          f_mal = ImageFont.truetype(str(SG_SB), 38)
          mal = "M A L · U H · S A Y · Z I N"
          bbox = draw.textbbox((0, 0), mal, font=f_mal)
          mal_w = bbox[2] - bbox[0]
          mal_y = rule_y_bot + 35
          draw.text(((W - mal_w) // 2, mal_y), mal, font=f_mal, fill=CREAM)
      
          f_sub = ImageFont.truetype(str(SG_MED), 22)
          sub1 = "N O V E L   M O L E C U L E"
          bbox = draw.textbbox((0, 0), sub1, font=f_sub)
          sub1_w = bbox[2] - bbox[0]
          sub1_y = mal_y + 60
          draw.text(((W - sub1_w) // 2, sub1_y), sub1, font=f_sub, fill=CREAM_DIM)
      
          f_claim = ImageFont.truetype(str(SG_MED), 24)
          claim = "10× MORE POWERFUL ANTIOXIDANT THAN VITAMIN C"
          bbox = draw.textbbox((0, 0), claim, font=f_claim)
          claim_w = bbox[2] - bbox[0]
          claim_y = sub1_y + 50
          draw.text(((W - claim_w) // 2, claim_y), claim, font=f_claim, fill=CREAM_DIM)
      
          tmp.unlink()
          dst = OUT / "end-card-annotated-9x16.png"
          im.save(dst, "PNG", optimize=True)
          print(f"✓ end-card-annotated-9x16: {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)")
      
      
      def main():
          render_cold_open_card_9x16()
          render_end_card_annotated_9x16()
      
      
      if __name__ == "__main__":
          main()
      
    • strip_product_backgrounds.py 4.1 KB
      """Strip product backgrounds via fal-ai/birefnet/v2.
      
      Reads PNGs from ../source/scraped-product-images/, fires birefnet-v2 in parallel,
      saves cutouts to ../assets/product-cutouts/, validates alpha quality, writes
      manifest.json with per-file stats.
      
      Run from this directory: python3 01_strip_product_backgrounds.py
      """
      from __future__ import annotations
      
      import json
      import sys
      from concurrent.futures import ThreadPoolExecutor, as_completed
      from pathlib import Path
      
      PROJECT_ROOT = Path(__file__).resolve().parent.parent
      SHARED = PROJECT_ROOT.parent.parent.parent / "skills" / "atoms" / "_shared"
      sys.path.insert(0, str(SHARED))
      
      from fal_helpers import download, load_fal_key, subscribe, upload_file  # noqa: E402
      
      SOURCE_DIR = PROJECT_ROOT / "source" / "scraped-product-images"
      OUTPUT_DIR = PROJECT_ROOT / "assets" / "product-cutouts"
      MANIFEST = OUTPUT_DIR / "manifest.json"
      
      PRODUCTS = [
          "molecular-hero-serum.png",
          "molecular-genesis.png",
          "retinol-synergist.png",
      ]
      
      
      def strip_one(filename: str) -> dict:
          src = SOURCE_DIR / filename
          dst = OUTPUT_DIR / filename
          if not src.exists():
              return {"file": filename, "status": "ERROR_MISSING_SOURCE"}
      
          print(f"[{filename}] uploading {src.stat().st_size // 1024} KB…", flush=True)
          image_url = upload_file(src)
      
          print(f"[{filename}] running birefnet-v2…", flush=True)
          result = subscribe(
              "fal-ai/birefnet/v2",
              {"image_url": image_url},
              timeout_sec=300,
          )
      
          if not result or "image" not in result:
              return {"file": filename, "status": "ERROR_NO_IMAGE_IN_RESULT", "result": result}
      
          print(f"[{filename}] downloading cutout…", flush=True)
          download(result["image"]["url"], dst)
      
          # validate alpha
          from PIL import Image
          im = Image.open(dst)
          if im.mode != "RGBA":
              im = im.convert("RGBA")
              im.save(dst)
          alpha = im.split()[3]
          pixels = list(alpha.getdata())
          total = len(pixels)
          transparent = sum(1 for p in pixels if p == 0)
          opaque = sum(1 for p in pixels if p == 255)
          partial = total - transparent - opaque
          pct_transparent = 100 * transparent / total
          pct_partial = 100 * partial / total
      
          quality = "GOOD"
          warnings = []
          if pct_transparent < 20:
              quality = "BAD_NO_REMOVAL"
              warnings.append(f"only {pct_transparent:.1f}% transparent — BG not stripped")
          if pct_partial > 8:
              quality = "WARN_SOFT_EDGE"
              warnings.append(f"{pct_partial:.1f}% partial-alpha edge — may show halo")
      
          return {
              "file": filename,
              "status": "OK",
              "quality": quality,
              "warnings": warnings,
              "size_px": list(im.size),
              "pct_transparent": round(pct_transparent, 1),
              "pct_partial_alpha": round(pct_partial, 1),
              "output_path": str(dst.relative_to(PROJECT_ROOT)),
              "fal_url": result["image"]["url"],
          }
      
      
      def main() -> int:
          OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
          load_fal_key()
          print(f"running birefnet/v2 on {len(PRODUCTS)} PNGs in parallel…", flush=True)
      
          results = []
          with ThreadPoolExecutor(max_workers=3) as ex:
              futures = {ex.submit(strip_one, p): p for p in PRODUCTS}
              for fut in as_completed(futures):
                  try:
                      r = fut.result()
                      results.append(r)
                      print(f"\n>>> DONE [{r['file']}]: {r.get('status')} {r.get('quality', '')}")
                      for w in r.get("warnings", []):
                          print(f"    WARN: {w}")
                  except Exception as e:
                      results.append({"file": futures[fut], "status": "EXCEPTION", "error": str(e)})
                      print(f"\n>>> FAILED [{futures[fut]}]: {e}")
      
          MANIFEST.write_text(json.dumps({"results": results}, indent=2))
          print(f"\nmanifest: {MANIFEST}")
      
          bad = [r for r in results if r.get("status") != "OK" or r.get("quality") == "BAD_NO_REMOVAL"]
          if bad:
              print(f"\n{len(bad)} file(s) need attention:")
              for r in bad:
                  print(f"  - {r['file']}: {r.get('status')} / {r.get('quality', 'n/a')}")
              return 1
          return 0
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
  • tests
    • smoke-test.md 706 B
      # Smoke Test
      
      render_overlays.py (cold-open card + annotated end card PNGs) ; composite_variants.py (BG dim + vertically-centered cutouts + overlays → silent master) ; music_and_mux.py (loudnorm + separate-pass mux, `-map 0:v:0 -map 1:a:0`) — 1080x1920, 9-12s, 30fps, h264 yuv420p crf20 +faststart, AAC 192k ~-18 LUFS.
      
      Pass when the assembly scripts run to a valid master mp4 with real (non-1kbps) audio. Assembly (overlays + composite + mux) is FREE. The birefnet cutout (`strip_product_backgrounds.py`) and the music generation are paid FAL/ElevenLabs calls that in prod route through create-image-fal / create-music-elevenlabs (proxy-routed, bills the agent) — not a provider SDK's default host.
      
  • SKILL.md 4.2 KB
    ---
    name: render-vignette
    description: Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video — birefnet cutout, then a cold-open text card + product carousel + annotated specimen-sheet end card, plus loudnorm + separate-pass music mux. FREE assembly (PIL + rsvg + FFmpeg); the recipe supplies the config and gates the paid BG/cutout/music calls to their own capabilities. Use for the vignette format.
    status: active
    ---
    
    # render-vignette
    
    Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video. Motion lives in the BG video; the product rides on top as a static cutout layer. Music-led, zero VO, sub-12s, loopable — it reads muted because the product label + on-screen copy carry the message. Defaults to the V-CARD structure: a cold-open text card → a product carousel under one shared BG → an annotated specimen-sheet end card.
    
    ## Run
    1. `strip_product_backgrounds.py` — birefnet cutout of each PDP shot to clean hard-edge alpha (no halo/shadow).
    2. `render_overlays.py` — PIL + `rsvg-convert` render the cold-open card (Boska Black, dead-center) + the annotated specimen-sheet end card (brand SVG logo + Space Grotesk annotations) as transparent 1080x1920 PNGs. FREE.
    3. `composite_variants.py` — one FFmpeg `filter_complex` per BG variant: BG (palette-aware dim) → cold-open overlay → cutouts (width-anchored, vertically centered `y=(H-h)/2`) → end card. h264 crf20 yuv420p +faststart 30fps. FREE.
    4. `music_and_mux.py` — instrumental music bed → `acompressor + loudnorm I=-18:TP=-2:LRA=9` → muxed into every variant in a SEPARATE pass with explicit `-map 0:v:0 -map 1:a:0`. The mux is FREE; the music generation is a paid call that in prod routes through create-music-elevenlabs.
    
    ## Contract
    - FREE assembly: birefnet cutout (see gap below) + PIL/rsvg overlays + FFmpeg composite + mux. No AI-rendered text; the product art/labels and on-screen copy are real, never invented.
    - The template recipe (DB) supplies the per-brand config (products, cold-open text, end-card lines, BG concept, beat timing). This capability is the generic assembler.
    - Craft rules preserved from the source molecule:
      - Cutouts stripped clean (no halo/shadow), height-anchored at vertical-center (`y=(H-h)/2`) so mixed-shape SKUs share one visual mid-line — never bottom-anchor (squat jars jump). For 9:16 scale by WIDTH (~75% tall bottles, ~65% squat jars).
      - Palette-aware BG dim: high-contrast/chrome BG → push saturation DOWN hard (`saturation=0.50`); naturally-contrasty BG → lighter dim (`saturation=0.85`).
      - End card = annotated specimen-sheet (EST year + rule + wordmark + rule + ingredient + positioning + claim), never a bare logo. Use the WHITE logo variant on dark BGs, cream on light.
      - Music-led, NO VO — instrumental only (VO/lyrics would fight the cold-open + end-card text). Loudnorm before the mux.
      - Mux is a SEPARATE FFmpeg pass with explicit `-map 0:v:0 -map 1:a:0` (single-pass composite+mux silently ships 1 kbps garbage audio).
    
    ## Gaps / routing notes
    - **birefnet is a paid FAL model, not free.** `strip_product_backgrounds.py` calls `fal-ai/birefnet/v2` directly via a `fal_helpers` shim (`sys.path.insert` into a shared atoms dir + `from fal_helpers import ...`). It is bundled here because the cutout is an intrinsic assembly step, but in prod the birefnet cutout should route through **create-image-fal** (the fal-proxy capability that bills the Ads agent) rather than hitting `fal.run` directly. Treat the direct-fal path as a gap to close; the recipe gates the cutout to `gooseworks fetch create-image-fal`.
    - **Background sourcing is not in this capability.** The kinetic BG is sourced upstream — PEXELS-FIRST (free stock, the key cost lever), falling back to T2V (create-video-fal) only when stock coverage fails — and dropped into `source/t2v-outputs/<slug>.mp4` so the composite is source-agnostic. There is no Pexels fetcher bundled here; that lives in the recipe's playbook.
    - `music_and_mux.py` also uses the `fal_helpers` shim for the ElevenLabs music generation; in prod that generation routes through **create-music-elevenlabs**, and only the loudnorm + separate-pass mux run locally as FREE assembly.
    
  • skill.meta.json 307 B
    {
      "slug": "render-vignette",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-vignette",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related