Photo-Grid format = a continuously-scrolling tile grid (product photos + short brand video clips + big-type offer + promo-code tiles) under a wordmark + headline + chips. Silent, music-optional, sub-12s, loopable. A single HTML hyperframe rendered frame-by-frame via Playwright + ffmpeg — $0 spend, ~30s compute. This is the cheap, editorial sibling of
create-vignette-ad(which lays product cutouts over a paid T2V kinetic background).
Purpose
Produce a short-form (8–12s) vertical or 4:5 Photo-Grid promo card for a brand by:
- Gathering brand assets (clean product PNGs, logo SVG, brand palette, and short clips harvested from the brand's own existing ad corpus — HD source preferred over Pexels).
- Authoring ONE HTML hyperframe: centered wordmark, big headline + sub, a 2×N tile grid that scrolls horizontally for the whole duration behind an edge-fade mask, and outline feature chips.
- Filling the grid with a rhythm of tile kinds — product photos, short looping brand video clips, big-type offer tiles (e.g. "25%" / "OFF" in a display serif), and one dark promo-code tile.
- Rendering it frame-stepped (Playwright screenshot per frame, videos played in real time) and encoding to MP4 with ffmpeg.
- Optionally laying an instrumental music bed under it (EBU R128 loudness-normalized) — the base master is silent and works muted.
Use when:
- The brand has clean product photography AND/OR an existing ad/video corpus to harvest tiles from.
- The aesthetic target is an editorial, premium feed card that reads instantly while muted.
- A promo/offer (discount + code) or a seasonal hook ("For dad, by Hume.") drives the card.
- Budget is ~$0 and turnaround is minutes.
Do NOT use for:
- Kinetic-background atmospheric ads with product cutouts → use
create-vignette-ad(paid T2V BG). - UGC creator / talking-head ads → use
skills/molecules/ugc-ad/*. - VO-driven explainers or demos → use a VO-anchored beat-video molecule.
- Long-form (>15s) → the format's read breaks past ~12s.
Inputs
Required:
brand_url— e.g.https://humehealth.com(for product PNGs, logo, palette).headline— 2–4 words, 1–2 lines, e.g. "For dad, by Hume.".offer— discount + code, e.g.25% OFF+DAD25(or a non-promo hook if the card is brand-led).
Optional (with autonomous defaults):
aspect_ratio—9:16(default, 1080×1920) or4:5(1080×1350).duration_s— default 10.0 (range 8–12).sub— one line under the headline, e.g. "The longevity band, gift-wrapped.".grid— rows × tiles-per-row (default 2×5) + tile size (default 360×380, 24px gap).chips— 0–3 outline feature pills, e.g.["14 DAY BATTERY", "NEW PATENTED STRAP"].palette—{ ink, ink_soft, bg, bg_tile, bg_band, accent }hex; default derived from the brand homepage CSS (top frequencies) + the brand accent.fonts— one display sans + one display serif (default Cabinet Grotesk + Zodiak via Fontshare).music_brief— free-text; when set, an instrumental bed is generated and muxed. Default: silent.source_clips— brand ad-corpus clips to extract video tiles from (default: scan the brand's existing-ads corpus for sustained-shot HD moments; fall back to Pexels portrait stock).
Prerequisites:
- Playwright Chromium +
ffmpeg/ffprobeon the machine. librsvg(rsvg-convert) if the logo is an SVG that needs rasterizing.PEXELS_API_KEY(optional) only if the brand has no usable corpus and stock tiles are needed.- For a music bed: the Gooseworks ElevenLabs proxy (bills the user's credits) or a stored asset.
Composed Atoms
source-brand-assets— scrape product PNGs from the brand Shopify/CDN, brand palette hex from the homepage CSS, and the logo SVG from the footer.create-motion-graphics-hyperframes— author + frame-step render the HTML hyperframe (wordmark + headline + sub + scrolling grid + chips) via Playwright; the renderer scaffold (initRenderer,tw/easeOut/clamp01) drives per-frame opacity/transform.stitch-videos-ffmpeg— extract sustained-shot tile clips from the brand corpus (scale→crop→scalefor low-bitrate sources), applysetptsslowdown so no tile loops within the window, and encode the frame sequence to MP4.create-music-elevenlabs— optional instrumental bed via the ElevenLabs music endpoint (generate ~2s longer than the card, trim).add-music-track— EBU R128 loudness-normalize the bed (loudnorm I=-18:TP=-2:LRA=9) and mux it as a SEPARATE ffmpeg pass with explicit-map 0:v:0 -map 1:a:0.watch—/watch:watchfor end-to-end QC on the rendered master.
Workflow
Phase 0 — Brand asset gathering (autonomous)
- Scrape clean product PNGs from the brand PDP (highest-res available). Promote the best PDP
shots into the shared
brand-assets/reference-photos/namespace so later cards reuse them. - Scrape brand palette + logo — top hex frequencies from the homepage CSS; logo SVG from the
footer/theme assets. Derive
{ ink, ink_soft, bg, bg_tile, bg_band, accent }. - Harvest tile video clips — scan the brand's existing-ads corpus for HD, sustained-shot
moments (product macro, app screen-record, a clean lifestyle beat). Probe
existing-ads/*.mp4for resolution BEFORE reaching for anymeta-library/*.mp4— meta-library is 360p delivery and reads blurry upscaled; existing-ads is the HD pool. Avoid Pexels when the brand has its own corpus. - Audit clip captions for compliance — brand ad b-roll sometimes carries on-screen claims (e.g. health-risk overlays). Skip any clip whose caption makes a sensitive claim.
Output: brief.md with the chosen tiles (product photos + clip windows), palette swatches,
headline/sub/offer/chips copy, and aspect/duration.
Phase 1 — Author the hyperframe (autonomous)
- Author one HTML file at the target canvas (1080×1920 for 9:16). Load the display sans + serif
from Fontshare. Lay out: centered wordmark (top), headline (big extrabold, ≤2 lines), sub, a
grid-viewportwith an edge-fademask-image, agrid-trackofrepeat(tiles-per-row, W)columns × N rows, and outline chips near the bottom. - Fill the tiles with a rhythm — product-photo tiles (
object-fit: containon a band background), video-clip tiles (<video autoplay muted loop playsinline>,object-fit: cover), big-type offer tiles (display serif, e.g. "25%" / "OFF"), and one dark promo-code tile (label + value with an accent-colored prefix, e.g.DADin accent +25in white). - Drive motion in
initRenderer(duration, t => …): wordmark fade-in (0–0.4s), headline drop+fade (0.1–0.7s), sub fade (0.5–0.9s), then a continuous lineartranslateXscroll of the track across its full overflow for the whole duration (with a soft eased entry), and chips fade-up (1.5–1.9s). - All asset paths must be RELATIVE to the HTML file — Playwright renders blank for
file://absolute paths.
Phase 2 — Render + optional music (autonomous)
- Frame-step render: launch Chromium with
--autoplay-policy=no-user-gesture-required, wait for fonts + every<video>'scanplay+play(), then for each ofduration×fpsframes callrenderAt(t), double-requestAnimationFrame, screenshot, and sleep to keep real-time video playback. Encode the PNG sequence to MP4 (h264, yuv420p, +faststart, 25fps) → silent master. - Tile clip prep: for each corpus clip,
setpts=N*PTSwhereN = duration / clip_lenso the tile never loops within the window (2–4× slowdown also reads as premium slow-mo). - Optional music: when
music_briefis set, generate an instrumental bed, loudness-normalize, and mux as a separate pass (-map 0:v:0 -map 1:a:0 -shortest). Verify audio bit_rate ≈ 192k. - Self-QC via
/watch:watch: scroll is smooth and continuous; headline + chips legible; type tiles don't bleed; no video tile visibly loops; promo code readable; duration ≈ target.
Decision Rules
- One HTML, one render script — skip the full beat-video pipeline. When the brief is a static/ scrolling photo grid or feed promo card, do NOT bring in VO, Whisper, beat manifests, concat, or captions. Author one hyperframe + one render pass. (~30s compute, $0.)
- Grounded brand corpus beats Pexels for tile content. The brand's own ads carry authentic color grade, lighting, and product fidelity stock can't match. Reach for Pexels only when the brand has no usable corpus.
- HD source only — probe
existing-ads/beforemeta-library/. Meta-library ads are 360p delivery; upscaled into a 1080-wide tile they read obviously stock-y. grid-template-columns: repeat(N, W)is tiles-PER-ROW, not total tiles. Setting it to the total tile count packs everything into row 1 and leaves later rows empty.setptsslowdown, never<video loop>with short clips. One visible loop-restart makes the whole tile read as cheap stock. Slow each clip to fill the window (or ping-pong).- Edge-fade the grid viewport with a horizontal
mask-imagegradient so scroll tiles appear/ disappear softly instead of popping at the edges. - Pair one display sans with one display serif when the card mixes photo tiles and big-type tiles — the serif (offer/price) adds rhythm and an editorial "sales-print" weight.
- Serif display digits run ~20–30% wider than geometric sans — budget type-tile width at ~65% of nominal and re-check fit after any aspect change.
- Aspect change cascades into every type size. Converting 4:5 ↔ 9:16 isn't free real estate — re-audit headline, tile, type-in-tile, and chip sizes against the new bounds.
- The base master is SILENT and must work muted. Music is an optional bed, never load-bearing — feed placements autoplay muted. When a bed is used, keep it instrumental and loudness-normalized.
- ffmpeg mux: explicit
-map 0:v:0 -map 1:a:0+ a SEPARATE pass from the frame encode. Default mapping silently produces ~1 kbps garbage audio. - Style tokens pass a taste gate — website palette/type is a candidate, not law. Background must
clear a legibility gate vs the headline/offer ink (≥ 4.5:1); when the brand color fails, use
near-white (
#F4F8FB/white) with dark ink, or a dark brand-neutral with light ink. Use the brand font ONLY if it's a loadable webface (Google Fonts/Fontshare); else the house faces (Cabinet Grotesk + Zodiak). Never author type below the muted-feed floors (1080-wide 9:16): headline ≥ 132px, sub ≥ 32px, offer/price ≥ 140px, code value ≥ 88px, code label ≥ 22px, chips ≥ 26px. (pick-05 ships#E6EEF3/#0E1B22and 154 / 38 / 156 / 104 / 26 / 30.) - Photo tiles are REAL product/brand photos only. Source ≥ 720px short-edge; never a favicon or logo as a photo tile (logos go in the wordmark slot only); never upscale a source past ~1.5×. If there aren't enough good photos, shrink the grid or mark the slot a pending NB2 generation — never pad with junk or stock-y filler.
Output
<project>/
├── brief.md
├── hyperframes/
│ ├── hyperframe.html # the card layout + initRenderer motion
│ └── _shared.js # initRenderer / tw / easeOut / clamp01 scaffold
├── assets/
│ ├── grounded/ # HD tile clips extracted from the brand corpus
│ └── music/ # optional bed (raw + loudnorm)
├── render.py # Playwright frame-step + ffmpeg encode
└── finals/
└── master-<aspect>.mp4 # silent master (+ -music-<variant>.mp4 when a bed is muxed)
The shipped master is the silent master-<aspect>.mp4; music variants are siblings.
Quality Checks
- Duration within 8–12s; aspect matches requested (9:16 = 1080×1920, 4:5 = 1080×1350).
- Wordmark, headline, sub render in loadable fonts at the right sizes (no clipping/bleed).
- Type sizes ≥ the muted-feed floors (headline ≥132 / sub ≥32 / offer ≥140 / code ≥88 / chip ≥26 px at 1080w).
- Background passes legibility vs the ink (≥4.5:1); not a low-contrast website-lifted combo.
- Photo tiles are real product photos (≥720px short-edge, no favicons/logos, no >1.5× upscale).
- Grid scrolls continuously across its full overflow for the whole duration; entry is soft.
- Grid viewport has the edge-fade mask (tiles don't pop in/out at the edges).
- Every video tile is HD and never visibly loops within the window (setpts applied).
- Type tiles ("25%", "OFF") fit inside the tile with margin; serif sizing re-checked for aspect.
- Promo-code tile reads as a voucher (label small, value huge, accent prefix).
- Feature chips legible as outline pills; no compliance-sensitive claim copy survives any tile.
- Base master plays cleanly muted in QuickTime + browser; if a bed exists, audio ≈ 192k AAC, −18 LUFS.
Failure Modes
| Failure | Cause | Recovery |
|---|---|---|
| All tiles land in grid row 1, later rows empty | grid-template-columns set to total tile count |
Use repeat(tiles-per-row, W) |
| Tile video visibly restarts mid-card | short <video loop> clip not slowed |
setpts=N*PTS (N = duration / clip_len) or ping-pong |
| Tiles read blurry/pixelated | extracted from 360p meta-library/ source |
Re-extract from HD existing-ads/ corpus (probe resolution first) |
| Playwright renders a blank card | file:// absolute asset paths |
Make every asset path relative to the HTML file |
| Type tile text bleeds past the tile edge | serif display digits wider than expected | Drop size ~20–30% + tighten tracking; budget width at 65% nominal |
| Entering/leaving tiles pop at grid edges | no viewport mask | Add mask-image: linear-gradient(90deg, transparent, #000 4%, #000 96%, transparent) |
| Bottom of a 9:16 card looks empty | grid too short for the taller canvas | Add a 3rd tile row, grow tiles, or push chips up (or keep the whitespace as "premium minimal") |
| Final MP4 audio is ~1 kbps | ffmpeg mux without explicit map | -map 0:v:0 -map 1:a:0 as a separate pass from the frame encode |
| Clip caption makes a health/risk claim | brand b-roll overlay copy | Drop that clip; pick a caption-clean sustained shot |
Tests
See tests/:
smoke-test.md— structural pass (one hyperframe → silent MP4 at target aspect/duration).sample-input.md— Hume Health Father's Day promo card worked example.expected-output.md— what a successful render produces.human-test.md— manual acceptance criteria (watch the card muted).verifier.md— ffmpeg/ffprobe assertions.
Related
- Reference build: Hume Health
pick-05Father's Day promo card —HOW_TO_MAKE_THIS_VIDEO.md+LEARNINGS.md(18 load-bearing learnings; $0, 9:16, 10-tile scroll with HD corpus video tiles). - Remix wrapper:
remix-photo-grid-ad-from-sample— the app's Photo-Grid format tab calls this to remix a published card for a new brand. - Sibling format:
create-vignette-ad— product cutouts over a paid T2V kinetic background (the atmospheric, not editorial, silent format). - Registry entry:
formats.json→"photo-grid".