Claude Skill

render-3d-product-showcase

Assemble a premium 3D product-showcase ad from a config — four beat clips (an orbiting hero rotation, a macro push-in, a physics reveal, a typographic close) normalized to the brand-color canvas, hard-concatenated in order, closed on a deterministic Playwright brand end card, and

LLM Mart · 0 points · 5 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-product-showcase-e1592ee.zip · 17 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-product-showcase
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-product-showcase

Assemble a premium 3D product-showcase ad from a config: one real product floats centered on a clean seamless brand-color backdrop and sells itself across four beats — an orbiting hero rotation, a macro push-in on the surface detail, a physics reveal (exploded_view / particle_disintegration / liquid_splash, or rotation_only), then a typographic brand close. This capability is the FREE, deterministic assembly that stitches the delivered beats into the master; it spends nothing.

scripts/config.example.json is the worked example (DIBS Beauty "Desert Island Duo", ~15s 720×1280 9:16); scripts/build_endcard.py + scripts/build_masters.py are the runnable free assembly; scripts/PIPELINE.md maps every config block to its source step; scripts/README.md documents the assembly.

Run

This is the FREE, deterministic assembly stage — it spends nothing. The paid inputs are separate capabilities: Beats 1 & 2 (hero rotation + macro push-in) are create-video-fal image-to-video seeded on a create-image-fal styled hero — a nano-banana restyle of the brand's REAL product photo onto the seamless studio set, so the product geometry is real and never AI-invented; Beat 3 (the physics reveal) is a Veo3 image-to-video seeded on Beat 1's last frame (also create-video-fal); the one instrumental bed is create-music-elevenlabs. There is no Higgsfield / Marketing Studio in this format — everything paid runs through the fal-proxy / elevenlabs-proxy so it bills the Ads agent.

Given those beats + the brand wordmark, this capability:

  1. build_endcard.py --bg beat1_last_frame.png --headline "…" --wordmark <wordmark> --out endcard.png — the deterministic Beat-4 hyperframe (Playwright 1080×1920 → ffmpeg-scale to 720×1280).
  2. build_masters.py --config config.json --clips working/clips --endcard endcard.png --music music.mp3 --out master.mp4 — trims each beat to its window, normalizes to the brand canvas, hard-concats, mixes the bed.

Re-cuts reuse the existing beats and cost $0.

Contract (the free assembly)

  • Music-only, no VO. One ElevenLabs instrumental bed carries the film; nobody speaks. Do not add a spoken voiceover or a second bed.
  • Beat 1's last frame is the shared anchor. Extract it (ffmpeg -sseof -0.1 … -frames:v 1) once — it seeds the Veo3 Beat 3 AND backs the Beat 4 end-card hyperframe, so the product + lighting carry across all four beats and the geometry never AI-drifts.
  • End card via Playwright from the real wordmark — never AI-render brand text. The brand close is a deterministic hyperframe (Beat 1 last frame + scrim + Playfair headline + the real wordmark, recolored for contrast). build_endcard.py auto-picks a legible headline color from the bg luminance and renders 1080×1920 then scales to 720×1280. Playfair is loaded from Google Fonts; bundle the .ttf if determinism offline matters. If the resolvable Playwright wants an uninstalled browser build, export PW_CHROME=<installed Chromium binary> (shoot.js honours it).
  • Normalize each beat to the brand canvas, hard-concat. Per beat: trim to the window, strip the i2v model's auto-audio (-an), scale + pad to 720×1280 with the brand bg pad color, 24fps, yuv420p, crf 18 → concat demuxer. No dissolves.
  • FFmpeg mix, deterministic, FREE. Mix one instrumental bed (afade in/out + loudnorm I=-16 TP=-1.5 LRA=11, apad + atrimmed to master duration) over the concatenated beats → a 720×1280 h264+aac master. No paid calls, no keys.
Files (goose-skills)
  • scripts
    • build_endcard.py 5.5 KB
      #!/usr/bin/env python3
      """build_endcard.py — the deterministic Beat-4 brand close (FREE, no AI text).
      
      Composites a typographic end card: the stilled Beat-1 last frame + a scrim +
      a Playfair Display headline + the brand's REAL wordmark (recolored for
      contrast). Renders at 1080x1920 via Playwright, then ffmpeg-scales to 720x1280
      (matching the viewport to the output dims clips the right edge). The brand text
      is NEVER AI-rendered — a diffusion model garbles a wordmark, so the lockup is
      composited from the real asset every time.
      
        build_endcard.py --bg beat1_last_frame.png --headline "YOUR SANCTUARY AT HOME" \
            --wordmark casablui-logo.svg --out endcard.png \
            [--headline-size 86] [--headline-color auto|#F6F1E7|#2A1F26] [--bg-is dark|light]
      
      Requires: a Playwright Chromium (via scripts/shoot.js) and ffmpeg. If the npx
      Playwright wants an uninstalled browser build, export PW_CHROME=<installed
      Chromium binary> — shoot.js honours it.
      """
      import argparse
      import base64
      import html as htmlmod
      import os
      import pathlib
      import subprocess
      import sys
      import tempfile
      
      HERE = pathlib.Path(__file__).resolve().parent
      LIGHT = "#F6F1E7"   # warm white — for dark/saturated brand backgrounds
      DARK = "#2A1F26"    # ink — for light/pastel brand backgrounds
      
      
      def _avg_luma(png_path):
          """Rough mean luminance (0-255) of the bg, to auto-pick a legible text color."""
          try:
              from PIL import Image
              im = Image.open(png_path).convert("RGB").resize((32, 32))
              px = list(im.getdata())
              return sum(0.299 * r + 0.587 * g + 0.114 * b for r, g, b in px) / len(px)
          except Exception:
              return 60.0  # assume dark -> warm-white text
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--bg", required=True, help="background image (Beat 1 last frame)")
          ap.add_argument("--headline", required=True)
          ap.add_argument("--wordmark", required=True, help="brand wordmark .svg or .png")
          ap.add_argument("--out", required=True, help="output PNG (scaled to 720x1280)")
          ap.add_argument("--headline-size", type=int, default=86)
          ap.add_argument("--headline-color", default="auto", help="'auto' | hex")
          ap.add_argument("--width", type=int, default=1080)
          ap.add_argument("--height", type=int, default=1920)
          ap.add_argument("--scale-w", type=int, default=720)
          ap.add_argument("--scale-h", type=int, default=1280)
          a = ap.parse_args()
      
          color = a.headline_color
          if color == "auto":
              color = LIGHT if _avg_luma(a.bg) < 128 else DARK
      
          b64 = base64.b64encode(pathlib.Path(a.bg).read_bytes()).decode()
          wm_path = pathlib.Path(a.wordmark)
          if wm_path.suffix.lower() == ".svg":
              svg = wm_path.read_text()
              # recolor common wordmark fills to the headline color; force a sane box
              for stock in ("#000000", "#000", "#153429", "#2A1F26", "#1a1a1a", "#111111"):
                  svg = svg.replace(f'fill="{stock}"', f'fill="{color}"')
              svg = svg.replace('fill="none"', 'fill="none" style="width:340px;height:auto;display:block"', 1)
              wordmark_html = f'<div class="wordmark">{svg}</div>'
          else:
              wm_b64 = base64.b64encode(wm_path.read_bytes()).decode()
              mime = "image/png" if wm_path.suffix.lower() == ".png" else "image/jpeg"
              wordmark_html = f'<img class="wordmark" src="data:{mime};base64,{wm_b64}">'
      
          # scrim: darken bottom for legibility regardless of headline color
          doc = f"""<!doctype html><html><head><meta charset="utf-8">
      <link rel="preconnect" href="https://fonts.googleapis.com">
      <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
      <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@600;700&display=swap" rel="stylesheet">
      <style>
        * {{ margin:0; padding:0; box-sizing:border-box; }}
        html,body {{ width:{a.width}px; height:{a.height}px; overflow:hidden; }}
        .stage {{ position:relative; width:{a.width}px; height:{a.height}px; }}
        .bg {{ position:absolute; inset:0; width:100%; height:100%; object-fit:cover; }}
        .scrim {{ position:absolute; inset:0; background:linear-gradient(180deg,
            rgba(10,14,12,.30) 0%, rgba(10,14,12,0) 34%, rgba(6,10,8,.12) 60%, rgba(4,8,6,.82) 100%); }}
        .lockup {{ position:absolute; left:0; right:0; bottom:150px; display:flex; flex-direction:column;
            align-items:center; gap:52px; padding:0 90px; }}
        .headline {{ font-family:'Playfair Display', Georgia, serif; font-weight:600; text-transform:uppercase;
            color:{color}; font-size:{a.headline_size}px; line-height:1.16; letter-spacing:.09em;
            text-align:center; text-shadow:0 3px 30px rgba(0,0,0,.55); max-width:900px; }}
        .wordmark {{ width:340px; opacity:.98; filter:drop-shadow(0 2px 14px rgba(0,0,0,.5)); }}
        .rule {{ width:64px; height:2px; background:{color}; opacity:.7; }}
      </style></head>
      <body><div class="stage">
        <img class="bg" src="data:image/png;base64,{b64}">
        <div class="scrim"></div>
        <div class="lockup">
          <div class="headline">{htmlmod.escape(a.headline)}</div>
          <div class="rule"></div>
          {wordmark_html}
        </div>
      </div></body></html>"""
      
          tmp_html = pathlib.Path(tempfile.mktemp(suffix=".html"))
          tmp_html.write_text(doc)
          raw_png = pathlib.Path(tempfile.mktemp(suffix=".png"))
      
          subprocess.run(
              ["node", str(HERE / "shoot.js"), tmp_html.as_uri(), str(raw_png),
               str(a.width), str(a.height), "2000"],
              check=True,
          )
          subprocess.run(
              ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(raw_png),
               "-vf", f"scale={a.scale_w}:{a.scale_h}", a.out],
              check=True,
          )
          print(a.out)
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • build_masters.py 4.8 KB
      #!/usr/bin/env python3
      """build_masters.py — the FREE, deterministic assembly (no paid calls, no keys).
      
      Given the delivered beat clips + the end-card PNG + one instrumental bed, this
      trims each beat to its window, normalizes every segment to the 720x1280 brand
      canvas (strips the i2v model's auto-audio with -an), hard-concats in beat order
      (no dissolves), and mixes the music bed at loudnorm I=-16 -> the h264+aac master.
      Re-cuts reuse the existing beats and cost $0.
      
        build_masters.py --config config.json \
            --clips working/clips --endcard working/endcard.png \
            --music working/music.mp3 --out working/master.mp4
      
      Expected inputs in --clips: beat1.mp4, beat2.mp4, beat3.mp4 (raw i2v takes;
      beat3 optional / skipped when reveal_variant == rotation_only).
      
      Per-beat trim window comes from config beats[].{duration_sec, trim_start}
      (trim_start defaults to 0.0 for the rotation/macro beats and 1.0 for the Veo3
      reveal so its 8s take lands on rise -> peak -> early settle).
      """
      import argparse
      import json
      import pathlib
      import subprocess
      import sys
      import tempfile
      
      TS = "12800"  # shared video_track_timescale so concat -c copy is safe
      
      
      def _run(cmd):
          subprocess.run(cmd, check=True)
      
      
      def _dur(path):
          out = subprocess.run(
              ["ffprobe", "-v", "error", "-show_entries", "format=duration",
               "-of", "default=noprint_wrappers=1:nokey=1", str(path)],
              capture_output=True, text=True, check=True)
          return float(out.stdout.strip())
      
      
      def _normalize(src, dst, bg, dur, fps, trim_start, is_still):
          vf = (f"scale=720:1280:force_original_aspect_ratio=decrease,"
                f"pad=720:1280:(ow-iw)/2:(oh-ih)/2:color={bg},setsar=1,fps={fps},format=yuv420p")
          base = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
          if is_still:
              base += ["-loop", "1", "-t", str(dur), "-i", str(src)]
          else:
              base += ["-ss", str(trim_start), "-t", str(dur), "-i", str(src)]
          base += ["-vf", vf, "-c:v", "libx264", "-crf", "18", "-preset", "medium",
                   "-pix_fmt", "yuv420p", "-video_track_timescale", TS, "-an", str(dst)]
          _run(base)
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--config", required=True)
          ap.add_argument("--clips", default="working/clips")
          ap.add_argument("--endcard", default="working/endcard.png")
          ap.add_argument("--music", default=None)
          ap.add_argument("--out", required=True)
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          bg = cfg.get("studio_look", {}).get("bg", "#000000").replace("#", "0x")
          fps = cfg.get("fps", 24)
          clips = pathlib.Path(a.clips)
          reveal = cfg.get("reveal_variant", "")
          beats = cfg.get("beats", [])
      
          tmpdir = pathlib.Path(tempfile.mkdtemp(prefix="3dps_"))
          segs = []
          # video beats 1..3 (raw clips), then the end card still
          beat_files = {"beat-01-hero-rotation": "beat1.mp4",
                        "beat-02-macro-detail": "beat2.mp4",
                        "beat-03-reveal": "beat3.mp4"}
          default_trim = {"beat-03-reveal": 1.0}
          for b in beats:
              bid = b.get("id", "")
              dur = float(b.get("duration_sec", 4))
              if b.get("is_end_card"):
                  seg = tmpdir / "seg_endcard.mp4"
                  _normalize(a.endcard, seg, bg, dur, fps, 0.0, is_still=True)
                  segs.append(seg)
                  continue
              if bid == "beat-03-reveal" and reveal == "rotation_only":
                  continue  # no reveal beat for rotation_only products
              src = clips / beat_files.get(bid, f"{bid}.mp4")
              if not src.exists():
                  sys.exit(f"missing beat clip: {src}")
              trim_start = float(b.get("trim_start", default_trim.get(bid, 0.0)))
              seg = tmpdir / f"seg_{bid}.mp4"
              _normalize(src, seg, bg, dur, fps, trim_start, is_still=False)
              segs.append(seg)
      
          # hard-concat
          concat_txt = tmpdir / "concat.txt"
          concat_txt.write_text("".join(f"file '{s}'\n" for s in segs))
          silent = tmpdir / "silent_master.mp4"
          _run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-f", "concat",
                "-safe", "0", "-i", str(concat_txt), "-c", "copy", str(silent)])
          total = _dur(silent)
      
          if not a.music:
              _run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(silent),
                    "-c", "copy", a.out])
              print(a.out, f"{total:.1f}s (no music)")
              return
      
          fade_out = max(0.0, total - 0.6)
          afilter = (f"[1:a]afade=t=in:st=0:d=0.3,afade=t=out:st={fade_out:.2f}:d=0.6,"
                     f"loudnorm=I=-16:TP=-1.5:LRA=11,apad[a]")
          _run(["ffmpeg", "-y", "-hide_banner", "-loglevel", "error",
                "-i", str(silent), "-i", a.music, "-filter_complex", afilter,
                "-map", "0:v", "-map", "[a]", "-t", f"{total:.3f}",
                "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", a.out])
          print(a.out, f"{total:.1f}s")
      
      
      if __name__ == "__main__":
          sys.exit(main())
      
    • config.example.json 10.1 KB
      {
        "_comment": "DIBS Beauty 'Desert Island Duo (shade 3)' — the worked example. Copy to config.json and edit. A ~15s premium 3D product showcase: one real product floats centered on a clean seamless brand-color backdrop and sells itself across 4 beats — Beat 1 an orbiting hero rotation, Beat 2 a macro push-in on the surface detail, Beat 3 a physics reveal (reveal_variant), then Beat 4 a typographic brand close. 720x1280 9:16, music-only (no VO). IDENTITY: Beats 1+2 are create-video-fal image-to-video seeded on a create-image-fal STYLED HERO built from the brand's real product photo (real geometry, never AI-invented). Beat 3 is Veo3 i2v seeded on Beat 1's last frame. Beat 4 is a deterministic Playwright hyperframe. One ElevenLabs instrumental bed, loudnorm I=-16. There is NO Higgsfield / Marketing Studio in this format.",
        "brand_name": "DIBS Beauty",
        "width": 720,
        "height": 1280,
        "fps": 24,
        "duration_s": 15,
        "target_duration_sec": 15,
      
        "product": {
          "_note": "the REAL product — its geometry is locked by the create-image-fal styled hero built from the real product photo; never AI-invented",
          "name": "Desert Island Duo (shade 3)",
          "category": "cosmetic",
          "form_factor": "dual-ended cream stick, pink barrel, bronzer one side + blush the other",
          "hero_png": "../../brand-assets/product-photos/desert-island-duo/did-sku_4050A-03_featured1.png",
          "pdp_url": "https://www.dibsbeauty.com/products/desert-island-duo"
        },
      
        "reveal_variant": "exploded_view",
        "_reveal_variant_note": "pick by category: cosmetic bottle/liquid or fragrance -> particle_disintegration; beverage OR a water product (spa/tub/fountain) -> liquid_splash; dual-ended/structural product -> exploded_view; large/featureless no-reveal product (a tool, a solid appliance) -> rotation_only (skip Beat 3). DID is a dual-ended stick -> exploded_view.",
      
        "studio_look": {
          "bg": "#F8B8C6",
          "_bg_note": "bubblegum pink (DIBS brand primary) — the seamless backdrop AND the normalize pad color",
          "lighting_preset": "luxe_botanical_warm",
          "rim_light": "strong warm rim light from upper-right; soft fill from below",
          "register": "premium product film — glossy speculars, warm bokeh, clean seamless backdrop, no people, no hands, no on-screen text on the plate",
          "reference_videos": "9 catalogued 3D-product-showcase shorts (GOOSE-1778) — the format reference"
        },
      
        "product_identity": {
          "_note": "identity lock — the analog of a character lock. Run BEFORE any i2v clip call. NO Marketing Studio.",
          "step_1": "create-image-fal (fal-ai/nano-banana/edit) — restyle the real product photo onto the seamless brand-color set (centered ~50-55% frame height, warm rim light upper-right, warm bokeh, 9:16, geometry IDENTICAL to the reference, no people/hands/text) -> hero_styled.png",
          "step_2": "host hero_styled.png via MCP get_upload_url -> get_download_url so create-video-fal can seed the beats off a PUBLIC url",
          "hard_rule": "the product geometry ALWAYS comes from the real product photo via the create-image-fal restyle — never a text-only AI invention. A lifestyle photo works too; the restyle isolates the product onto the studio set."
        },
      
        "beats": [
          {
            "id": "beat-01-hero-rotation",
            "label": "Hero rotation — orbiting camera",
            "engine": "fal_i2v",
            "provider": "fal",
            "model": "fal-ai/bytedance/seedance/v1/lite/image-to-video",
            "_model_note": "create-video-fal i2v seeded on hero_styled.png. Seedance Lite is the cheap default; swap to seedance/v1/pro or kling-video/v2.1/standard for higher fidelity. All bill the Ads agent via the fal-proxy.",
            "start_image": "hero_styled.png",
            "duration_sec": 4,
            "_duration_note": "i2v takes are ~5s; build_masters trims to 4s. A single i2v yields a partial arc (~60-120°), NOT a literal 360 — do not promise a full turn.",
            "motion": "A slow deliberate camera orbit around the product; product stays centered horizontally, occupying ~50% of frame height.",
            "prompt": "9:16 PRODUCT-SHOWCASE HERO ROTATION. The DIBS Desert Island Duo — a glossy pink dual-ended cream stick with a rosy-bronzer end and a pink-blush end, shade 3. A slow deliberate camera orbit around the stick. Stick stays centered horizontally, occupying ~50% of frame height, geometry solid and unchanged. Bubblegum pink seamless backdrop. Strong warm rim light from upper-right; soft fill from below. Subtle warm bokeh in background. Lowercase 'dibs.' wordmark on the barrel visible during the orbit. Cinematic, premium beauty product film. Product identical to the seed hero. No on-screen text.",
            "produces_anchor": "beat1_last_frame.png (ffmpeg -sseof -0.1 -i beat1.mp4 -frames:v 1) — the SHARED seed for Beat 3 AND the Beat 4 hyperframe background",
            "t_start_sec": 0.0,
            "t_end_sec": 4.0
          },
          {
            "id": "beat-02-macro-detail",
            "label": "Macro detail — push-in on the swatch line",
            "engine": "fal_i2v",
            "provider": "fal",
            "model": "fal-ai/bytedance/seedance/v1/lite/image-to-video",
            "start_image": "hero_styled.png",
            "_start_image_note": "SAME styled hero as Beat 1 so the product reads identical across both beats",
            "duration_sec": 4,
            "motion": "Camera moves smoothly along the barrel, revealing the swatch line where the two creams meet, with texture detail catching warm rim light; shallow DOF.",
            "prompt": "9:16 MACRO PUSH-IN along the length of the DIBS Desert Island Duo cream stick. Camera moves smoothly along the barrel, revealing the swatch line where the rosy-bronzer cream meets the pink-blush cream, with cream texture detail catching warm rim light. Shallow depth of field. Strong specular highlights on the glossy pink barrel. Warm bokeh background. Lowercase 'dibs.' wordmark visible. Product identical to the seed hero. No on-screen text.",
            "t_start_sec": 4.0,
            "t_end_sec": 8.0
          },
          {
            "id": "beat-03-reveal",
            "label": "Reveal — exploded_view",
            "engine": "veo3_i2v",
            "provider": "fal",
            "model": "fal-ai/veo3/fast/image-to-video",
            "veo3_tier": "fast",
            "start_image": "beat1_last_frame.png",
            "generate_audio": false,
            "duration_sec": 4,
            "_duration_note": "Veo3 fast i2v defaults to an 8s take (no 4s option on the fal-proxy); build_masters trims the best 4s window (rise -> peak -> early settle). Priciest single call in the format.",
            "_reveal_spatial_rule": "the reveal prompt MUST name WHERE the reveal happens (which surface faces the gap) — omitting it garbled the SHIPPED DID Beat 3 (P1: Veo3 cap-popped + smeared ambient swatches instead of a clean cross-section). The prompt below is the spatial-clause FIX.",
            "prompt": "The DIBS Desert Island Duo cream stick separates smoothly along its long horizontal axis, the two halves sliding apart to create a visible gap. At the join, each half's cream is visible in circular cross-section facing the gap — rosy-bronzer cream on the left half's exposed end, pink-blush cream on the right half's exposed end (like a sliced apple). Subtle pink shimmer particles drift through the gap. Halves smoothly reassemble. Bubblegum pink seamless backdrop. Camera holds steady, stick stays centered. No on-screen text. No text whatsoever.",
            "t_start_sec": 8.0,
            "t_end_sec": 12.0
          },
          {
            "id": "beat-04-brand-close",
            "label": "Brand close — typographic end card",
            "engine": "html_hyperframe",
            "provider": "playwright",
            "is_end_card": true,
            "method": "build_endcard.py — deterministic Playwright hyperframe (NO AI)",
            "background": "beat1_last_frame.png (stilled)",
            "headline": "ONE STICK · TWO FACES",
            "headline_size_px": 92,
            "_headline_size_note": "shrunk from the 110 default because the middot string needs to fit one line at 88% safe area",
            "duration_sec": 3,
            "t_start_sec": 12.0,
            "t_end_sec": 15.0
          }
        ],
      
        "end_card": {
          "method": "Playwright hyperframe (build_endcard.py, no AI)",
          "background": "beat1_last_frame.png (stilled)",
          "scrim": "linear-gradient(180deg, transparent 38%, rgba(248,184,198,.50) 100%)",
          "headline": {
            "text": "ONE STICK · TWO FACES",
            "font": "Playfair Display SemiBold",
            "size_px": 92,
            "uppercase": true,
            "color": "auto",
            "_color_note": "warm-white (#F6F1E7) on dark/saturated bg, dark ink (#2A1F26) on light/pastel bg. DID's pink bg is light -> #2A1F26. build_endcard.py --headline-color to override.",
            "animation": "0.55s spring-rise cubic-bezier(.34,1.56,.64,1), 88% safe area"
          },
          "wordmark": {
            "asset": "dibs-logo-black.svg",
            "width_px": 180,
            "animation": "0.35s fade-in +0.55s",
            "_note": "the REAL brand wordmark — never uppercase, never AI-rendered. build_endcard.py recolors the SVG to the headline color."
          },
          "dwell_sec": 3.0,
          "render_dims": "1080x1920 then ffmpeg scale -> 720x1280 (matching the viewport to the output clips the right edge)"
        },
      
        "music": {
          "provider": "elevenlabs_music",
          "model_id": "music_v1",
          "prompt": "Uplifting pop-ambient instrumental, soft female-led harmonies, slow warm build for 6 seconds, gentle kick + sub-bass drop at 7s carrying through to end. 100 BPM. Premium beauty product film aesthetic. No vocals with lyrics, atmospheric only.",
          "bpm": 100,
          "music_length_ms": 15000,
          "force_instrumental": true,
          "no_artist_names": true
        },
      
        "audio_mix": {
          "music_bus": "afade in 0.3s + afade out 0.6s (near the end) + loudnorm I=-16 TP=-1.5 LRA=11, apad + atrim to master duration",
          "master_bus": "loudnorm I=-16 (IG/TikTok safe), -1.5 dBTP true peak",
          "vo": "none — music-only product film"
        },
      
        "captions": {
          "style": "none",
          "_note": "N/A — music-only, no VO; the only on-screen text is the Beat 4 hyperframe headline (deterministic, not a burned caption)"
        },
      
        "finalize": {
          "codec": "720x1280 H.264, AAC 192k",
          "normalize": "per beat: trim to window, scale=720:1280:force_original_aspect_ratio=decrease,pad=720:1280:(ow-iw)/2:(oh-ih)/2:color=<bg>,setsar=1 + 24fps + yuv420p + -an (strip the i2v model auto-audio) + crf 18",
          "concat": "ffmpeg concat demuxer over the normalized segments"
        }
      }
      
    • PIPELINE.md 5 KB
      # PIPELINE — config → source steps
      
      This capability ships the **recipe** (`config.example.json`) **and** the runnable free-assembly
      scripts (`build_endcard.py`, `build_masters.py`, `shoot.js`). The paid stages (styled hero, i2v
      beats, music) are separate media capabilities. This maps each config field to the step that
      consumes it.
      
      ## Order of operations
      
      Styled hero (identity lock) → prompts approved (Gate 1) → probe one i2v call → Beats 1+2 i2v
      seeded on the styled hero → extract Beat 1 last frame → Veo3 Beat 3 reveal seeded on it → Beat 4
      `build_endcard.py` → music → `build_masters.py` (trim + normalize + concat + mix) → 720×1280 master.
      
      ## Field → step map
      
      | config field | source step | phase | what it does | paid? |
      |---|---|---|---|---|
      | `product.hero_png`, `product_identity.*`, `studio_look.*` | `create-image-fal` (`fal-ai/nano-banana/edit`) | Hero | Restyle the brand's REAL product photo onto the seamless brand-color studio set → `hero_styled.png`; host it (MCP get_upload_url → get_download_url) so i2v can seed off a public url. Locks the product's real geometry — no Marketing Studio, no PDP import. | **PAID** (cheap, ~1 image) |
      | `beats[0].prompt`, `beats[0].model`, `beats[0].start_image`, `beats[0].duration_sec` | `create-video-fal` i2v (`beats[].model` — Seedance Lite default) | Beat 1 | i2v hero rotation seeded on `hero_styled.png`. A single i2v is a partial arc, not a literal 360. Result mp4 → `working/clips/beat1.mp4`. | **PAID** |
      | `beats[1].prompt`, `beats[1].model`, `beats[1].start_image` | `create-video-fal` i2v (same model) | Beat 2 | i2v macro push-in seeded on the SAME `hero_styled.png` so the product reads identical. → `working/clips/beat2.mp4`. | **PAID** |
      | `beats[0].produces_anchor` | `ffmpeg -sseof -0.1 -i beat1.mp4 -frames:v 1 beat1_last_frame.png` | Beat 1→3 | Extract Beat 1's **last frame** — the shared anchor: Veo3 Beat-3 seed AND the Beat-4 hyperframe background. | free |
      | `beats[2].prompt`, `beats[2].start_image`, `beats[2].model`, `beats[2].generate_audio`, `reveal_variant` | `create-video-fal` Veo3 i2v (`fal-ai/veo3/fast/image-to-video`) | Beat 3 | Veo3 i2v physics reveal seeded on `beat1_last_frame.png`, `generate_audio:false`. Defaults to an 8s take (trimmed later). Skip when `reveal_variant == rotation_only`. The reveal prompt must name **where** the reveal happens. | **PAID** (priciest single call) |
      | `end_card.*`, `beats[3].headline`, `beats[3].headline_size_px` | `build_endcard.py --bg beat1_last_frame.png --headline "<H>" --wordmark <wm> --out endcard.png` | Beat 4 | Playwright hyperframe: Beat 1 last frame + scrim + Playfair SemiBold headline + the **real** wordmark (recolored for contrast) → render 1080×1920 → ffmpeg-scale to 720×1280. | free |
      | `music.*` | `create-music-elevenlabs` | Music | One ElevenLabs instrumental bed (genre + BPM + drop at 7s), `force_instrumental`, no artist names → `working/music.mp3` (~15s). | **PAID** |
      | `finalize.*`, `audio_mix.*`, `beats[].duration_sec`, `beats[].trim_start`, `studio_look.bg` | `build_masters.py --config config.json` | Assemble | Trim each beat to its window, normalize (`-an`, scale 720×1280 pad-to-`bg`, 24fps, yuv420p, crf 18) → concat demuxer → mix the bed (`afade` in 0.3s / out 0.6s + `loudnorm I=-16 TP=-1.5 LRA=11`, apad + atrimmed to master duration) → master mp4 (~15s). | free |
      
      ## Notes
      
      - **Model surfaces:** `create-image-fal` (styled hero), `create-video-fal` i2v ×2 (Beats 1+2),
        `create-video-fal` Veo3 i2v (Beat 3 reveal), `create-music-elevenlabs` (bed). Everything else
        (last-frame extract, `build_endcard.py`, `build_masters.py`) is free/deterministic. **No
        Marketing Studio / Higgsfield** — there is no such capability, and it would not bill the Ads
        agent; Beats 1+2 are FAL i2v seeded on the styled hero.
      - **Identity discipline:** the styled hero is built from the brand's REAL product photo, so Beats
        1+2 carry the real geometry; Beat 3 is seeded on Beat 1's *last frame*; Beat 4 sits over Beat 1's
        stilled last frame — the product + lighting carry across all four beats and are never AI-invented.
      - **Reveal-prompt spatial clause is load-bearing.** The shipped DID Beat 3 used a prompt that
        didn't say *where* the creams should show; Veo3 cap-popped and smeared ambient swatches (a P1).
        The `config.example.json` `beats[2].prompt` is the spatial-clause fix.
      - **Reveal-variant taxonomy:** cosmetic/liquid/fragrance → particle_disintegration; beverage OR a
        water product (spa/tub/fountain) → liquid_splash; dual-ended/structural → exploded_view;
        large/featureless no-reveal (a tool, a solid appliance) → rotation_only (skip Beat 3).
      - **Duration:** beats are 4+4+4+3 = **15s**. Veo3 fast is an 8s take → `build_masters` trims it to
        the 4s window (`trim_start` default 1.0s). i2v auto-audio is stripped (`-an`) or it leaks into
        the master.
      - **End-card render dims:** render at 1080×1920 then ffmpeg-scale to 720×1280 — matching the
        Playwright viewport to the output dims clips the right edge.
      
    • README.md 3.7 KB
      # render-3d-product-showcase scripts — the FREE assembly
      
      `render-3d-product-showcase` is the **deterministic, $0 assembly stage** of the 3D
      product-showcase format. The paid stages are separate capabilities:
      
      - **the styled hero** (identity lock) — `create-image-fal` (nano-banana edit) restyles the
        brand's REAL product photo onto the seamless brand-color studio set. This is the seed for
        Beats 1 & 2 and the fallback end-card background. The product geometry is the real product,
        never AI-invented.
      - **Beats 1 & 2** (hero rotation + macro push-in) — `create-video-fal` image-to-video seeded on
        that styled hero.
      - **Beat 3** (physics reveal) — `create-video-fal` Veo3 i2v seeded on Beat 1's last frame.
      - **the bed** — `create-music-elevenlabs` (`force_instrumental` true).
      
      There is **no Higgsfield / Marketing Studio** in this format; every paid call routes through the
      fal-proxy / elevenlabs-proxy so it bills the Ads agent. This capability spends nothing: given the
      four delivered beats + the brand wordmark it stitches the finished master. Re-cuts (a swapped end
      card, a re-timed beat, a re-mixed bed) reuse the existing beats and cost **$0**.
      
      `config.example.json` is the worked example (DIBS Beauty "Desert Island Duo", ~15s 720×1280).
      `build_endcard.py` and `build_masters.py` are the runnable free assembly (below). `PIPELINE.md`
      maps every config block to its source step.
      
      ## 1. Beat 1 last-frame extract — the shared anchor
      
      Extract Beat 1's last frame once (`ffmpeg -sseof -0.1 -i beat1.mp4 -frames:v 1
      beat1_last_frame.png`). It is the shared anchor: it seeds the Veo3 Beat 3 (paid, upstream) AND
      backs the Beat 4 end-card hyperframe. Extracting it once keeps the product + lighting identical
      across the reveal and the close, so the geometry never AI-drifts.
      
      ## 2. End card — `build_endcard.py` (Playwright hyperframe, no AI text)
      
      ```
      build_endcard.py --bg beat1_last_frame.png --headline "YOUR SANCTUARY AT HOME" \
          --wordmark brand-logo.svg --out endcard.png [--headline-size 86] [--headline-color auto]
      ```
      
      Beat 1's stilled last frame + a scrim + a Playfair headline + the brand's **REAL** wordmark
      (SVG inlined and recolored, or a PNG). It renders at 1080×1920 via `scripts/shoot.js` (Playwright)
      then ffmpeg-scales to 720×1280 (matching the viewport to the output dims clips the right edge).
      The brand text is **never** AI-rendered — a diffusion model garbles a wordmark. `--headline-color
      auto` picks warm-white on a dark/saturated bg and dark ink on a light/pastel bg. Playfair loads
      from Google Fonts (bundle the .ttf for offline determinism). If the resolvable Playwright wants an
      uninstalled browser build, `export PW_CHROME=<installed Chromium binary>`.
      
      ## 3. Assemble + mix — `build_masters.py` (per-beat normalize + hard-concat + music)
      
      ```
      build_masters.py --config config.json --clips working/clips --endcard endcard.png \
          --music working/music.mp3 --out working/master.mp4
      ```
      
      Reads the config, then for each beat: **trims to its window** (`beats[].duration_sec`, with
      `beats[].trim_start` — defaults to 1.0s for the Veo3 reveal so its 8s take lands on rise → peak →
      early settle), normalizes to the brand canvas (strip the i2v model's auto-audio with `-an`, scale
      + pad to 720×1280 with the brand `bg` pad color, 24fps, yuv420p, crf 18), and — for the end card —
      holds the still for its duration. The normalized segments are hard-concatenated (concat demuxer)
      in beat order — no dissolves. `reveal_variant == rotation_only` skips Beat 3 automatically.
      
      Then it mixes one ElevenLabs instrumental bed under the concat: `afade` in 0.3s / out 0.6s +
      `loudnorm I=-16 TP=-1.5 LRA=11`, `apad` + atrimmed to the master duration. Music-only — no VO, no
      second bed. Output is a 720×1280 h264 + aac master (~15s). Deterministic, no paid calls, no keys.
      
    • shoot.js 1.2 KB
      #!/usr/bin/env node
      /*
       * Deterministic HTML -> PNG screenshot for the end-card hyperframe.
       *   node shoot.js <file-url> <out.png> <width> <height> [waitMs]
       *
       * Uses Playwright's Chromium. If the resolvable Playwright package wants a
       * browser build that isn't installed (a common npx-version skew), set
       * PW_CHROME to an installed Chromium binary and this launches that instead —
       * so the end card never fails on a browser-version mismatch.
       */
      const { chromium } = require('playwright');
      
      (async () => {
        const [url, out, w, h, waitMs] = process.argv.slice(2);
        const launchOpts = process.env.PW_CHROME ? { executablePath: process.env.PW_CHROME } : {};
        const browser = await chromium.launch(launchOpts);
        const page = await browser.newPage({
          viewport: { width: +w, height: +h },
          deviceScaleFactor: 1,
        });
        await page.goto(url, { waitUntil: 'networkidle' });
        try { await page.evaluate(() => document.fonts.ready); } catch (e) { /* fonts optional */ }
        await page.waitForTimeout(+waitMs || 1500);
        await page.screenshot({ path: out });
        await browser.close();
        console.log('shot', out);
      })().catch((e) => { console.error(e.message || e); process.exit(1); });
      
  • tests
    • smoke-test.md 1.1 KB
      # Smoke Test
      
      Given the generated beat clips (create-video-fal i2v hero rotation + macro push-in, Veo3
      physics reveal), the Playwright hyperframe end card, and one instrumental bed,
      `render-3d-product-showcase` assembles the master via `build_masters.py`: trim each beat to its
      window, order the beats, hard-cut on the beat, mux the music → 720×1280 h264+aac (~15s).
      
      Pass when the assembly runs to a valid MP4 and:
      - the 4 beats land in order (hero rotation → macro push-in → physics reveal → typographic
        brand close);
      - the real product is carried through from the source clips (geometry never AI-invented on this
        free stage — the beats are seeded on the create-image-fal styled hero upstream);
      - the end card is the deterministic Playwright hyperframe (real wordmark, no AI-rendered text);
      - the instrumental bed carries with no VO, loudnormed to −16 LUFS;
      - **no paid call is made** — the hero/macro/reveal clips and the music come from the paid
        capabilities (create-image-fal / create-video-fal / create-music-elevenlabs); this assembly is
        $0 and a re-cut reuses the existing assets.
      
  • SKILL.md 4.2 KB
    ---
    name: render-3d-product-showcase
    description: Assemble a premium 3D product-showcase ad from a config — four beat clips (an orbiting hero rotation, a macro push-in, a physics reveal, a typographic close) normalized to the brand-color canvas, hard-concatenated in order, closed on a deterministic Playwright brand end card, and mixed under one instrumental bed at loudnorm I=-16 (music-only, no VO). Ships the runnable build_endcard.py + build_masters.py; the rotation/macro clips are create-video-fal i2v seeded on a create-image-fal styled hero, the reveal is Veo3 i2v, and the bed is create-music-elevenlabs. Use for the 3d-product-showcase format.
    status: active
    ---
    
    # render-3d-product-showcase
    
    Assemble a premium **3D product-showcase** ad from a config: one real product floats centered
    on a clean seamless brand-color backdrop and sells itself across four beats — an orbiting hero
    rotation, a macro push-in on the surface detail, a physics reveal (`exploded_view` /
    `particle_disintegration` / `liquid_splash`, or `rotation_only`), then a typographic brand
    close. This capability is the **FREE, deterministic assembly** that stitches the delivered
    beats into the master; it spends nothing.
    
    `scripts/config.example.json` is the worked example (DIBS Beauty "Desert Island Duo", ~15s
    720×1280 9:16); `scripts/build_endcard.py` + `scripts/build_masters.py` are the runnable free
    assembly; `scripts/PIPELINE.md` maps every config block to its source step; `scripts/README.md`
    documents the assembly.
    
    ## Run
    
    This is the **FREE, deterministic** assembly stage — it spends nothing. The paid inputs are
    separate capabilities: Beats 1 & 2 (hero rotation + macro push-in) are **`create-video-fal`
    image-to-video seeded on a `create-image-fal` styled hero** — a nano-banana restyle of the
    brand's REAL product photo onto the seamless studio set, so the product geometry is real and
    never AI-invented; Beat 3 (the physics reveal) is a Veo3 image-to-video seeded on Beat 1's last
    frame (also `create-video-fal`); the one instrumental bed is `create-music-elevenlabs`. **There
    is no Higgsfield / Marketing Studio in this format** — everything paid runs through the
    fal-proxy / elevenlabs-proxy so it bills the Ads agent.
    
    Given those beats + the brand wordmark, this capability:
    
    1. `build_endcard.py --bg beat1_last_frame.png --headline "…" --wordmark <wordmark> --out endcard.png`
       — the deterministic Beat-4 hyperframe (Playwright 1080×1920 → ffmpeg-scale to 720×1280).
    2. `build_masters.py --config config.json --clips working/clips --endcard endcard.png --music music.mp3 --out master.mp4`
       — trims each beat to its window, normalizes to the brand canvas, hard-concats, mixes the bed.
    
    Re-cuts reuse the existing beats and cost **$0**.
    
    ## Contract (the free assembly)
    
    - **Music-only, no VO.** One ElevenLabs instrumental bed carries the film; nobody speaks. Do
      not add a spoken voiceover or a second bed.
    - **Beat 1's last frame is the shared anchor.** Extract it (`ffmpeg -sseof -0.1 … -frames:v 1`)
      once — it seeds the Veo3 Beat 3 AND backs the Beat 4 end-card hyperframe, so the product +
      lighting carry across all four beats and the geometry never AI-drifts.
    - **End card via Playwright from the real wordmark — never AI-render brand text.** The brand
      close is a deterministic hyperframe (Beat 1 last frame + scrim + Playfair headline + the real
      wordmark, recolored for contrast). `build_endcard.py` auto-picks a legible headline color from
      the bg luminance and renders 1080×1920 then scales to 720×1280. Playfair is loaded from Google
      Fonts; bundle the .ttf if determinism offline matters. If the resolvable Playwright wants an
      uninstalled browser build, export `PW_CHROME=<installed Chromium binary>` (shoot.js honours it).
    - **Normalize each beat to the brand canvas, hard-concat.** Per beat: trim to the window, strip
      the i2v model's auto-audio (`-an`), scale + pad to 720×1280 with the brand `bg` pad color,
      24fps, yuv420p, crf 18 → concat demuxer. No dissolves.
    - **FFmpeg mix, deterministic, FREE.** Mix one instrumental bed (`afade` in/out +
      `loudnorm I=-16 TP=-1.5 LRA=11`, apad + atrimmed to master duration) over the concatenated
      beats → a 720×1280 h264+aac master. No paid calls, no keys.
    
  • skill.meta.json 329 B
    {
      "slug": "render-3d-product-showcase",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-3d-product-showcase",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related