render-photo-grid-card
Render a 'photo-grid promo card' video from a config — a real-DOM card with a FIXED header/footer and a CONTINUOUSLY SCROLLING 2-row grid of MIXED tiles (video clips, product/lifestyle stills, big-serif %-OFF, dark promo-code or CTA) + feature chips, frame-stepped via Playwright
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-photo-grid-card
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-photo-grid-card
Render the 'photo-grid promo card' format from a config. The signature of this format is a
continuously scrolling 2-row grid (NOT a static card) under a fixed header (brand
wordmark + big headline + sub) and fixed feature chips. The grid-viewport scrolls left
across the whole ~10s, edge-faded with a CSS mask, and its tiles are mixed media:
video clips playing inside tiles, band-product/photo stills, big-serif pct/off
type tiles, and a dark code (promo code) or cta tile.
The renderer itself is FREE/deterministic (Playwright frame-step + FFmpeg). The paid inputs
are separate capabilities: the tile clips come from create-video-fal (i2v of the
product heroes, or clips extracted from the brand's own ad corpus) and the music from
create-music-elevenlabs. Text (wordmark, %, code) is real DOM — never AI-rendered.
Run
build_card.py --config config.json --out hyperframe.html ; render.py --config config.json --html hyperframe.html --out master-silent.mp4 — 1080x1920, scrolling grid, deterministic, $0.
Clip tiles are driven by FRAME-SWAP, not <video>: render.py pre-extracts each clip to
PNG frames with ffmpeg, preloads them, and swaps each <img class="vidframe">'s src per
output frame. This is because Playwright's bundled Chromium can't decode <video> H.264
over file:// (open-source build, no proprietary codecs) — it hangs on canplay. Letting
ffmpeg decode makes it codec-independent AND deterministic. Provide clips longer than the
master (or the swap just loops them); a subtle setpts-slow reads as cinematic.
Contract
- Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls in this capability.
- Tiles-per-row = grid columns (
cols), NOT total tiles; the grid fills row-major. - The template recipe (DB) supplies the config; clips + music are separate capabilities.
Files (goose-skills)
-
scripts
-
build_card.py 10.6 KB
#!/usr/bin/env python3 """Generate the 'photo-grid promo card' HTML from a config.json — a designed feed card whose signature is a CONTINUOUSLY SCROLLING 2-row grid (NOT a static hold): a fixed header (brand wordmark + big headline + sub) and fixed feature chips, with a middle grid-viewport that scrolls horizontally left across the whole ~10s while its tiles play. Tiles are typed and MIXED-MEDIA (this is the whole point of the format): - "video" a clip playing inside the tile (autoplay/muted/loop; cover-cropped) - "band-product" a real product hero on a soft band (contain) - "photo"/"image" a still photo (cover) - "pct"/"off"/"text" a big serif type tile (e.g. "25%", "OFF") — pixel-crisp DOM - "code" a dark promo-code tile (CODE + accent-prefixed value) - "cta" a dark call-to-action tile (title + sub) when there is no promo code The grid is `grid-template-columns: repeat(cols, tile_w)` × `rows` rows, filled row-major, faded at both edges with a CSS mask so tiles don't pop in. Motion is a pure function of time (window.renderAt(t)); the <video> tiles play in real time (render.py paces to wall-clock). Reuses _shared.js (the initRenderer scaffold). NO AI-rendered text — wordmark/%/code stay crisp. Usage: build_card.py --config config.json --out hyperframe.html """ import argparse, html, json, pathlib def esc(s): return html.escape(str(s), quote=True) def tile_html(t): kind = t.get("type", "photo") if kind == "band-product" or kind == "product": return f'<div class="tile band-product"><img src="{esc(t["image"])}" /></div>' if kind in ("photo", "image"): img = t.get("image") if img: return f'<div class="tile"><img src="{esc(img)}" /></div>' return '<div class="tile photo-placeholder"></div>' if kind == "video": cap = f'<div class="cap">{esc(t["caption"])}</div>' if t.get("caption") else "" stem = pathlib.Path(t["src"]).stem # Played by FRAME-SWAP, not <video>: render.py pre-extracts the clip to PNG frames and # swaps this <img>'s src per output frame. Playwright's bundled Chromium can't decode # <video> H.264 over file:// (open-source build lacks proprietary codecs) — it hangs on # canplay — so we let ffmpeg do the decode and drive frames deterministically. return (f'<div class="tile"><img class="vidframe" data-clip="{esc(stem)}" ' f'data-src="{esc(t["src"])}" alt="" ' f'src="data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" />' f'{cap}</div>') if kind in ("pct", "off", "text"): size = t.get("size", 162 if kind == "off" else 156) txt = t.get("text", t.get("pct", t.get("label", ""))) return (f'<div class="tile type-big"><div class="big" style="font-size:{size}px">' f'{esc(txt)}</div></div>') if kind == "code": val = t.get("value", "CODE"); accent = t.get("accent_prefix", "") if accent and val.startswith(accent): val_html = f'<span class="accent">{esc(accent)}</span>{esc(val[len(accent):])}' else: val_html = esc(val) return (f'<div class="tile code"><div class="lbl">{esc(t.get("label","CODE"))}</div>' f'<div class="val">{val_html}</div></div>') if kind == "cta": return (f'<div class="tile cta"><div class="cta-t">{esc(t.get("title","SHOP"))}</div>' f'<div class="cta-s">{esc(t.get("sub",""))}</div></div>') raise SystemExit(f"unknown tile type: {kind}") DEFAULT_PALETTE = {"ink":"#0E1B22","ink_soft":"#4A5961","bg":"#E6EEF3", "bg_tile":"#F4F8FB","bg_band":"#DCE6EC","accent":"#31ABE8"} def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", required=True) ap.add_argument("--out", required=True) a = ap.parse_args() cfg = json.loads(pathlib.Path(a.config).read_text()) W, H = cfg.get("width",1080), cfg.get("height",1920) dur = cfg.get("duration_sec",10.0) p = {**DEFAULT_PALETTE, **cfg.get("palette",{})} sc = cfg.get("scroll",{}) tw_ = sc.get("tile_w",360); th_ = sc.get("tile_h",380); gap = sc.get("gap",24) pad_x = sc.get("pad_x",60); cols = sc.get("cols",5); rows = sc.get("rows",2) entry_off = sc.get("entry_offset",80); soft = sc.get("soft_entry",0.80) tiles = cfg["tiles"] if len(tiles) != cols*rows: raise SystemExit(f"expected {cols*rows} tiles ({cols}x{rows}), got {len(tiles)}") track_total = cols*tw_ + (cols-1)*gap + 2*pad_x scroll_range = max(0, track_total - W) tiles_html = "\n".join(tile_html(t) for t in tiles) chips_html = "\n".join(f'<div class="chip">{esc(c)}</div>' for c in cfg.get("chips",[])) shared = (pathlib.Path(__file__).resolve().parent / "_shared.js").read_text() doc = TEMPLATE.format( W=W,H=H,dur=dur,shared_js=shared, wordmark=esc(cfg["wordmark"]), headline=cfg["headline"], sub=esc(cfg.get("sub","")), tiles=tiles_html, chips=chips_html, tile_w=tw_, tile_h=th_, gap=gap, pad_x=pad_x, cols=cols, rows=rows, grid_top=sc.get("band_top",640), grid_h=sc.get("band_height",800), head_top=sc.get("head_top",200), head_size=sc.get("head_size",154), brand_top=sc.get("brand_top",110), sub_top=sc.get("sub_top",528), chips_bottom=sc.get("chips_bottom",240), scroll_range=scroll_range, entry_off=entry_off, soft=soft, **{f"c_{k}":v for k,v in p.items()}, ) pathlib.Path(a.out).write_text(doc) print(f"[card] wrote {a.out} ({W}x{H}, {cols}x{rows} scrolling grid, " f"track={track_total}px, scroll_range={scroll_range}px)") TEMPLATE = """<!doctype html> <html lang="en"><head><meta charset="utf-8" /> <link href="https://api.fontshare.com/v2/css?f[]=cabinet-grotesk@500,700,800&f[]=zodiak@500,700&display=swap" rel="stylesheet" /> <script>{shared_js}</script> <style> :root {{ --ink:{c_ink}; --ink-soft:{c_ink_soft}; --bg:{c_bg}; --bg-tile:{c_bg_tile}; --bg-band:{c_bg_band}; --accent:{c_accent}; }} * {{ margin:0; padding:0; box-sizing:border-box; }} html,body {{ width:{W}px; height:{H}px; background:var(--bg); font-family:'Cabinet Grotesk',system-ui,sans-serif; color:var(--ink); overflow:hidden; -webkit-font-smoothing:antialiased; }} .frame {{ position:relative; width:{W}px; height:{H}px; }} .brand-mark {{ position:absolute; top:{brand_top}px; left:0; right:0; display:flex; justify-content:center; align-items:center; z-index:10; }} .brand-mark img {{ height:44px; opacity:.92; }} .headline {{ position:absolute; top:{head_top}px; left:0; right:0; text-align:center; font-weight:800; font-size:{head_size}px; line-height:.94; letter-spacing:-4.2px; color:var(--ink); z-index:10; }} .sub {{ position:absolute; top:{sub_top}px; left:0; right:0; text-align:center; font-weight:500; font-size:38px; letter-spacing:-.2px; color:var(--ink-soft); z-index:10; }} .grid-viewport {{ position:absolute; left:0; right:0; top:{grid_top}px; height:{grid_h}px; overflow:hidden; mask-image:linear-gradient(90deg,transparent 0,#000 4%,#000 96%,transparent 100%); -webkit-mask-image:linear-gradient(90deg,transparent 0,#000 4%,#000 96%,transparent 100%); }} .grid-track {{ position:absolute; top:0; left:0; display:grid; grid-template-columns:repeat({cols},{tile_w}px); grid-template-rows:repeat({rows},{tile_h}px); gap:{gap}px; padding:0 {pad_x}px; width:max-content; will-change:transform; }} .tile {{ position:relative; width:{tile_w}px; height:{tile_h}px; border-radius:26px; overflow:hidden; background:var(--bg-tile); box-shadow:0 1px 0 rgba(14,27,34,.03); }} .tile img, .tile video {{ width:100%; height:100%; object-fit:cover; display:block; }} .tile.photo-placeholder {{ background:linear-gradient(135deg,var(--bg-tile),var(--bg-band)); }} .tile.band-product {{ background:var(--bg-band); display:flex; align-items:center; justify-content:center; padding:16px; }} .tile.band-product img {{ width:105%; height:auto; object-fit:contain; }} .tile .cap {{ position:absolute; left:16px; bottom:16px; background:#fff; color:#111; font-weight:600; font-size:24px; line-height:1; padding:10px 13px; border-radius:9px; box-shadow:0 1px 3px rgba(0,0,0,.12); }} .tile.type-big {{ background:var(--bg-tile); display:flex; align-items:center; justify-content:center; padding:20px; }} .tile.type-big .big {{ font-family:'Zodiak',serif; font-weight:700; letter-spacing:-5px; line-height:.9; color:var(--ink); }} .tile.code {{ background:var(--ink); color:#fff; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:22px; }} .tile.code .lbl {{ font-weight:700; letter-spacing:6px; font-size:26px; color:rgba(255,255,255,.65); text-transform:uppercase; margin-bottom:14px; }} .tile.code .val {{ font-weight:800; font-size:104px; letter-spacing:-3px; color:#fff; }} .tile.code .val .accent {{ color:var(--accent); }} .tile.cta {{ background:var(--ink); color:#fff; display:flex; flex-direction:column; align-items:center; justify-content:center; text-align:center; padding:22px; }} .tile.cta .cta-t {{ font-weight:800; font-size:60px; line-height:.98; letter-spacing:-2px; }} .tile.cta .cta-s {{ font-weight:600; font-size:26px; letter-spacing:1px; color:rgba(255,255,255,.72); margin-top:14px; }} .chips {{ position:absolute; left:0; right:0; bottom:{chips_bottom}px; display:flex; justify-content:center; gap:22px; z-index:10; }} .chip {{ background:var(--bg); border:2px solid var(--ink); border-radius:999px; padding:22px 42px; font-weight:700; font-size:30px; letter-spacing:1.8px; color:var(--ink); text-transform:uppercase; }} </style></head> <body><div class="frame"> <div class="brand-mark" id="brand"><img src="{wordmark}" /></div> <div class="headline" id="headline">{headline}</div> <div class="sub" id="sub">{sub}</div> <div class="grid-viewport"><div class="grid-track" id="track"> {tiles} </div></div> <div class="chips" id="chips"> {chips} </div> </div> <script>{shared_js}</script> <script> const brand=document.getElementById('brand'),headline=document.getElementById('headline'),sub=document.getElementById('sub'),track=document.getElementById('track'); const SCROLL_RANGE={scroll_range}, ENTRY_OFF={entry_off}, DUR={dur}; initRenderer(DUR, function(t){{ brand.style.opacity=easeOut(clamp01(tw(t,0.00,0.40))); const hx=clamp01(tw(t,0.10,0.70)); headline.style.opacity=easeOut(hx); headline.style.transform=`translateY(${{(1-easeOut(hx))*32}}px)`; sub.style.opacity=easeOut(clamp01(tw(t,0.50,0.90))); const entry=easeOut(clamp01(tw(t,0.00,{soft}))); const entryOffset=ENTRY_OFF*(1-entry); const scrollOffset=-SCROLL_RANGE*clamp01(t/DUR); track.style.transform=`translateX(${{scrollOffset+entryOffset}}px)`; }}); </script></body></html> """ if __name__ == "__main__": main() -
render.py 4.6 KB
#!/usr/bin/env python3 """Frame-step a scrolling promo-card hyperframe.html -> silent mp4 (Playwright + ffmpeg). The card scrolls continuously and its tiles contain clips. Playwright's bundled Chromium can't decode <video> H.264 over file:// (open-source build, no proprietary codecs) and hangs on `canplay`, so we DON'T use <video>: build_card emits each clip tile as an <img class= "vidframe" data-clip=... data-src=...>, and this renderer pre-extracts every clip to PNG frames with ffmpeg (which decodes fine), preloads them, and swaps each vidframe's src per output frame — codec-independent AND deterministic (a pure function of time). Reads dims/fps/duration from config.json. FREE (no paid calls). Usage: render.py --config config.json --html hyperframe.html --out master-silent.mp4 """ import argparse, asyncio, json, pathlib, shutil, subprocess from playwright.async_api import async_playwright async def run(cfg, html_path, out_path): fps = cfg.get("fps",25); dur = cfg.get("duration_sec",10.0) W,H = cfg.get("width",1080), cfg.get("height",1920) html_dir = html_path.resolve().parent # 1) pre-extract each unique clip to PNG frames at fps (ffmpeg decodes; Chromium doesn't) srcs = {pathlib.Path(t["src"]).stem: t["src"] for t in cfg.get("tiles",[]) if t.get("type") == "video"} cliproot = html_dir / "_clipframes" if cliproot.exists(): shutil.rmtree(cliproot) clips = {} for stem, src in srcs.items(): d = cliproot / stem; d.mkdir(parents=True) subprocess.run(["ffmpeg","-v","error","-i", str(html_dir / src), "-vf", f"fps={fps}", str(d / "f_%04d.png")], check=True) clips[stem] = len(list(d.glob("f_*.png"))) scratch = out_path.parent / "_scratch_frames" if scratch.exists(): shutil.rmtree(scratch) scratch.mkdir(parents=True) async with async_playwright() as p: b = await p.chromium.launch(args=["--autoplay-policy=no-user-gesture-required"]) ctx = await b.new_context(viewport={"width":W,"height":H}, device_scale_factor=1) page = await ctx.new_page() await page.goto(html_path.resolve().as_uri()) await page.wait_for_function("window.__driverReady === true", timeout=15000) await page.evaluate("document.fonts.ready") if clips: # warm the cache: preload every clip frame so per-frame swaps decode instantly await page.evaluate("""(clips)=>{ window.__clips = clips; window.__pre = Promise.all(Object.entries(clips).flatMap(([c,n]) => Array.from({length:n}, (_,k) => new Promise(r => { const im = new Image(); im.onload = im.onerror = r; im.src = `_clipframes/${c}/f_${String(k+1).padStart(4,'0')}.png`; })))); }""", clips) await page.evaluate("window.__pre") n = round(dur*fps) for i in range(n): t = i/fps await page.evaluate(f"window.renderAt({t})") if clips: await page.evaluate("""(i)=>{ document.querySelectorAll('.vidframe').forEach(v => { const c = v.dataset.clip, n = window.__clips && window.__clips[c]; if (!n) return; const s = `_clipframes/${c}/f_${String((i%n)+1).padStart(4,'0')}.png`; if (v.getAttribute('src') !== s) v.src = s; }); }""", i) await page.evaluate("Promise.all(Array.from(document.querySelectorAll('.vidframe')).map(v=>v.decode?v.decode().catch(()=>{}):0))") await page.evaluate("new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r)))") await page.screenshot(path=str(scratch/f"frame_{i:04d}.png"), clip={"x":0,"y":0,"width":W,"height":H}) await b.close() subprocess.run(["ffmpeg","-y","-framerate",str(fps),"-i",str(scratch/"frame_%04d.png"), "-c:v","libx264","-pix_fmt","yuv420p","-crf","18","-r",str(fps), "-movflags","+faststart",str(out_path)], check=True) shutil.rmtree(scratch) shutil.rmtree(cliproot, ignore_errors=True) print(f"[render] {out_path}") def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", required=True); ap.add_argument("--html", required=True) ap.add_argument("--out", required=True) a = ap.parse_args() cfg = json.loads(pathlib.Path(a.config).read_text()) asyncio.run(run(cfg, pathlib.Path(a.html), pathlib.Path(a.out))) if __name__ == "__main__": main() -
_shared.js 950 B
// Deterministic frame renderer scaffold for pick-05. // Same contract as pick-01: initRenderer(duration_s, renderFn). Pure-function-of-time motion. (function () { let _duration = 0; let _renderFn = null; window.initRenderer = function (durationSeconds, renderFn) { _duration = durationSeconds; _renderFn = renderFn; renderFn(0); window.__driverReady = true; }; window.renderAt = function (t) { if (!_renderFn) return; if (t < 0) t = 0; if (t > _duration) t = _duration; _renderFn(t); }; window.clamp01 = function (x) { return Math.max(0, Math.min(1, x)); }; window.tw = function (t, t0, t1) { if (t <= t0) return 0; if (t >= t1) return 1; return (t - t0) / (t1 - t0); }; window.easeOut = function (x) { return 1 - Math.pow(1 - x, 3); }; window.easeOutBack = function (x, s) { s = s || 1.70158; return 1 + (s + 1) * Math.pow(x - 1, 3) + s * Math.pow(x - 1, 2); }; })();
-
-
tests
-
smoke-test.md 641 B
# Smoke Test build_card.py --config config.json --out hyperframe.html ; render.py --config config.json --html hyperframe.html --out master-silent.mp4 — 1080x1920 scrolling 2-row grid, deterministic, $0. Pass when: - the scripts run to a valid 1080x1920 mp4 at the config fps/duration; - the middle grid scrolls horizontally over the clip (not a static hold), tiles are edge-faded, and `<video>` tiles play (real-time playback, no seek hang); - header wordmark/headline/sub and the %-OFF / code / CTA type tiles stay pixel-crisp DOM; - no paid provider host is called from this capability (clips + music are separate media capabilities).
-
-
SKILL.md 2.4 KB
--- name: render-photo-grid-card description: Render a 'photo-grid promo card' video from a config — a real-DOM card with a FIXED header/footer and a CONTINUOUSLY SCROLLING 2-row grid of MIXED tiles (video clips, product/lifestyle stills, big-serif %-OFF, dark promo-code or CTA) + feature chips, frame-stepped via Playwright with real-time <video> playback and encoded with FFmpeg — deterministic assembly, FREE (the clips + music come from create-video-fal + create-music-elevenlabs), text stays pixel-crisp. Use for the photo-grid-promo-card format. status: active --- # render-photo-grid-card Render the 'photo-grid promo card' format from a config. The signature of this format is a **continuously scrolling 2-row grid** (NOT a static card) under a fixed header (brand wordmark + big headline + sub) and fixed feature chips. The grid-viewport scrolls left across the whole ~10s, edge-faded with a CSS mask, and its tiles are **mixed media**: `video` clips playing inside tiles, `band-product`/`photo` stills, big-serif `pct`/`off` type tiles, and a dark `code` (promo code) or `cta` tile. The renderer itself is FREE/deterministic (Playwright frame-step + FFmpeg). The paid inputs are separate capabilities: the tile **clips** come from `create-video-fal` (i2v of the product heroes, or clips extracted from the brand's own ad corpus) and the **music** from `create-music-elevenlabs`. Text (wordmark, %, code) is real DOM — never AI-rendered. ## Run build_card.py --config config.json --out hyperframe.html ; render.py --config config.json --html hyperframe.html --out master-silent.mp4 — 1080x1920, scrolling grid, deterministic, $0. Clip tiles are driven by FRAME-SWAP, not `<video>`: `render.py` pre-extracts each clip to PNG frames with ffmpeg, preloads them, and swaps each `<img class="vidframe">`'s src per output frame. This is because Playwright's bundled Chromium can't decode `<video>` H.264 over file:// (open-source build, no proprietary codecs) — it hangs on `canplay`. Letting ffmpeg decode makes it codec-independent AND deterministic. Provide clips longer than the master (or the swap just loops them); a subtle `setpts`-slow reads as cinematic. ## Contract - Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls in this capability. - Tiles-per-row = grid columns (`cols`), NOT total tiles; the grid fills row-major. - The template recipe (DB) supplies the config; clips + music are separate capabilities. -
skill.meta.json 321 B
{ "slug": "render-photo-grid-card", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-photo-grid-card", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.