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
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-value-prop
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
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.inkcolor onpalette.bg; the per-beat accent color (fromvalue_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.bgto 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 typographicbrand_namewordmark (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 " " 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.
Reviews (0)
No reviews yet.
No comments yet.