Claude Skill

render-value-prop

Render a designed 'value prop' video from a config — 3-5 noun-phrase benefit claims (<=4 words each) revealed sequentially over per-SKU product visuals, one crisp editorial frame per claim (hook sticker -> N claim beats -> brand end card). Deterministic PIL/HTML beat renderer fra

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-value-prop-e1592ee.zip · 32 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-value-prop
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-value-prop

Render a designed 'value prop' video from a config: a hook sticker, then one beat per short noun-phrase benefit claim (<=4 words each — "Drug-Free", "Zero Sugar", "NSF Certified"), each pairing the claim headline with a per-SKU product visual (the hero SKU rotates beat to beat so the eye anchor shifts), then a brand-wordmark end card. Text + product carry the spot — no narration, no talking head — and it is built to be legible sound-off. Every beat is a pure function of beat-local time t (deterministic PIL start frames + Playwright hyperframes + FFmpeg); no CSS keyframes, no setTimeout. FREE (no paid calls); music is a separate capability (create-music-elevenlabs), or ship silent for $0.

Run

render_master.py --config config.json --project <dir> -> <dir>/finals/master-clean.mp4 (silent), 1080x1920, deterministic, $0. The renderer is fully config-driven — palette, copy, SKUs, pacing, hook, logo and end card all come from config.json (schema = ad_sample.recipe.config; see config.example.json). Nothing is hardcoded to one brand. build_storyboard_preview.py is an optional free preview gallery for the gate; build_text_overlays.py is optional (transparent text-zone PNGs for compositing claims over a motion clip).

Environment: run with a Python that has Playwright (override the frame-render interpreter with RENDER_PYTHON); ffmpeg is auto-discovered (FFMPEG env > PATH > common prefixes). The frame renderer render_hyperframe.py is bundled in scripts/ — no external atom to fetch.

Contract

  • Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls, no AI-rendered text.
  • Claims are noun phrases, <=4 words; never <3, never >5. Optional benefit sentence <=12 words.
  • One product visual per beat; rotate which SKU is the hero. Never reuse a flat variety-pack image as every canvas.
  • Sound-off legibility is the bar: the headline uses the config palette.ink color on palette.bg; the per-beat accent color (from value_props[].accent — a SKU-accent slug or a hex) is the accent rule, not the headline.
  • Product widths auto-scale from each image's aspect ratio (target display height), so tall sachet cutouts and wide product packshots both frame correctly.
  • Assets are packshots, not always transparent cutouts: set palette.bg to the product image's background color for seamless compositing (free — avoids a paid background-removal step).
  • Uniform pacing (hook ~3.0s, props 2.0-2.5s each, endcard ~2.0s); total lands in the 10-20s window (~17s). No acceleration curve.
  • No human face is the focus. End card uses the brand wordmark image when a hi-res one (aspect >= ~1.2, i.e. a real >=1200x600 wordmark) is provided via config.logo; otherwise it falls back to a typographic brand_name wordmark (many brands ship only a favicon).
  • Music is added separately by create-music-elevenlabs (quiet instrumental bed at -14 dB), or ship silent.
Files (goose-skills)
  • scripts
    • build_storyboard_preview.py 10.9 KB
      #!/usr/bin/env python3
      """Generate beat HTMLs + render preview PNGs + assemble storyboard.html.
      
      Reads shot-list.yml. Writes:
        working/beats/beat-N-<slug>.html        — one per beat (full-res 1080x1080)
        assets/hyperframes/preview/beat-N-<slug>.png  — Playwright-rendered preview
        storyboard.html (project root)          — gallery view of all 7 previews
      
      Run:
        python3 working/build_storyboard.py
      
      Phase 2 will re-use the beat HTMLs and feed them to ffmpeg for the final mp4.
      """
      import json
      import subprocess
      import sys
      from pathlib import Path
      
      # yaml is optional — fall back to minimal inline parser if not installed
      try:
          import yaml
      except ImportError:
          print("ERROR: pip install pyyaml", file=sys.stderr)
          sys.exit(1)
      
      try:
          from playwright.sync_api import sync_playwright
      except ImportError:
          print("ERROR: pip install playwright && playwright install chromium", file=sys.stderr)
          sys.exit(1)
      
      
      PROJECT = Path(__file__).resolve().parent.parent
      SHOT_LIST = PROJECT / "shot-list.yml"
      BEATS_DIR = PROJECT / "working" / "beats"
      PREVIEW_DIR = PROJECT / "assets" / "hyperframes" / "preview"
      STORYBOARD = PROJECT / "storyboard.html"
      
      
      BEAT_HTML_TEMPLATE = """<!DOCTYPE html>
      <html lang="en">
      <head>
      <meta charset="utf-8">
      <title>Beat {id} — {slug}</title>
      <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=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
      <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        html, body {{ background: #fff; }}
        .frame {{
          width: {width}px; height: {height}px;
          position: relative;
          background: #fff;
          overflow: hidden;
          font-family: "Instrument Sans", sans-serif;
          color: #0B253C;
          display: flex;
          flex-direction: column;
        }}
        .text-zone {{
          flex: 0 0 380px;
          padding: 56px 70px 24px;
          text-align: center;
          background: #fff;
          display: flex;
          flex-direction: column;
          justify-content: center;
          z-index: 2;
        }}
        .hero-bg-container {{
          flex: 1 1 auto;
          background: #fff;
          display: flex;
          align-items: flex-end;
          justify-content: center;
          overflow: hidden;
          padding: 0 40px 30px;
        }}
        .hero-bg {{
          width: 100%;
          height: 100%;
          object-fit: contain;
          object-position: center bottom;
        }}
        .headline {{
          font-family: "Instrument Sans", sans-serif;
          font-weight: {headline_weight};
          font-size: {headline_size_px}px;
          letter-spacing: {headline_letter_spacing};
          line-height: {headline_line_height};
          color: #0B253C;
          text-transform: {headline_text_transform};
          margin: 0;
        }}
        .sub-sentence {{
          font-family: "Instrument Sans", sans-serif;
          font-weight: {sub_weight};
          font-size: {sub_size_px}px;
          letter-spacing: {sub_letter_spacing};
          line-height: 1.35;
          color: rgba(11, 37, 60, 0.78);
          margin-top: 22px;
          text-transform: {sub_text_transform};
        }}
        /* End card composition (beat 7) */
        .endcard {{
          position: absolute;
          inset: 0;
          background: #fff;
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          text-align: center;
          padding: 80px;
        }}
        .endcard-logo {{
          width: 380px;
          margin-bottom: 80px;
        }}
        .endcard-tagline {{
          font-family: "Instrument Sans", sans-serif;
          font-weight: 500;
          font-size: 124px;
          letter-spacing: -0.015em;
          line-height: 1.0;
          color: #0B253C;
          margin: 0;
        }}
        .endcard-cta {{
          font-family: "Instrument Sans", sans-serif;
          font-weight: 500;
          font-size: 32px;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          color: rgba(11, 37, 60, 0.65);
          margin-top: 80px;
        }}
      </style>
      </head>
      <body>
      <div class="frame">
      {body_html}
      </div>
      </body>
      </html>
      """
      
      
      def render_beat_body(beat):
          """Render the per-beat <body> content."""
          if beat.get("canvas") == "endcard":
              return f"""<div class="endcard">
        <img class="endcard-logo" src="../../source/logo-som-blue.png" alt="Som Sleep">
        <h1 class="endcard-tagline">{beat['headline']}</h1>
        <div class="endcard-cta">{beat['sub_sentence']}</div>
      </div>"""
          # Hero canvas beats
          sub = ""
          if beat.get("sub_sentence"):
              sub = f'<div class="sub-sentence">{beat["sub_sentence"]}</div>'
          return f"""<div class="text-zone">
        <h1 class="headline">{beat['headline']}</h1>
        {sub}
      </div>
      <div class="hero-bg-container">
        <img class="hero-bg" src="../../source/hero-variety-pack-40.png" alt="">
      </div>"""
      
      
      def write_beat_html(beat, project):
          body = render_beat_body(beat)
          html = BEAT_HTML_TEMPLATE.format(
              id=beat["id"],
              slug=beat["slug"],
              width=project["width"],
              height=project["height"],
              headline_weight=beat.get("headline_weight", 600),
              headline_size_px=beat.get("headline_size_px", 124),
              headline_letter_spacing=beat.get("headline_letter_spacing_em", "-0.02em"),
              headline_line_height=beat.get("headline_line_height", 0.95),
              headline_text_transform=beat.get("headline_text_transform", "uppercase"),
              sub_weight=beat.get("sub_weight", 400),
              sub_size_px=beat.get("sub_size_px", 36) or 36,
              sub_letter_spacing=beat.get("sub_letter_spacing_em", "normal"),
              sub_text_transform=beat.get("sub_text_transform", "none"),
              body_html=body,
          )
          out = BEATS_DIR / f"beat-{beat['id']}-{beat['slug']}.html"
          out.write_text(html)
          return out
      
      
      def render_preview(html_path, png_path, width, height):
          """Render an HTML file to PNG via Playwright. Returns the file path."""
          with sync_playwright() as p:
              browser = p.chromium.launch()
              ctx = browser.new_context(
                  viewport={"width": width, "height": height},
                  device_scale_factor=1,
              )
              page = ctx.new_page()
              page.goto(f"file://{html_path.resolve()}")
              page.wait_for_load_state("networkidle")
              # Wait for web fonts to settle (Instrument Sans from Google CDN)
              page.evaluate("document.fonts.ready")
              page.wait_for_timeout(400)
              page.screenshot(
                  path=str(png_path),
                  full_page=False,
                  clip={"x": 0, "y": 0, "width": width, "height": height},
              )
              browser.close()
          return png_path
      
      
      STORYBOARD_TEMPLATE = """<!DOCTYPE html>
      <html lang="en">
      <head>
      <meta charset="utf-8">
      <title>Som Sleep — Video 01 Storyboard (VP-SWAP)</title>
      <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=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
      <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        body {{
          font-family: "Instrument Sans", sans-serif;
          background: #f5f5f7;
          color: #0B253C;
          padding: 40px;
          line-height: 1.4;
        }}
        h1 {{ font-size: 32px; font-weight: 600; margin-bottom: 8px; }}
        .meta {{ font-size: 14px; color: #6b6b7b; margin-bottom: 28px; }}
        .meta strong {{ color: #0B253C; }}
        .grid {{
          display: grid;
          grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
          gap: 24px;
        }}
        .beat {{
          background: #fff;
          border-radius: 12px;
          overflow: hidden;
          box-shadow: 0 4px 16px rgba(11,37,60,0.06);
        }}
        .beat-img {{
          width: 100%;
          aspect-ratio: 1/1;
          display: block;
          background: #fff;
        }}
        .beat-meta {{
          padding: 16px 20px;
          border-top: 1px solid #eef0f3;
        }}
        .beat-id {{
          font-size: 11px;
          letter-spacing: 0.12em;
          text-transform: uppercase;
          color: #6b6b7b;
          margin-bottom: 4px;
        }}
        .beat-headline {{
          font-size: 16px;
          font-weight: 600;
          margin-bottom: 4px;
        }}
        .beat-sub {{
          font-size: 13px;
          color: rgba(11,37,60,0.7);
          margin-bottom: 8px;
        }}
        .beat-notes {{
          font-size: 12px;
          color: #6b6b7b;
          font-style: italic;
        }}
        .timeline {{
          margin-top: 28px;
          padding: 20px 24px;
          background: #fff;
          border-radius: 8px;
          font-size: 13px;
          line-height: 1.6;
        }}
        .timeline strong {{ color: #0B253C; }}
      </style>
      </head>
      <body>
      <h1>Som Sleep — Video 01 (VP-SWAP) — Storyboard</h1>
      <div class="meta">
        <strong>Format:</strong> Value Prop ad (silent) ·
        <strong>Aspect:</strong> {width}x{height} (1:1) ·
        <strong>Duration:</strong> {total_duration}s ·
        <strong>FPS:</strong> {fps} ·
        <strong>Beats:</strong> {beat_count}
      </div>
      <div class="grid">
      {cards}
      </div>
      <div class="timeline">
      {timeline}
      </div>
      </body>
      </html>
      """
      
      
      def render_storyboard(project, beats, preview_paths):
          cards = []
          for beat, png_path in zip(beats, preview_paths):
              rel = png_path.relative_to(PROJECT)
              sub = beat.get("sub_sentence") or "&nbsp;"
              notes = beat.get("notes", "").strip().replace("\n", " ")
              # Strip HTML <br> for storyboard text
              headline_text = beat["headline"].replace("<br>", " · ")
              cards.append(f"""<div class="beat">
        <img class="beat-img" src="{rel}" alt="Beat {beat['id']}">
        <div class="beat-meta">
          <div class="beat-id">Beat {beat['id']} · {beat['slug']} · {beat['start_s']}–{beat['end_s']}s · {beat['duration_s']}s</div>
          <div class="beat-headline">{headline_text}</div>
          <div class="beat-sub">{sub}</div>
          {'<div class="beat-notes">' + notes + '</div>' if notes else ''}
        </div>
      </div>""")
      
          timeline_rows = []
          for beat in beats:
              timeline_rows.append(
                  f'<div><strong>{beat["start_s"]:>5.1f}s – {beat["end_s"]:>5.1f}s</strong> '
                  f'(beat {beat["id"]}/{beat["slug"]}) — {beat["headline"].replace("<br>", " · ")}</div>'
              )
      
          html = STORYBOARD_TEMPLATE.format(
              width=project["width"],
              height=project["height"],
              total_duration=beats[-1]["end_s"],
              fps=project["fps"],
              beat_count=len(beats),
              cards="\n".join(cards),
              timeline="\n".join(timeline_rows),
          )
          STORYBOARD.write_text(html)
      
      
      def main():
          data = yaml.safe_load(SHOT_LIST.read_text())
          project = data["project"]
          beats = data["beats"]
      
          BEATS_DIR.mkdir(parents=True, exist_ok=True)
          PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
      
          print(f"Generating {len(beats)} beat HTMLs...")
          beat_htmls = [write_beat_html(b, project) for b in beats]
      
          print(f"Rendering {len(beats)} preview PNGs via Playwright @ {project['width']}x{project['height']}...")
          preview_paths = []
          for beat, html_path in zip(beats, beat_htmls):
              png_path = PREVIEW_DIR / f"beat-{beat['id']}-{beat['slug']}.png"
              render_preview(html_path, png_path, project["width"], project["height"])
              print(f"  rendered beat {beat['id']} → {png_path.relative_to(PROJECT)}")
              preview_paths.append(png_path)
      
          print(f"Assembling storyboard.html...")
          render_storyboard(project, beats, preview_paths)
          print(f"  storyboard at {STORYBOARD.relative_to(PROJECT)}")
          print(f"\nopen {STORYBOARD}")
      
      
      if __name__ == "__main__":
          main()
      
    • build_text_overlays.py 6.6 KB
      #!/usr/bin/env python3
      """Generate transparent text-overlay PNGs for Variants 2 + 3 motion compositing.
      
      Each PNG is 1080x1080 with:
        - Top 380px: white BG + headline + sub-sentence (matches static layout's text zone)
        - Bottom 700px: TRANSPARENT (motion clip shows through)
      
      Beat 7 (endcard) is FULL frame (no transparency — replaces the motion clip entirely
      for the endcard duration).
      """
      import sys
      from pathlib import Path
      
      try:
          import yaml
      except ImportError:
          sys.exit("ERROR: pip install pyyaml")
      try:
          from playwright.sync_api import sync_playwright
      except ImportError:
          sys.exit("ERROR: pip install playwright && playwright install chromium")
      
      PROJECT = Path(__file__).resolve().parent.parent
      SHOT_LIST = PROJECT / "shot-list.yml"
      BEATS_DIR = PROJECT / "working" / "beats-overlay"
      OVERLAY_DIR = PROJECT / "assets" / "hyperframes" / "overlay"
      BEATS_DIR.mkdir(parents=True, exist_ok=True)
      OVERLAY_DIR.mkdir(parents=True, exist_ok=True)
      
      
      TEXT_OVERLAY_TEMPLATE = """<!DOCTYPE html>
      <html lang="en">
      <head>
      <meta charset="utf-8">
      <title>Overlay Beat {id}</title>
      <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=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
      <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        html, body {{ background: transparent !important; }}
        .frame {{
          width: {width}px; height: {height}px;
          position: relative;
          background: transparent;
          font-family: "Instrument Sans", sans-serif;
          color: #0B253C;
        }}
        .text-zone {{
          position: absolute;
          top: 0; left: 0; right: 0;
          height: 380px;
          padding: 56px 70px 24px;
          text-align: center;
          background: #FFFFFF;
          display: flex;
          flex-direction: column;
          justify-content: center;
        }}
        .headline {{
          font-weight: {headline_weight};
          font-size: {headline_size_px}px;
          letter-spacing: {headline_letter_spacing};
          line-height: {headline_line_height};
          color: #0B253C;
          text-transform: {headline_text_transform};
          margin: 0;
        }}
        .sub-sentence {{
          font-weight: {sub_weight};
          font-size: {sub_size_px}px;
          letter-spacing: {sub_letter_spacing};
          line-height: 1.35;
          color: rgba(11, 37, 60, 0.78);
          margin-top: 22px;
          text-transform: {sub_text_transform};
        }}
      </style>
      </head>
      <body>
      <div class="frame">
        <div class="text-zone">
          <h1 class="headline">{headline}</h1>
          {sub_html}
        </div>
      </div>
      </body>
      </html>
      """
      
      
      ENDCARD_FULL_TEMPLATE = """<!DOCTYPE html>
      <html lang="en">
      <head>
      <meta charset="utf-8">
      <title>Overlay Endcard</title>
      <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=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap" rel="stylesheet">
      <style>
        * {{ margin: 0; padding: 0; box-sizing: border-box; }}
        html, body {{ background: #fff; }}
        .frame {{
          width: {width}px; height: {height}px;
          background: #fff;
          display: flex;
          flex-direction: column;
          justify-content: center;
          align-items: center;
          padding: 80px;
          font-family: "Instrument Sans", sans-serif;
        }}
        .logo {{
          width: 380px;
          margin-bottom: 80px;
        }}
        .tagline {{
          font-weight: 500;
          font-size: 124px;
          letter-spacing: -0.015em;
          line-height: 1.0;
          color: #0B253C;
          text-align: center;
          margin: 0;
        }}
        .cta {{
          font-weight: 500;
          font-size: 32px;
          letter-spacing: 0.18em;
          text-transform: uppercase;
          color: rgba(11, 37, 60, 0.65);
          margin-top: 80px;
        }}
      </style>
      </head>
      <body>
      <div class="frame">
        <img class="logo" src="../../source/logo-som-blue.png" alt="Som Sleep">
        <h1 class="tagline">{tagline}</h1>
        <div class="cta">{cta}</div>
      </div>
      </body>
      </html>
      """
      
      
      def write_overlay_html(beat, project):
          if beat.get("canvas") == "endcard":
              html = ENDCARD_FULL_TEMPLATE.format(
                  width=project["width"],
                  height=project["height"],
                  tagline=beat["headline"],
                  cta=beat["sub_sentence"],
              )
          else:
              sub_html = ""
              if beat.get("sub_sentence"):
                  sub_html = f'<div class="sub-sentence">{beat["sub_sentence"]}</div>'
              html = TEXT_OVERLAY_TEMPLATE.format(
                  id=beat["id"],
                  width=project["width"],
                  height=project["height"],
                  headline=beat["headline"],
                  sub_html=sub_html,
                  headline_weight=beat.get("headline_weight", 600),
                  headline_size_px=beat.get("headline_size_px", 124),
                  headline_letter_spacing=beat.get("headline_letter_spacing_em", "-0.02em"),
                  headline_line_height=beat.get("headline_line_height", 0.95),
                  headline_text_transform=beat.get("headline_text_transform", "uppercase"),
                  sub_weight=beat.get("sub_weight", 400),
                  sub_size_px=beat.get("sub_size_px", 36) or 36,
                  sub_letter_spacing=beat.get("sub_letter_spacing_em", "normal"),
                  sub_text_transform=beat.get("sub_text_transform", "none"),
              )
          out = BEATS_DIR / f"overlay-{beat['id']}-{beat['slug']}.html"
          out.write_text(html)
          return out
      
      
      def render(html_path, png_path, width, height, with_alpha):
          with sync_playwright() as p:
              browser = p.chromium.launch()
              ctx = browser.new_context(
                  viewport={"width": width, "height": height},
                  device_scale_factor=1,
              )
              page = ctx.new_page()
              page.goto(f"file://{html_path.resolve()}")
              page.wait_for_load_state("networkidle")
              page.evaluate("document.fonts.ready")
              page.wait_for_timeout(400)
              page.screenshot(
                  path=str(png_path),
                  full_page=False,
                  clip={"x": 0, "y": 0, "width": width, "height": height},
                  omit_background=with_alpha,
              )
              browser.close()
      
      
      def main():
          data = yaml.safe_load(SHOT_LIST.read_text())
          project = data["project"]
          beats = data["beats"]
      
          print(f"Rendering {len(beats)} overlay PNGs at {project['width']}x{project['height']}...")
          for beat in beats:
              html_path = write_overlay_html(beat, project)
              png_path = OVERLAY_DIR / f"overlay-{beat['id']}-{beat['slug']}.png"
              is_endcard = beat.get("canvas") == "endcard"
              render(html_path, png_path, project["width"], project["height"], with_alpha=not is_endcard)
              mode = "FULL (no alpha)" if is_endcard else "with alpha (transparent below text-zone)"
              print(f"  rendered overlay beat {beat['id']} [{mode}] → {png_path.relative_to(PROJECT)}")
      
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 1.9 KB
      {
        "width": 1080,
        "height": 1920,
        "fps": 30,
        "duration_s": 17,
        "brand_name": "Acme",
        "hook_line": "Your Headline.\nSecond Line.",
        "hook_eyebrow": "THE ONE-LINER",
        "hook_sku": "sku-a",
        "tagline": "Your Headline. Second Line.",
        "cta": "Shop Now",
        "logo": "source/logo.png",
        "skus": [
          { "slug": "sku-a", "png": "source/sku-a.png" },
          { "slug": "sku-b", "png": "source/sku-b.png" },
          { "slug": "sku-c", "png": "source/sku-c.png" },
          { "slug": "sku-d", "png": "source/sku-d.png" }
        ],
        "palette": {
          "bg": "#FFFFFF",
          "ink": "#161616",
          "bg_cream": "#F7F4ED",
          "sku_accents": {
            "sku-a": "#5BB4D9",
            "sku-b": "#4A2871",
            "sku-c": "#F0C234",
            "sku-d": "#E8853A"
          }
        },
        "pacing": { "hook_s": 3.0, "prop_s": 2.4, "endcard_s": 2.0 },
        "value_props": [
          { "label": "Claim One", "eyebrow": "PROOF POINT", "benefit_sentence": "One-line benefit sentence, <=12 words.", "accent": "sku-a", "layout": "row" },
          { "label": "Claim Two", "eyebrow": "PROOF POINT", "benefit_sentence": "One-line benefit sentence, <=12 words.", "accent": "sku-a", "layout": "hero", "hero_sku": "sku-a" },
          { "label": "Claim Three", "eyebrow": "PROOF POINT", "benefit_sentence": "One-line benefit sentence, <=12 words.", "accent": "sku-b", "layout": "hero", "hero_sku": "sku-b" },
          { "label": "Claim Four", "eyebrow": "PROOF POINT", "benefit_sentence": "One-line benefit sentence, <=12 words.", "accent": "#D98A94", "layout": "hero", "hero_sku": "sku-c" }
        ],
        "layout": {
          "text_top": 210,
          "headline_size": 96,
          "hero_h": 680,
          "hero_bottom": 200,
          "support_h": 300,
          "support_bottom": 360,
          "support_opacity": 0.38,
          "row_h": 300,
          "row_bottom": 320
        },
        "music_brief": "Optional: the instrumental bed brief, consumed by create-music-elevenlabs (not this renderer).",
        "music": { "length_ms": 17000, "mix_db": -14, "fade_in_s": 0.4, "fade_out_s": 0.6, "force_instrumental": true }
      }
      
    • render_hyperframe.py 4.5 KB
      #!/usr/bin/env python3
      """Render an HTML hyperframe to MP4 via Playwright + ffmpeg.
      
      Frame-by-frame screencast: scrubs the page's animation timeline by calling
      ``window.renderAt(t_seconds)`` once per frame, then encodes the screenshots
      via ffmpeg. Deterministic and pixel-perfect.
      
      The HTML composition MUST expose a global ``window.renderAt(t_seconds)`` that
      sets every animation's ``currentTime`` to the supplied time. See the skill's
      SKILL.md for the contract. (Older versions of this script mutated
      ``document.timeline.currentTime`` directly, which is read-only in modern
      Chromium and silently no-ops — that bug shipped klarify/run-04 with
      identical frames at every time step before being caught in run-05's review.)
      
      Usage:
          render_hyperframe.py <input.html> <output.mp4> <duration_sec>
              [--fps=25] [--width=736] [--height=1312] [--font-wait=300]
      """
      import argparse, os, subprocess, tempfile, shutil
      from pathlib import Path
      from playwright.sync_api import sync_playwright
      
      
      def render(html_path, out_mp4, duration, fps=25, width=736, height=1312,
                 font_wait=300, with_audio_track=True):
          html_path = os.path.abspath(html_path)
          out_mp4 = os.path.abspath(out_mp4)
          tmp = Path(tempfile.mkdtemp(prefix="hyperframe_"))
          frames_dir = tmp / "frames"
          frames_dir.mkdir()
      
          with sync_playwright() as p:
              browser = p.chromium.launch()
              ctx = browser.new_context(
                  viewport={"width": width, "height": height},
                  device_scale_factor=1,
              )
              page = ctx.new_page()
              page.goto(f"file://{html_path}")
              page.wait_for_load_state("networkidle")
      
              # Wait for web fonts to settle. CDN-loaded fonts (Bricolage Grotesque,
              # Inter) only finish layout *after* networkidle on Linux Chromium —
              # without this, the first few hundred ms of every render show
              # system-fallback fonts.
              page.evaluate("document.fonts.ready")
              if font_wait > 0:
                  page.wait_for_timeout(font_wait)
      
              n_frames = int(round(duration * fps))
              for i in range(n_frames):
                  t_sec = i / fps
                  # Contract: HTML composition exposes window.renderAt(t_seconds)
                  # that scrubs document.getAnimations() to t.
                  page.evaluate(f"window.renderAt({t_sec})")
                  page.screenshot(
                      path=str(frames_dir / f"frame_{i:05d}.png"),
                      full_page=False,
                      # Clip enforces viewport bounds so Bricolage Grotesque 900,
                      # which occasionally renders past the body width on Linux
                      # Chromium, gets trimmed instead of expanding the canvas.
                      clip={"x": 0, "y": 0, "width": width, "height": height},
                  )
              browser.close()
      
          ffmpeg_args = [
              "ffmpeg", "-y", "-loglevel", "error",
              "-framerate", str(fps),
              "-i", str(frames_dir / "frame_%05d.png"),
          ]
          # Mux a silent stereo AAC track when requested so the output mp4 is
          # concat-compatible with scene clips that have audio (the assembly
          # atoms expect every input to either have audio or none have audio).
          if with_audio_track:
              ffmpeg_args += [
                  "-f", "lavfi", "-t", str(duration),
                  "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
              ]
          ffmpeg_args += [
              "-c:v", "libx264",
              "-pix_fmt", "yuv420p",
              "-crf", "18",
              "-preset", "medium",
              "-r", str(fps),
          ]
          if with_audio_track:
              ffmpeg_args += ["-c:a", "aac", "-shortest"]
          ffmpeg_args += ["-movflags", "+faststart", out_mp4]
      
          subprocess.run(ffmpeg_args, check=True)
          shutil.rmtree(tmp)
          print(f"rendered {out_mp4} ({duration}s @ {fps}fps, {width}x{height})")
      
      
      if __name__ == "__main__":
          parser = argparse.ArgumentParser(description=__doc__)
          parser.add_argument("html")
          parser.add_argument("output_mp4")
          parser.add_argument("duration", type=float)
          parser.add_argument("--fps", type=int, default=25)
          parser.add_argument("--width", type=int, default=736)
          parser.add_argument("--height", type=int, default=1312)
          parser.add_argument("--font-wait", type=int, default=300,
                              help="Extra ms to wait after document.fonts.ready (default 300)")
          parser.add_argument("--no-audio-track", action="store_true",
                              help="Skip muxing a silent stereo AAC track")
          args = parser.parse_args()
          render(args.html, args.output_mp4, args.duration,
                 fps=args.fps, width=args.width, height=args.height,
                 font_wait=args.font_wait,
                 with_audio_track=not args.no_audio_track)
      
    • render_master.py 17.5 KB
      #!/usr/bin/env python3
      """Config-driven value-prop renderer — deterministic, FREE, brand-agnostic.
      
      Reads a brand `config.json` (the `ad_sample.recipe.config` schema) and renders
      a value-prop spot: hook sticker -> one beat per short benefit claim (the hero
      product rotates beat to beat) -> brand end card. Text + product carry the spot;
      built to be legible sound-off. Every beat is a pure function of beat-local time
      `t` (Playwright frame-step via the BUNDLED render_hyperframe.py + FFmpeg). No
      paid calls; music is added separately (create-music-elevenlabs) or ship silent.
      
      Nothing here is hardcoded to one brand: palette, copy, SKUs, pacing, logo, hook
      and end card all come from the config. Product widths auto-scale from each
      image's aspect ratio, so the same renderer handles tall sachets and wide
      packshots alike.
      
      Usage:
          render_master.py --config config.json --project <project_dir> [--out master.mp4]
      
      - `--project` is the folder holding the source assets the config references
        (SKU pngs, logo) and where working/ + finals/ outputs are written. Asset
        paths in the config resolve relative to it. Defaults to the config's folder.
      - Interpreter: run this with a Python that has Playwright installed. Override
        the frame-render interpreter with RENDER_PYTHON if needed.
      - ffmpeg: auto-discovered (FFMPEG env > PATH > common install prefixes).
      
      Config schema (see config.example.json):
          width,height,fps,duration_s
          brand_name, hook_line, hook_eyebrow?, hook_sku?, tagline, cta
          logo?                      # path to a wordmark image (>=1200x600); if
                                     # missing/absent, a typographic brand_name wordmark is used
          skus: [{slug, png}]
          palette: { bg, ink, bg_cream?, sku_accents: {slug: hex} }
          pacing: { hook_s, prop_s, endcard_s }
          value_props: [{ label, eyebrow, benefit_sentence?, accent, layout('row'|'hero'), hero_sku?, headline_html? }]
          layout?: { text_top, headline_size, hero_h, hero_bottom, support_h, support_bottom,
                     support_opacity, row_h, row_bottom }   # all optional; sane defaults below
      """
      import argparse
      import json
      import os
      import shutil
      import subprocess
      import sys
      from pathlib import Path
      
      HERE = Path(__file__).resolve().parent
      
      
      # ---------------------------------------------------------------- environment
      def find_ffmpeg():
          if os.environ.get("FFMPEG"):
              return os.environ["FFMPEG"]
          found = shutil.which("ffmpeg")
          if found:
              return found
          for p in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"):
              if Path(p).exists():
                  return p
          return "ffmpeg"  # last resort; will error clearly if truly absent
      
      
      def render_python():
          # The interpreter that drives Playwright. Default = the one running us
          # (so `playwright-python render_master.py ...` just works); override with env.
          return os.environ.get("RENDER_PYTHON") or sys.executable
      
      
      def find_shared(name):
          """_shared.css / _shared.js ship in the capability's shared/. Tolerate the
          few layouts `gooseworks fetch` / installers produce."""
          for cand in (HERE / name, HERE / "shared" / name, HERE.parent / "shared" / name, HERE.parent / name):
              if cand.exists():
                  return cand
          raise FileNotFoundError(f"{name} not found near {HERE}")
      
      
      def hyperframe_script():
          for cand in (HERE / "render_hyperframe.py", HERE / "scripts" / "render_hyperframe.py"):
              if cand.exists():
                  return cand
          raise FileNotFoundError("render_hyperframe.py not bundled next to render_master.py")
      
      
      def img_aspect(path, default=0.8):
          """width/height of an image, for auto-scaling product widths. Falls back to
          a portrait-packshot default if PIL is unavailable or the file is missing."""
          try:
              from PIL import Image
              with Image.open(path) as im:
                  w, h = im.size
                  return w / h if h else default
          except Exception:
              return default
      
      
      # ------------------------------------------------------------------- palette
      def _hex_rgb(h):
          h = h.lstrip("#")
          if len(h) == 3:
              h = "".join(c * 2 for c in h)
          return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))
      
      
      def palette_style(pal):
          """Override the shared stylesheet's :root tokens from config so the renderer
          carries no baked-in brand color."""
          ink = pal.get("ink", "#111111")
          r, g, b = _hex_rgb(ink)
          bg = pal.get("bg", "#FFFFFF")
          cream = pal.get("bg_cream", bg)
          return (
              "<style>:root{"
              f"--ink:{ink};"
              f"--ink-72:rgba({r},{g},{b},0.72);--ink-55:rgba({r},{g},{b},0.55);"
              f"--ink-30:rgba({r},{g},{b},0.30);--ink-15:rgba({r},{g},{b},0.14);"
              f"--bg-white:{bg};--bg-cream:{cream};}}"
              f"html,body{{background:{bg};}}"
              f".prop-h{{font-family:var(--f-display);font-weight:700;line-height:0.98;"
              f"letter-spacing:-0.028em;color:var(--ink);}} .prop-h em{{font-style:italic;font-weight:600;}}"
              "</style>"
          )
      
      
      def accent_of(vp, pal):
          a = vp.get("accent", "")
          if isinstance(a, str) and a.startswith("#"):
              return a
          return pal.get("sku_accents", {}).get(a, pal.get("ink", "#111111"))
      
      
      # --------------------------------------------------------------- beat markup
      LAYOUT_DEFAULTS = dict(text_top=210, headline_size=96, hero_h=680, hero_bottom=200,
                             support_h=300, support_bottom=360, support_opacity=0.38,
                             row_h=300, row_bottom=320)
      
      
      def html_doc(head_extra, body, dur_s, render_js):
          return (
              "<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">"
              "<link rel=\"stylesheet\" href=\"_shared.css\">" + head_extra + "</head><body>"
              + body + "<script src=\"_shared.js\"></script><script>initRenderer("
              + f"{dur_s}, function(t){{{render_js}}});</script></body></html>"
          )
      
      
      def prop_beat(vp, pal, sku_src, lay):
          accent = accent_of(vp, pal)
          eyebrow = vp.get("eyebrow", "")
          headline = vp.get("headline_html") or vp.get("label", "")
          sub = vp.get("benefit_sentence", "")
          imgs, anim = "", ""
      
          if vp.get("layout") == "row":
              items = list(sku_src.items())
              n = len(items)
              gap = 1080 / (n + 1)
              for i, (slug, src) in enumerate(items):
                  w = int(lay["row_h"] * img_aspect(src))
                  cx = gap * (i + 1)
                  imgs += (f'<img id="s{i}" class="sachet" src="file://{src}" '
                           f'style="left:{cx:.0f}px; width:{w}px; bottom:{lay["row_bottom"]}px; '
                           f'transform:translateX(-50%); opacity:0;">')
                  d = 0.10 + i * 0.07
                  anim += (f"{{const u=tw_lin(t,{d:.2f},{d + 0.42:.2f});const e=easeOut(u);"
                           f"const el=document.getElementById('s{i}');el.style.opacity=e;"
                           f"el.style.transform=`translateX(-50%) translateY(${{(1-e)*55}}px) scale(${{0.96+0.04*e}})`;}}")
          else:  # hero
              hero = vp.get("hero_sku") or next(iter(sku_src))
              hero_src = sku_src.get(hero, next(iter(sku_src.values())))
              others = [s for s in sku_src if s != hero] or [hero]
              lsrc, rsrc = sku_src[others[0]], sku_src[others[-1]]
              hw = int(lay["hero_h"] * img_aspect(hero_src))
              sw = int(lay["support_h"] * img_aspect(lsrc))
              so = lay["support_opacity"]
              imgs = (
                  f'<img id="sl" class="sachet" src="file://{lsrc}" style="left:40px; width:{sw}px; bottom:{lay["support_bottom"]}px; opacity:0;">'
                  f'<img id="sr" class="sachet" src="file://{rsrc}" style="right:40px; width:{sw}px; bottom:{lay["support_bottom"]}px; opacity:0;">'
                  f'<img id="sh" class="sachet" src="file://{hero_src}" style="left:50%; width:{hw}px; bottom:{lay["hero_bottom"]}px; opacity:0;">'
              )
              anim = (
                  f"{{const u=tw_lin(t,0.10,0.50);const e=easeOut(u);const el=document.getElementById('sl');"
                  f"el.style.opacity=e*{so};el.style.transform=`translateY(${{(1-e)*45}}px)`;}}"
                  f"{{const u=tw_lin(t,0.16,0.56);const e=easeOut(u);const el=document.getElementById('sr');"
                  f"el.style.opacity=e*{so};el.style.transform=`translateY(${{(1-e)*45}}px)`;}}"
                  "{const u=tw_lin(t,0.20,0.72);const e=easeOut(u);const el=document.getElementById('sh');"
                  "el.style.opacity=e;const sc=u<1?springScale(u)*0.94+0.06:1;"
                  "el.style.transform=`translateX(-50%) translateY(${(1-e)*60}px) scale(${sc})`;}"
              )
      
          body = (
              f'<div class="stage">'
              f'<div class="text-zone" style="position:absolute; top:{lay["text_top"]}px; left:0; right:0; padding:0 70px; text-align:center;">'
              f'<div id="eyebrow" class="eyebrow" style="opacity:0;">{eyebrow}</div>'
              f'<div id="rule" class="accent-rule" style="background:{accent}; opacity:0; transform:scaleX(0.3); transform-origin:center;"></div>'
              f'<h1 id="headline" class="prop-h" style="font-size:{lay["headline_size"]}px; opacity:0; transform:translateY(28px);">{headline}</h1>'
              f'<div id="sub" class="body" style="opacity:0; transform:translateY(16px); margin-top:34px;">{sub}</div>'
              f'</div>{imgs}</div>'
          )
          js = (
              "{const u=tw_lin(t,0.10,0.45);const e=easeOut(u);document.getElementById('eyebrow').style.opacity=e;}"
              "{const u=tw_lin(t,0.20,0.55);const e=easeOut(u);const el=document.getElementById('rule');"
              "el.style.opacity=e;el.style.transform=`scaleX(${0.3+0.7*e})`;}"
              "{const u=tw_lin(t,0.30,0.75);const e=easeOut(u);const el=document.getElementById('headline');"
              "el.style.opacity=e;el.style.transform=`translateY(${(1-e)*28}px)`;}"
              "{const u=tw_lin(t,0.45,0.90);const e=easeOut(u);const el=document.getElementById('sub');"
              "el.style.opacity=e;el.style.transform=`translateY(${(1-e)*16}px)`;}"
              + anim
          )
          return html_doc(pal["_style"], body, 2.4, js)
      
      
      def hook_beat(cfg, pal, sku_src, dur):
          hero = cfg.get("hook_sku") or next(iter(sku_src))
          hero_src = sku_src.get(hero, next(iter(sku_src.values())))
          hw = int(760 * img_aspect(hero_src))
          eyebrow = cfg.get("hook_eyebrow", "")
          line = cfg.get("hook_line", cfg.get("tagline", "")).replace("\n", "<br>")
          badge = cfg.get("brand_name", "")
          head = pal["_style"] + (
              "<style>"
              f".hook-prod{{position:absolute;left:50%;bottom:120px;width:{hw}px;transform-origin:bottom center;}}"
              ".sticker{position:absolute;top:250px;left:50%;background:var(--ink);color:#fff;padding:42px 62px;"
              "border-radius:22px;box-shadow:0 26px 64px rgba(0,0,0,0.28);text-align:center;max-width:900px;}"
              ".sticker .eye{font-family:var(--f-body);font-size:22px;font-weight:600;letter-spacing:0.34em;"
              "text-transform:uppercase;color:rgba(255,255,255,0.72);margin-bottom:18px;}"
              ".sticker .h{font-family:var(--f-display);font-weight:700;font-style:italic;font-size:96px;"
              "line-height:1.0;letter-spacing:-0.028em;color:#fff;}"
              ".badge{position:absolute;bottom:60px;left:50%;transform:translateX(-50%);font-family:var(--f-body);"
              "font-size:22px;font-weight:600;letter-spacing:0.34em;text-transform:uppercase;color:var(--ink-55);}"
              "</style>"
          )
          eyebrow_html = f'<div class="eye">{eyebrow}</div>' if eyebrow else ""
          body = (
              '<div class="stage" style="padding:0;">'
              f'<img id="prod" class="hook-prod" src="file://{hero_src}">'
              f'<div id="sticker" class="sticker">{eyebrow_html}<div class="h">{line}</div></div>'
              f'<div id="badge" class="badge">{badge}</div></div>'
          )
          js = (
              "const e1=easeOut(tw_lin(t,0.00,0.80));const sc=0.94+0.06*e1;const p=document.getElementById('prod');"
              "p.style.opacity=e1;p.style.transform=`translateX(-50%) translateY(${(1-e1)*80}px) scale(${sc})`;"
              "const u2=clamp(tw_lin(t,0.40,1.10),0,1);const ss=u2<1?springScale(u2):1.0;const rot=-2.0+(1-u2)*-4;"
              "const s=document.getElementById('sticker');s.style.opacity=easeOut(u2);"
              "s.style.transform=`translateX(-50%) rotate(${rot}deg) scale(${ss})`;"
              "document.getElementById('badge').style.opacity=tw(t,1.20,1.60);"
          )
          return html_doc(head, body, dur, js)
      
      
      def endcard_beat(cfg, pal, logo_src, dur):
          tag = cfg.get("tagline", "").replace("\n", "<br>")
          cta = cfg.get("cta", "")
          # Wordmark: use a hi-res logo image if one is available; else fall back to a
          # crisp typographic brand_name wordmark (many brands only have a favicon).
          if logo_src and Path(logo_src).exists() and img_aspect(logo_src, 0) >= 1.2:
              mark = f'<div id="logo" class="mark-img" style="opacity:0; transform:scale(0.94);"><img src="file://{logo_src}"></div>'
          else:
              mark = f'<div id="logo" class="mark-txt" style="opacity:0; transform:scale(0.94);">{cfg.get("brand_name", "")}</div>'
          head = pal["_style"] + (
              "<style>"
              ".endcard-stage{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:80px;}"
              ".mark-img{width:780px;} .mark-img img{width:100%;display:block;}"
              ".mark-txt{font-family:var(--f-display);font-weight:700;font-size:150px;letter-spacing:-0.04em;color:var(--ink);line-height:1;}"
              ".tag{margin-top:48px;font-family:var(--f-display);font-weight:700;font-style:italic;font-size:60px;"
              "line-height:1.06;letter-spacing:-0.02em;color:var(--ink);text-align:center;}"
              ".cta{margin-top:76px;font-family:var(--f-body);font-weight:600;font-size:24px;letter-spacing:0.34em;"
              "text-transform:uppercase;color:#fff;background:var(--ink);padding:22px 44px;border-radius:14px;}"
              "</style>"
          )
          body = (
              '<div class="endcard-stage">' + mark
              + f'<div id="tag" class="tag" style="opacity:0; transform:translateY(16px);">{tag}</div>'
              + f'<div id="cta" class="cta" style="opacity:0;">{cta}</div></div>'
          )
          js = (
              "{const u=tw_lin(t,0.00,0.70);const e=easeOut(u);const sc=u<1?springScale(u)*0.94+0.06:1;"
              "const el=document.getElementById('logo');el.style.opacity=e;el.style.transform=`scale(${sc})`;}"
              "{const u=tw_lin(t,0.30,0.80);const e=easeOut(u);const el=document.getElementById('tag');"
              "el.style.opacity=e;el.style.transform=`translateY(${(1-e)*16}px)`;}"
              "document.getElementById('cta').style.opacity=tw(t,0.80,1.20);"
          )
          return html_doc(head, body, dur, js)
      
      
      # ----------------------------------------------------------------------- main
      def main():
          ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
          ap.add_argument("--config", required=True, help="brand config.json (recipe.config schema)")
          ap.add_argument("--project", help="asset root + output dir (default: config's folder)")
          ap.add_argument("--out", help="output mp4 (default: <project>/finals/master-clean.mp4)")
          a = ap.parse_args()
      
          cfg = json.loads(Path(a.config).read_text())
          project = Path(a.project).resolve() if a.project else Path(a.config).resolve().parent
          hf_dir = project / "working" / "hyperframes"
          clips_dir = project / "working" / "beat-clips"
          finals = project / "finals"
          for d in (hf_dir, clips_dir, finals):
              d.mkdir(parents=True, exist_ok=True)
          out = Path(a.out).resolve() if a.out else finals / "master-clean.mp4"
      
          W = int(cfg.get("width", 1080))
          H = int(cfg.get("height", 1920))
          FPS = int(cfg.get("fps", 30))
          pal = dict(cfg.get("palette", {}))
          pal["_style"] = palette_style(pal)
          lay = {**LAYOUT_DEFAULTS, **cfg.get("layout", {})}
      
          def resolve(p):
              return str((project / p).resolve()) if p else None
      
          sku_src = {s["slug"]: resolve(s["png"]) for s in cfg.get("skus", [])}
          if len(sku_src) < 3:
              print(f"WARNING: value-prop leans on >=3 per-SKU visuals; got {len(sku_src)}", file=sys.stderr)
          logo_src = resolve(cfg.get("logo"))
          pacing = cfg.get("pacing", {})
          hook_s = float(pacing.get("hook_s", 3.0))
          prop_s = float(pacing.get("prop_s", 2.4))
          end_s = float(pacing.get("endcard_s", 2.0))
      
          # copy the shared runtime next to the generated HTML (paths are relative)
          for name in ("_shared.css", "_shared.js"):
              shutil.copy(find_shared(name), hf_dir / name)
      
          beats = [("hook", hook_s, hook_beat(cfg, pal, sku_src, hook_s))]
          for i, vp in enumerate(cfg.get("value_props", []), 1):
              slug = vp.get("label", f"prop{i}").lower().replace(" ", "-")[:24]
              beats.append((f"{i:02d}-{slug}", prop_s, prop_beat(vp, pal, sku_src, lay)))
          beats.append(("endcard", end_s, endcard_beat(cfg, pal, logo_src, end_s)))
      
          py, hf, ff = render_python(), str(hyperframe_script()), find_ffmpeg()
          print(f"Rendering {len(beats)} beats @ {W}x{H} {FPS}fps  (py={py}, ffmpeg={ff})")
          clips = []
          for slug, dur, html in beats:
              hp = hf_dir / f"beat-{slug}.html"
              hp.write_text(html)
              cp = clips_dir / f"beat-{slug}.mp4"
              subprocess.run([py, hf, str(hp), str(cp), str(dur),
                              "--width", str(W), "--height", str(H), "--fps", str(FPS),
                              "--no-audio-track"], check=True)
              clips.append(cp)
      
          concat = clips_dir / "concat.txt"
          concat.write_text("\n".join(f"file '{c.resolve()}'" for c in clips) + "\n")
          total = sum(d for _, d, _ in beats)
          subprocess.run([ff, "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
                          "-i", str(concat), "-f", "lavfi", "-t", f"{total:.3f}",
                          "-i", "anullsrc=channel_layout=stereo:sample_rate=44100",
                          "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac",
                          "-b:a", "128k", "-shortest", "-movflags", "+faststart", str(out)], check=True)
          print(f"OK {out} ({total:.1f}s, {out.stat().st_size / 1024 / 1024:.1f}MB) {W}x{H}")
      
      
      if __name__ == "__main__":
          main()
      
  • shared
    • animations
      • sachet-entrance
        • cascade-from-top.js 980 B
          // sachet-entrance/cascade-from-top.js
          // Sachets fall in from above with gravity easing. Sophisticated.
          // STATUS: scaffolded.
          //
          // Contract: animate(t, elements, params?) → void
          //   params: { drop_distance_px?: 200, duration_ms?: 700, stagger_ms?: 80 }
          
          (function (root) {
            function animateSachets(t, elements, params) {
              params = params || {};
              const drop     = params.drop_distance_px ?? 200;
              const duration = (params.duration_ms ?? 700) / 1000;
              const stagger  = (params.stagger_ms ?? 80) / 1000;
          
              elements.forEach(function (el, i) {
                const start = 0.10 + i * stagger;
                const u = tw_lin(t, start, start + duration);
                // Gravity-ish curve — slow start, fast settle
                const e = u < 1 ? Math.pow(u, 2.2) : 1;
                const baseTx = el.dataset.baseTx || "";
                el.style.opacity = e;
                el.style.transform = `${baseTx} translateY(${(1 - e) * -drop}px)`;
              });
            }
            root.animateSachets_cascadeFromTop = animateSachets;
          })(window);
          
        • fade-stagger.js 1013 B
          // sachet-entrance/fade-stagger.js
          // Calm fade + slide-up, staggered. Validated on Som Sleep v5 (sleep_wellness preset).
          //
          // Contract: animate(t, elements, params?) → void
          //   t        — beat-local time in seconds (driven by initRenderer in _shared.js)
          //   elements — NodeList or Array of sachet <img> elements
          //   params   — { delay_per_ms?: 70, duration_ms?: 500, offset_y_px?: 60 }
          
          (function (root) {
            function animateSachets(t, elements, params) {
              params = params || {};
              const delayPer = (params.delay_per_ms ?? 70) / 1000;
              const duration = (params.duration_ms ?? 500) / 1000;
              const offsetY  = params.offset_y_px ?? 60;
          
              elements.forEach(function (el, i) {
                const start = 0.10 + i * delayPer;
                const u = tw_lin(t, start, start + duration);
                const e = easeOut(u);
                el.style.opacity = e;
                el.style.transform = `translateY(${(1 - e) * offsetY}px) scale(${0.96 + 0.04 * e})`;
              });
            }
            root.animateSachets_fadeStagger = animateSachets;
          })(window);
          
        • slide-in-lateral.js 1.2 KB
          // sachet-entrance/slide-in-lateral.js
          // Sachets enter from screen edges, alternating left/right per index. Active.
          // For sport_active / energy_cpg / tech_active brand registers.
          //
          // Contract: animate(t, elements, params?) → void
          //   params: { duration_ms?: 450, offset_x_px?: 320 }
          //
          // Index parity drives direction:
          //   even idx → enters from left (translateX -offset → 0)
          //   odd idx  → enters from right (translateX +offset → 0)
          // Staggered 50ms apart.
          
          (function (root) {
            function animateSachets(t, elements, params) {
              params = params || {};
              const duration = (params.duration_ms ?? 450) / 1000;
              const offsetX  = params.offset_x_px ?? 320;
              const staggerS = 0.05;
          
              elements.forEach(function (el, i) {
                const start = 0.08 + i * staggerS;
                const u = tw_lin(t, start, start + duration);
                const e = easeOut(u);
                const dir = i % 2 === 0 ? -1 : +1;   // even=left, odd=right
                // Preserve any centering transform via translateX override
                const baseTx = el.dataset.baseTx || "";
                el.style.opacity = e;
                el.style.transform = `${baseTx} translateX(${dir * (1 - e) * offsetX}px) scale(${0.94 + 0.06 * e})`;
              });
            }
            root.animateSachets_slideInLateral = animateSachets;
          })(window);
          
        • springScale-pop.js 883 B
          // sachet-entrance/springScale-pop.js
          // Sachets scale 0.4 → 1.08 → 1.0 with elastic overshoot. Punchy.
          // STATUS: scaffolded — not yet validated on a real brand run.
          //
          // Contract: animate(t, elements, params?) → void
          //   params: { duration_ms?: 600, stagger_ms?: 100 }
          
          (function (root) {
            function animateSachets(t, elements, params) {
              params = params || {};
              const duration = (params.duration_ms ?? 600) / 1000;
              const stagger  = (params.stagger_ms ?? 100) / 1000;
          
              elements.forEach(function (el, i) {
                const start = 0.10 + i * stagger;
                const u = tw_lin(t, start, start + duration);
                const baseTx = el.dataset.baseTx || "";
                const s = u < 1 ? springScale(u) : 1.0;
                el.style.opacity = easeOut(u);
                el.style.transform = `${baseTx} scale(${s})`;
              });
            }
            root.animateSachets_springScalePop = animateSachets;
          })(window);
          
        • tilt-fan.js 1.1 KB
          // sachet-entrance/tilt-fan.js
          // Sachets fan out from stacked center with slight rotation per index. Luxurious.
          // STATUS: scaffolded.
          //
          // Contract: animate(t, elements, params?) → void
          //   params: { fan_angle_deg?: 8, duration_ms?: 700 }
          //
          // Index drives rotation: relative to center, sachets tilt outward.
          
          (function (root) {
            function animateSachets(t, elements, params) {
              params = params || {};
              const fanAngle = params.fan_angle_deg ?? 8;
              const duration = (params.duration_ms ?? 700) / 1000;
              const n = elements.length;
              const center = (n - 1) / 2;
          
              elements.forEach(function (el, i) {
                const start = 0.10 + i * 0.05;
                const u = tw_lin(t, start, start + duration);
                const e = easeOut(u);
                const baseTx = el.dataset.baseTx || "";
                const offsetFromCenter = i - center;
                const finalRot = offsetFromCenter * fanAngle;
                const rot = finalRot * e;
                el.style.opacity = e;
                el.style.transform = `${baseTx} rotate(${rot}deg) scale(${0.94 + 0.06 * e})`;
              });
            }
            root.animateSachets_tiltFan = animateSachets;
          })(window);
          
      • text-entrance
        • fade-stagger.js 1.3 KB
          // text-entrance/fade-stagger.js
          // Eyebrow → rule scaleX → headline translateY → sub fade. Staggered.
          // Validated on Som Sleep v5 (sleep_wellness preset).
          //
          // Contract: animate(t, elements, params?) → void
          //   elements: { eyebrow, rule, headline, sub } — getElementById refs
          //   params:   none required
          
          (function (root) {
            function animateText(t, elements, params) {
              // Eyebrow
              if (elements.eyebrow) {
                const u = tw_lin(t, 0.10, 0.45);
                elements.eyebrow.style.opacity = easeOut(u);
              }
              // Accent rule (scaleX from 0.3 → 1.0)
              if (elements.rule) {
                const u = tw_lin(t, 0.20, 0.55);
                const e = easeOut(u);
                elements.rule.style.opacity = e;
                elements.rule.style.transform = `scaleX(${0.3 + 0.7 * e})`;
              }
              // Headline (translateY 28 → 0)
              if (elements.headline) {
                const u = tw_lin(t, 0.30, 0.75);
                const e = easeOut(u);
                elements.headline.style.opacity = e;
                elements.headline.style.transform = `translateY(${(1 - e) * 28}px)`;
              }
              // Sub-sentence (translateY 16 → 0)
              if (elements.sub) {
                const u = tw_lin(t, 0.45, 0.90);
                const e = easeOut(u);
                elements.sub.style.opacity = e;
                elements.sub.style.transform = `translateY(${(1 - e) * 16}px)`;
              }
            }
            root.animateText_fadeStagger = animateText;
          })(window);
          
        • mask-reveal.js 1.1 KB
          // text-entrance/mask-reveal.js
          // Text reveals behind a moving curtain mask. Sophisticated.
          // STATUS: scaffolded.
          //
          // IMPLEMENTATION NOTE: Uses CSS clip-path with inset() animated by t.
          // The headline element needs `overflow:hidden` and the .h-display child gets the clip.
          
          (function (root) {
            function animateText(t, elements, params) {
              if (elements.eyebrow) elements.eyebrow.style.opacity = easeOut(tw_lin(t, 0.05, 0.30));
              if (elements.rule) {
                const u = tw_lin(t, 0.10, 0.35);
                const e = easeOut(u);
                elements.rule.style.opacity = e;
                elements.rule.style.transform = `scaleX(${0.4 + 0.6 * e})`;
              }
              if (elements.headline) {
                const u = tw_lin(t, 0.25, 0.85);
                const e = easeOut(u);
                // Reveal from left → right via clip-path inset
                const cutRight = (1 - e) * 100;
                elements.headline.style.opacity = 1;
                elements.headline.style.clipPath = `inset(0 ${cutRight}% 0 0)`;
              }
              if (elements.sub) {
                const u = tw_lin(t, 0.75, 1.05);
                elements.sub.style.opacity = easeOut(u);
              }
            }
            root.animateText_maskReveal = animateText;
          })(window);
          
        • type-on.js 1.3 KB
          // text-entrance/type-on.js
          // Headline characters appear letter-by-letter. Tech feel.
          // STATUS: scaffolded — not yet validated.
          //
          // REQUIREMENT: headline must be split into <span class="char"> per character.
          //
          // Contract: animate(t, elements, params?) → void
          //   params: { char_stagger_ms?: 30, char_duration_ms?: 120 }
          
          (function (root) {
            function animateText(t, elements, params) {
              params = params || {};
              const stagger = (params.char_stagger_ms ?? 30) / 1000;
              const dur     = (params.char_duration_ms ?? 120) / 1000;
          
              if (elements.eyebrow) elements.eyebrow.style.opacity = easeOut(tw_lin(t, 0.05, 0.30));
              if (elements.rule) {
                const u = tw_lin(t, 0.10, 0.35);
                const e = easeOut(u);
                elements.rule.style.opacity = e;
                elements.rule.style.transform = `scaleX(${0.4 + 0.6 * e})`;
              }
              if (elements.headline) {
                const chars = elements.headline.querySelectorAll('.char');
                const baseStart = 0.30;
                chars.forEach(function (c, i) {
                  const start = baseStart + i * stagger;
                  const u = tw_lin(t, start, start + dur);
                  c.style.opacity = easeOut(u);
                });
              }
              if (elements.sub) {
                const u = tw_lin(t, 0.85, 1.15);
                elements.sub.style.opacity = easeOut(u);
              }
            }
            root.animateText_typeOn = animateText;
          })(window);
          
        • word-by-word.js 1.9 KB
          // text-entrance/word-by-word.js
          // Each headline word fades + translates individually, 80ms staggered.
          // Punchy / sport-active register.
          //
          // REQUIREMENT: headline must be split into <span class="word"> per word at build time.
          // The build script should pre-process headline text:
          //   "NSF Certified for Sport." →
          //   <span class="word">NSF</span> <span class="word">Certified</span> ...
          //
          // Contract: animate(t, elements, params?) → void
          //   elements: { eyebrow, rule, headline, sub } — headline contains .word children
          //   params:   { word_stagger_ms?: 80, word_duration_ms?: 350 }
          
          (function (root) {
            function animateText(t, elements, params) {
              params = params || {};
              const wordStagger = (params.word_stagger_ms ?? 80) / 1000;
              const wordDur     = (params.word_duration_ms ?? 350) / 1000;
          
              // Eyebrow + rule fade in fast
              if (elements.eyebrow) {
                const u = tw_lin(t, 0.05, 0.30);
                elements.eyebrow.style.opacity = easeOut(u);
              }
              if (elements.rule) {
                const u = tw_lin(t, 0.10, 0.35);
                const e = easeOut(u);
                elements.rule.style.opacity = e;
                elements.rule.style.transform = `scaleX(${0.4 + 0.6 * e})`;
              }
              // Headline: per-word stagger
              if (elements.headline) {
                const words = elements.headline.querySelectorAll('.word');
                const baseStart = 0.25;
                words.forEach(function (w, i) {
                  const start = baseStart + i * wordStagger;
                  const u = tw_lin(t, start, start + wordDur);
                  const e = easeOut(u);
                  w.style.opacity = e;
                  w.style.transform = `translateY(${(1 - e) * 24}px)`;
                });
              }
              // Sub
              if (elements.sub) {
                const u = tw_lin(t, 0.65, 1.05);
                const e = easeOut(u);
                elements.sub.style.opacity = e;
                elements.sub.style.transform = `translateY(${(1 - e) * 14}px)`;
              }
            }
            root.animateText_wordByWord = animateText;
          })(window);
          
      • registry.yml 4.2 KB
        # Animation registry — maps preset IDs → JS modules + metadata
        # Read this at build time to wire shot-list animation choices to actual JS implementations.
        
        sachet_entrance:
        
          fade-stagger:
            file: sachet-entrance/fade-stagger.js
            description: "Each sachet fades up + slides 60px from below, staggered 60ms apart."
            energy: calm
            params:
              delay_per_ms: 70
              duration_ms: 500
              ease: easeOut
              offset_y_px: 60
            fits: [sleep_wellness, premium_clinical]
            status: validated   # Som v5
        
          slide-in-lateral:
            file: sachet-entrance/slide-in-lateral.js
            description: "Sachets enter from screen edges, alternating left/right per index."
            energy: active
            params:
              duration_ms: 450
              offset_x_px: 320
              ease: cubic-bezier(.2,.7,.2,1)
            fits: [sport_active, energy_cpg, tech_active]
            status: validated   # Som v6
        
          springScale-pop:
            file: sachet-entrance/springScale-pop.js
            description: "Sachets scale 0.4→1.08→1.0 with elastic overshoot."
            energy: punchy
            params:
              duration_ms: 600
              overshoot: 1.08
            fits: [energy_cpg, sport_active]
            status: scaffolded
        
          cascade-from-top:
            file: sachet-entrance/cascade-from-top.js
            description: "Sachets fall in from above with gravity easing."
            energy: sophisticated
            params:
              drop_distance_px: 200
              duration_ms: 700
              delay_per_ms: 80
            fits: [luxury_editorial, premium_clinical]
            status: scaffolded
        
          tilt-fan:
            file: sachet-entrance/tilt-fan.js
            description: "Sachets fan out from stacked center, slight rotation per index."
            energy: luxurious
            params:
              fan_angle_deg: 8
              duration_ms: 700
            fits: [luxury_editorial]
            status: scaffolded
        
        
        text_entrance:
        
          fade-stagger:
            file: text-entrance/fade-stagger.js
            description: "Eyebrow → rule scaleX → headline translateY → sub fade. Staggered."
            energy: calm
            fits: [sleep_wellness, premium_clinical, luxury_editorial]
            status: validated   # Som v5
        
          word-by-word:
            file: text-entrance/word-by-word.js
            description: "Each headline word fades + translates individually, 80ms apart."
            energy: punchy
            fits: [sport_active, energy_cpg, tech_active]
            status: validated   # Som v6
        
          type-on:
            file: text-entrance/type-on.js
            description: "Headline characters appear letter-by-letter."
            energy: tech
            fits: [tech_active]
            status: scaffolded
        
          mask-reveal:
            file: text-entrance/mask-reveal.js
            description: "Text reveals behind a moving curtain mask."
            energy: sophisticated
            fits: [luxury_editorial]
            status: scaffolded
        
        
        transitions:
          # v1: hard-cut only. v2 will add cross-dissolve, whip-pan, slide-push.
          hard-cut:
            file: transitions/hard-cut.js
            description: "No overlap, instant beat boundary."
            fits: [all]
            status: validated
        
          cross-dissolve:
            file: transitions/cross-dissolve.js
            description: "Last 0.3s of beat N fades into first 0.3s of beat N+1."
            fits: [sleep_wellness, premium_clinical, luxury_editorial]
            status: deferred-v2
        
          whip-pan:
            file: transitions/whip-pan.js
            description: "Brief horizontal motion blur between beats."
            fits: [energy_cpg, tech_active]
            status: deferred-v2
        
          slide-push:
            file: transitions/slide-push.js
            description: "Beat N slides off-screen as beat N+1 slides on."
            fits: [sport_active]
            status: deferred-v2
        
        
        # Brand-register defaults — molecule picks ONE preset from this when 'animation_preset: auto'
        brand_register_defaults:
          sleep_wellness:
            sachet_entrance: fade-stagger
            text_entrance: fade-stagger
            transition: hard-cut    # v2: cross-dissolve
        
          premium_clinical:
            sachet_entrance: cascade-from-top
            text_entrance: mask-reveal
            transition: hard-cut    # v2: cross-dissolve
        
          energy_cpg:
            sachet_entrance: springScale-pop
            text_entrance: word-by-word
            transition: hard-cut    # v2: whip-pan
        
          sport_active:
            sachet_entrance: slide-in-lateral
            text_entrance: word-by-word
            transition: hard-cut    # v2: slide-push
        
          luxury_editorial:
            sachet_entrance: tilt-fan
            text_entrance: mask-reveal
            transition: hard-cut    # v2: cross-dissolve
        
          tech_active:
            sachet_entrance: springScale-pop
            text_entrance: type-on
            transition: hard-cut    # v2: whip-pan
        
    • beat-templates
      • endcard-wordmark.html 1.9 KB · in bundle
      • hook-sticker.html 2.4 KB · in bundle
      • prop-hero.html 2.7 KB · in bundle
      • prop-row.html 3 KB · in bundle
    • _shared.css 4.7 KB · in bundle
    • _shared.js 2.5 KB
      // Gradient Editorial — shared helpers + deterministic renderer init for hyperframes.
      //
      // Per molecule rule (Decision Rule 3): animation is a pure function of beat-local time
      // `t` in seconds. Never CSS keyframes, never setTimeout. Every beat HTML ends its script
      // with initRenderer(duration, renderFn) so:
      //   (a) Playwright can drive window.renderAt(t) at 1/25s steps for production rendering
      //   (b) the same HTML auto-loops in a browser tab for preview review
      //
      // Ported from everself-hb/working/doctor-christopher-avatar/working/hyperframes-v4/_shared.js
      
      const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
      const lerp = (a, b, t) => a + (b - a) * t;
      const easeOut = (t) => 1 - Math.pow(1 - t, 3);
      const easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
      
      // Spring scale: starts 0.4, overshoots 1.08 at 0.55, settles 1.0 at 1.0
      function springScale(t) {
        if (t <= 0) return 0.4;
        if (t >= 1) return 1.0;
        if (t < 0.55) {
          const u = t / 0.55;
          return 0.4 + (1.08 - 0.4) * easeOut(u);
        } else {
          const u = (t - 0.55) / 0.45;
          return 1.08 - 0.08 * easeOut(u);
        }
      }
      
      // Stagger word-reveals over a span: given an array of word elements and a starts array
      // of absolute times, set opacity and translateY.
      function revealWords(els, starts, t, dur = 0.24, yOffset = 28) {
        els.forEach((el, i) => {
          const u = clamp((t - starts[i]) / dur, 0, 1);
          const e = easeOut(u);
          el.style.opacity = e;
          el.style.transform = `translateY(${(1 - e) * yOffset}px)`;
        });
      }
      
      // Time-window helper: returns eased progress over [start, end].
      function tw(t, start, end) {
        if (end <= start) return t >= start ? 1 : 0;
        return easeOut(clamp((t - start) / (end - start), 0, 1));
      }
      
      function tw_lin(t, start, end) {
        if (end <= start) return t >= start ? 1 : 0;
        return clamp((t - start) / (end - start), 0, 1);
      }
      
      // Initialize the renderer. Beat HTMLs call this once with their duration + render fn.
      function initRenderer(duration, renderFn) {
        const _internal = renderFn;
        let lastExternalCallTime = 0;
      
        window.renderAt = function (t) {
          lastExternalCallTime = performance.now();
          _internal(t);
        };
      
        const LOOP_PAUSE = 0.6;
        let autoStart = null;
        function tick(now) {
          if (now - lastExternalCallTime > 400) {
            if (autoStart === null) autoStart = now;
            const elapsed = ((now - autoStart) / 1000) % (duration + LOOP_PAUSE);
            const tt = Math.min(elapsed, duration);
            _internal(tt);
          }
          requestAnimationFrame(tick);
        }
        _internal(0);
        requestAnimationFrame(tick);
      }
      
  • tests
    • smoke-test.md 477 B
      # Smoke Test
      
      build_storyboard_preview.py (free preview gallery) ; render_master.py -> finals/master-*-clean.mp4 — 1080x1920, deterministic, $0.
      
      Pass when the script runs to a valid silent MP4 output (hook + 3-5 claim beats + brand end card), every claim label is <=4 words and legible sound-off, the hero SKU rotates beat to beat, and no paid call is made (music is a separate capability). For paid caps the call is proxy-routed (bills the agent, no direct provider host).
      
  • SKILL.md 3.5 KB
    ---
    name: render-value-prop
    description: Render a designed 'value prop' video from a config — 3-5 noun-phrase benefit claims (<=4 words each) revealed sequentially over per-SKU product visuals, one crisp editorial frame per claim (hook sticker -> N claim beats -> brand end card). Deterministic PIL/HTML beat renderer frame-stepped via Playwright and encoded with FFmpeg, sound-off legible, hard cuts, uniform pacing. FREE (no paid calls); music is added separately (create-music-elevenlabs). Use for the value-prop format.
    status: active
    ---
    
    # render-value-prop
    
    Render a designed 'value prop' video from a config: a hook sticker, then one beat per short noun-phrase benefit claim (<=4 words each — "Drug-Free", "Zero Sugar", "NSF Certified"), each pairing the claim headline with a per-SKU product visual (the hero SKU rotates beat to beat so the eye anchor shifts), then a brand-wordmark end card. Text + product carry the spot — no narration, no talking head — and it is built to be legible sound-off. Every beat is a pure function of beat-local time `t` (deterministic PIL start frames + Playwright hyperframes + FFmpeg); no CSS keyframes, no setTimeout. FREE (no paid calls); music is a separate capability (create-music-elevenlabs), or ship silent for $0.
    
    ## Run
    `render_master.py --config config.json --project <dir>` -> `<dir>/finals/master-clean.mp4` (silent),
    1080x1920, deterministic, $0. The renderer is **fully config-driven** — palette, copy, SKUs,
    pacing, hook, logo and end card all come from `config.json` (schema = `ad_sample.recipe.config`;
    see `config.example.json`). Nothing is hardcoded to one brand. `build_storyboard_preview.py` is an
    optional free preview gallery for the gate; `build_text_overlays.py` is optional (transparent
    text-zone PNGs for compositing claims over a motion clip).
    
    Environment: run with a **Python that has Playwright** (override the frame-render interpreter with
    `RENDER_PYTHON`); `ffmpeg` is auto-discovered (`FFMPEG` env > PATH > common prefixes). The frame
    renderer `render_hyperframe.py` is **bundled** in `scripts/` — no external atom to fetch.
    
    ## Contract
    - Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls, no AI-rendered text.
    - Claims are noun phrases, <=4 words; never <3, never >5. Optional benefit sentence <=12 words.
    - One product visual per beat; rotate which SKU is the hero. Never reuse a flat variety-pack image as every canvas.
    - Sound-off legibility is the bar: the headline uses the config `palette.ink` color on `palette.bg`;
      the per-beat **accent** color (from `value_props[].accent` — a SKU-accent slug or a hex) is the
      accent rule, not the headline.
    - Product widths **auto-scale from each image's aspect ratio** (target display height), so tall
      sachet cutouts and wide product packshots both frame correctly.
    - Assets are packshots, not always transparent cutouts: set `palette.bg` to the product image's
      background color for seamless compositing (free — avoids a paid background-removal step).
    - Uniform pacing (hook ~3.0s, props 2.0-2.5s each, endcard ~2.0s); total lands in the 10-20s window (~17s). No acceleration curve.
    - No human face is the focus. End card uses the brand wordmark **image** when a hi-res one
      (aspect >= ~1.2, i.e. a real >=1200x600 wordmark) is provided via `config.logo`; otherwise it
      **falls back to a typographic `brand_name` wordmark** (many brands ship only a favicon).
    - Music is added separately by create-music-elevenlabs (quiet instrumental bed at -14 dB), or ship silent.
    
  • skill.meta.json 311 B
    {
      "slug": "render-value-prop",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-value-prop",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related