Claude Skill

render-model-comparison-grid

Render a 'model comparison grid' video from a config — a fal-style "same prompt, N contenders" showcase — a dark real-DOM stage where per beat a monospace prompt fades in centered, docks to a small top strip, then a labeled 2-4 panel grid (static images OR muted video clips, mixa

LLM Mart · 0 points · 3 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gooseworks-ai-goose-skills-skills_ads_capabilities_render-model-comparison-grid-e1592ee.zip · 9 KB
Part of gooseworks-ai/goose-skills — 44 skills

Install

skills CLI npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-model-comparison-grid
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
Git git clone https://github.com/gooseworks-ai/goose-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gooseworks-ai/goose-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

render-model-comparison-grid

Render the 'model comparison grid' format from a config. The signature of this format is a "Same prompt. N models." gauntlet: a dark stage where, per beat, a PROMPT eyebrow + the (condensed) prompt fades in centered in monospace and holds readable ~0.8s, then docks to a small top strip while a grid of 2-4 labeled panels staggers in (0.15s apart) and holds for side-by-side comparison. A persistent model/variant label sits under each panel; column order is identical on every beat. Ends on a minimal end card (headline + column names only — no meta-stats line).

The grid is media-agnostic per cell: any cell is a static image or a muted video clip (i2v outputs, screen recordings), mixable within one beat. Video cells loop during the hold and are frame-seeked deterministically (the renderer awaits each seek), so the render never depends on wall-clock playback timing.

The renderer itself is FREE/deterministic (Playwright frame-step + FFmpeg). The paid inputs are separate capabilities: the cell images come from create-image-fal, the cell clips from create-video-fal, and the music bed from create-music-elevenlabs. Prompt text and labels are real DOM — never AI-rendered.

Default shape: 5 beats × 4.5s + 2.5s end card = 25.0s @ 1280×720/30fps, all configurable from one config.json.

Run

build_composition.py --config config.json --output hyperframe.html ; render_seekable_hyperframe.py hyperframe.html master-silent.mp4

build_composition.py validates every cell path and the column count (2-4), infers each cell's media type from its extension (.png/.jpg/.jpeg/.webp → image; .mp4/.mov/.webm/.m4v → muted video), and emits a self-contained HTML that exposes window.mediaReady() + window.renderAt(t). render_seekable_hyperframe.py awaits both, so <video> cells seek to the right frame before each screenshot — never a frozen first frame.

Contract

  • Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls in this capability.
  • Columns = panels-per-beat (2-4); every beat supplies exactly that many cells, same order.
  • The template recipe (DB) supplies the config; cell images/clips + music are separate capabilities.
  • State is computed entirely in renderAt(t) — never CSS animation-delay/transitions (Playwright scrubbing traps delayed animations in pre-state).
  • Video cells must decode in the render Chromium (H.264 yes, ProRes no — transcode .mov ProRes to H.264 first). An images-only grid has no decode dependency.
Files (goose-skills)
  • scripts
    • build_composition.py 10.3 KB
      #!/usr/bin/env python3
      """Build the comparison-grid hyperframe HTML from a config.json.
      
      Usage:
        python3 build_composition.py --config /path/to/config.json --output /path/to/hyperframe.html
      
      Config schema (see config.example.json):
        canvas        {width, height, fps}         default 1280x720 @ 30
        beat_seconds  float                        default 4.5
        cell_aspect   "W:H" of each grid cell      default "3:4"
        columns       [{label}]                    2-4 columns; label renders under every cell
        beats         [{tag, prompt, cells:[path]}] one cell path per column, per beat.
                      Cell media type is inferred from the extension:
                        .png/.jpg/.jpeg/.webp -> image
                        .mp4/.mov/.webm/.m4v  -> video (muted, loops during the hold,
                                                 frame-seeked deterministically)
        endcard       {headline, subline, seconds} keep minimal - no meta-stats line
        timing        {prompt_in, prompt_out, panel_in, panel_stagger, panel_anim,
                       fade_out_start}             all optional, defaults = shipped v3
      
      The output HTML exposes:
        window.mediaReady()  -> Promise; resolves when all <video> metadata is loaded
        window.renderAt(t)   -> Promise; paints frame at time t (awaits video seeks)
      Render with scripts/render_seekable_hyperframe.py (awaits both).
      """
      import argparse
      import json
      import os
      from pathlib import Path
      
      VIDEO_EXT = {".mp4", ".mov", ".webm", ".m4v"}
      IMAGE_EXT = {".png", ".jpg", ".jpeg", ".webp"}
      
      TEMPLATE = """<!DOCTYPE html>
      <html>
      <head>
      <meta charset="utf-8">
      <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        html, body { width: __W__px; height: __H__px; overflow: hidden; background: #0a0a0a; }
        #stage { position: relative; width: __W__px; height: __H__px; background: #0a0a0a;
                 font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; }
        .beat { position: absolute; inset: 0; opacity: 0; }
        .eyebrow { position: absolute; top: __EYEBROW_TOP__px; width: 100%; text-align: center;
                   font-size: 15px; letter-spacing: 4px; color: #8a8a8a; font-weight: 600; }
        .prompt-big { position: absolute; top: __PROMPT_TOP__px; left: 50%; transform: translateX(-50%);
                      width: __PROMPT_W__px; text-align: center;
                      font-family: "SF Mono", Menlo, monospace; font-size: 24px; line-height: 1.55;
                      color: #e8e8e8; }
        .prompt-top { position: absolute; top: 30px; left: 50%; transform: translateX(-50%);
                      width: __TOPSTRIP_W__px; text-align: center;
                      font-family: "SF Mono", Menlo, monospace; font-size: 15px; line-height: 1.5;
                      color: #b9b9b9; opacity: 0; }
        .beat-tag { position: absolute; top: 88px; width: 100%; text-align: center;
                    font-size: 12px; letter-spacing: 3px; color: #6f6f6f; font-weight: 600; opacity: 0; }
        .row { position: absolute; top: __ROW_TOP__px; left: 0; width: __W__px;
               display: flex; justify-content: center; gap: __GAP__px; }
        .panel { width: __CELL_W__px; opacity: 0; }
        .panel .media { width: __CELL_W__px; height: __CELL_H__px; object-fit: cover;
                        border-radius: 6px; display: block; background: #111; }
        .panel .label { margin-top: 12px; text-align: center; font-size: 13px;
                        letter-spacing: 2.5px; color: #9a9a9a; font-weight: 600; }
        #endcard { position: absolute; inset: 0; opacity: 0; background: #0a0a0a; }
        #endcard .l1 { position: absolute; top: __EC1_TOP__px; width: 100%; text-align: center;
                       font-size: 46px; font-weight: 700; color: #f2f2f2; letter-spacing: -0.5px; opacity: 0; }
        #endcard .l2 { position: absolute; top: __EC2_TOP__px; width: 100%; text-align: center;
                       font-size: 19px; color: #a8a8a8; letter-spacing: 2px; opacity: 0; }
      </style>
      </head>
      <body>
      <div id="stage"></div>
      <script>
      const CFG = __CFG_JSON__;
      const T = CFG.timing;
      const BEAT_DUR = CFG.beat_seconds;
      const END_START = CFG.beats.length * BEAT_DUR;
      
      const stage = document.getElementById("stage");
      CFG.beats.forEach((b, bi) => {
        const beat = document.createElement("div");
        beat.className = "beat"; beat.id = "beat" + bi;
        const cells = b.cells.map((c, ci) => {
          const media = c.is_video
            ? `<video class="media" src="${c.src}" muted preload="auto"></video>`
            : `<img class="media" src="${c.src}">`;
          return `<div class="panel" id="p${bi}-${ci}">${media}
            <div class="label">${CFG.columns[ci].label}</div></div>`;
        }).join("");
        beat.innerHTML = `
          <div class="eyebrow">PROMPT</div>
          <div class="prompt-big">${b.prompt}</div>
          <div class="prompt-top">${b.prompt}</div>
          <div class="beat-tag">${b.tag}</div>
          <div class="row">${cells}</div>`;
        stage.appendChild(beat);
      });
      const endcard = document.createElement("div");
      endcard.id = "endcard";
      endcard.innerHTML = `
        <div class="l1">${CFG.endcard.headline}</div>
        <div class="l2">${CFG.endcard.subline}</div>`;
      stage.appendChild(endcard);
      
      const clamp01 = x => Math.max(0, Math.min(1, x));
      const easeOut = x => 1 - Math.pow(1 - x, 3);
      
      window.mediaReady = function () {
        const vids = Array.from(document.querySelectorAll("video"));
        const imgs = Array.from(document.querySelectorAll("img"));
        return Promise.all([
          ...vids.map(v => v.readyState >= 2 ? Promise.resolve()
            : new Promise(res => v.addEventListener("loadeddata", res, { once: true }))),
          ...imgs.map(im => im.complete ? Promise.resolve()
            : new Promise(res => im.addEventListener("load", res, { once: true }))),
        ]);
      };
      
      function seekVideo(v, t) {
        const dur = v.duration && isFinite(v.duration) ? v.duration : 0;
        const target = dur > 0 ? Math.max(0, t) % dur : 0;
        if (Math.abs(v.currentTime - target) < 1 / 120) return Promise.resolve();
        return new Promise(res => {
          v.addEventListener("seeked", res, { once: true });
          v.currentTime = target;
        });
      }
      
      window.renderAt = function (t) {
        const waits = [];
        CFG.beats.forEach((b, bi) => {
          const el = document.getElementById("beat" + bi);
          const tb = t - bi * BEAT_DUR;
          if (tb < -0.001 || tb > BEAT_DUR + 0.001) { el.style.opacity = 0; return; }
      
          let op = 1;
          if (bi > 0) op = Math.min(op, clamp01(tb / 0.25));
          op = Math.min(op, 1 - clamp01((tb - T.fade_out_start) / (BEAT_DUR - T.fade_out_start)));
          el.style.opacity = op;
      
          const bigIn = easeOut(clamp01(tb / T.prompt_in));
          const bigOut = clamp01((tb - T.prompt_out) / 0.3);
          const big = el.querySelector(".prompt-big");
          big.style.opacity = bigIn * (1 - bigOut);
          big.style.transform = `translateX(-50%) translateY(${12 * (1 - bigIn) - 26 * easeOut(bigOut)}px)`;
          el.querySelector(".eyebrow").style.opacity = bigIn * (1 - bigOut);
          el.querySelector(".prompt-top").style.opacity = 0.85 * clamp01((tb - T.panel_in) / 0.3);
          el.querySelector(".beat-tag").style.opacity = clamp01((tb - T.panel_in) / 0.3);
      
          b.cells.forEach((c, ci) => {
            const p = document.getElementById(`p${bi}-${ci}`);
            const start = T.panel_in + ci * T.panel_stagger;
            const k = easeOut(clamp01((tb - start) / T.panel_anim));
            p.style.opacity = k;
            p.style.transform = `translateY(${44 * (1 - k)}px) scale(${0.96 + 0.04 * k})`;
            if (c.is_video) waits.push(seekVideo(p.querySelector("video"), tb - start));
          });
        });
      
        const te = t - END_START;
        endcard.style.opacity = te >= 0 ? clamp01(te / 0.3) : 0;
        if (te >= 0) {
          endcard.querySelector(".l1").style.opacity = clamp01((te - 0.15) / 0.35);
          endcard.querySelector(".l2").style.opacity = clamp01((te - 0.45) / 0.35);
          const k = easeOut(clamp01((te - 0.15) / 0.5));
          endcard.querySelector(".l1").style.transform = `translateY(${14 * (1 - k)}px)`;
        }
        return Promise.all(waits);
      };
      window.renderAt(0);
      </script>
      </body>
      </html>
      """
      
      DEFAULT_TIMING = {"prompt_in": 0.35, "prompt_out": 1.15, "panel_in": 1.3,
                        "panel_stagger": 0.15, "panel_anim": 0.38, "fade_out_start": 4.2}
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("--config", required=True)
          ap.add_argument("--output", required=True)
          args = ap.parse_args()
      
          cfg = json.loads(Path(args.config).read_text())
          canvas = cfg.get("canvas", {})
          W, H = canvas.get("width", 1280), canvas.get("height", 720)
          cfg["beat_seconds"] = cfg.get("beat_seconds", 4.5)
          cfg["timing"] = {**DEFAULT_TIMING, **cfg.get("timing", {})}
          ncols = len(cfg["columns"])
          assert 2 <= ncols <= 4, "columns must be 2-4"
      
          aw, ah = (int(x) for x in cfg.get("cell_aspect", "3:4").split(":"))
          gap, margin = 26, 60
          cell_h = round(H * 0.622)
          cell_w = round(cell_h * aw / ah)
          max_w = (W - 2 * margin - (ncols - 1) * gap) // ncols
          if cell_w > max_w:
              cell_w = max_w
              cell_h = round(cell_w * ah / aw)
          row_top = round(H * 0.17)
      
          out_dir = Path(args.output).resolve().parent
          for b in cfg["beats"]:
              assert len(b["cells"]) == ncols, f"beat '{b.get('tag')}' needs {ncols} cells"
              resolved = []
              for c in b["cells"]:
                  p = Path(c)
                  if not p.is_absolute():
                      p = (Path(args.config).resolve().parent / p)
                  assert p.exists(), f"cell media missing: {p}"
                  ext = p.suffix.lower()
                  assert ext in VIDEO_EXT | IMAGE_EXT, f"unsupported media: {p}"
                  resolved.append({"src": os.path.relpath(p, out_dir), "is_video": ext in VIDEO_EXT})
              b["cells"] = resolved
      
          html = (TEMPLATE
                  .replace("__W__", str(W)).replace("__H__", str(H))
                  .replace("__EYEBROW_TOP__", str(round(H * 0.35)))
                  .replace("__PROMPT_TOP__", str(round(H * 0.417)))
                  .replace("__PROMPT_W__", str(round(W * 0.72)))
                  .replace("__TOPSTRIP_W__", str(round(W * 0.84)))
                  .replace("__ROW_TOP__", str(row_top))
                  .replace("__GAP__", str(gap))
                  .replace("__CELL_W__", str(cell_w)).replace("__CELL_H__", str(cell_h))
                  .replace("__EC1_TOP__", str(round(H * 0.372)))
                  .replace("__EC2_TOP__", str(round(H * 0.483)))
                  .replace("__CFG_JSON__", json.dumps(cfg)))
          Path(args.output).write_text(html)
      
          total = len(cfg["beats"]) * cfg["beat_seconds"] + cfg["endcard"].get("seconds", 2.5)
          print(f"Wrote {args.output}")
          print(f"Total duration: {total:.1f}s  ({len(cfg['beats'])} beats x {cfg['beat_seconds']}s "
                f"+ {cfg['endcard'].get('seconds', 2.5)}s end card)  cells {cell_w}x{cell_h}")
      
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 3.7 KB
      {
        "_comment": "This IS the shipped worked example: the 3-model image comparison (2026-07-08). Cell paths point at the source project; re-point them at your own media. Cells accept images (.png/.jpg/.webp) AND muted video clips (.mp4/.mov/.webm) interchangeably.",
        "canvas": { "width": 1280, "height": 720, "fps": 30 },
        "beat_seconds": 4.5,
        "cell_aspect": "3:4",
        "columns": [
          { "label": "GPT IMAGE 2" },
          { "label": "NANO BANANA PRO" },
          { "label": "SEEDREAM 5.0 PRO" }
        ],
        "beats": [
          {
            "tag": "01 — TYPOGRAPHY",
            "prompt": "Art-deco travel poster for Istanbul — gold lettering, 'Orient Express · Est. 1883', 1930s lithograph texture.",
            "cells": [
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat1-typography__gpt-image-2.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat1-typography__nano-banana-pro.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat1-typography__seedream-5-pro.png"
            ]
          },
          {
            "tag": "02 — PHOTOREALISM",
            "prompt": "Candid photo of a woman in her 60s laughing at a market stall, holding an orange. 85mm, no retouching.",
            "cells": [
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat2-photoreal__gpt-image-2.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat2-photoreal__nano-banana-pro.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat2-photoreal__seedream-5-pro.png"
            ]
          },
          {
            "tag": "03 — PRODUCT",
            "prompt": "Amber glass dropper bottle on wet slate, label 'MORNING RITUAL · Vitamin C · 30ml', premium ad photography.",
            "cells": [
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat3-product__gpt-image-2.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat3-product__nano-banana-pro.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat3-product__seedream-5-pro.png"
            ]
          },
          {
            "tag": "04 — ILLUSTRATION",
            "prompt": "Ukiyo-e woodblock print of a modern subway platform, commuters on smartphones, in the style of Hiroshige.",
            "cells": [
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat4-illustration__gpt-image-2.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat4-illustration__nano-banana-pro.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat4-illustration__seedream-5-pro.png"
            ]
          },
          {
            "tag": "05 — PROMPT ADHERENCE",
            "prompt": "Bassist in red, trumpeter in white, drummer in green. Neon sign 'BLUE ROOM'. A black cat on the unplayed piano.",
            "cells": [
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat5-adherence__gpt-image-2.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat5-adherence__nano-banana-pro.png",
              "../../../internal/experiments/image-model-triptych-comparison/generated/beat5-adherence__seedream-5-pro.png"
            ]
          }
        ],
        "endcard": {
          "headline": "Same prompt. Three models.",
          "subline": "GPT IMAGE 2 · NANO BANANA PRO · SEEDREAM 5.0 PRO",
          "seconds": 2.5
        },
        "music": {
          "enabled": true,
          "prompt": "Minimal dark electronic music bed for a sleek AI product showcase video. Slow-pulsing analog synth bass, soft airy pads, sparse clicky percussion around 100 BPM, understated and modern, gradually building subtle anticipation, gentle resolve at the end. Instrumental only, no vocals, no prominent melody hook, clean ending. 25 seconds."
        }
      }
      
    • render_seekable_hyperframe.py 2.5 KB
      #!/usr/bin/env python3
      """Render a seekable HTML hyperframe to MP4 (video-cell aware).
      
      The HTML must expose:
        window.renderAt(seconds) -> may return a Promise (awaited per frame; this is
                                    how <video> cells seek deterministically)
        window.mediaReady()      -> optional Promise; awaited once before the frame loop
      
      Usage:
        python3 render_seekable_hyperframe.py <input.html> <out.mp4> <duration> \
            [--fps 30] [--width 1280] [--height 720]
      """
      import argparse
      import os
      import subprocess
      import tempfile
      from pathlib import Path
      
      from playwright.sync_api import sync_playwright
      
      
      def render(html_path, out_mp4, duration, fps, width, height):
          html_path = os.path.abspath(html_path)
          out_mp4 = os.path.abspath(out_mp4)
          tmp = Path(tempfile.mkdtemp(prefix="cmpgrid_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")
              page.evaluate("document.fonts && document.fonts.ready")
              # Wait for all <video>/<img> cells to be decodable before frame-stepping.
              page.evaluate("window.mediaReady ? window.mediaReady() : null")
      
              n_frames = int(round(duration * fps))
              for i in range(n_frames):
                  t = i / fps
                  # page.evaluate awaits a returned Promise, so video seeks settle
                  # before the screenshot.
                  page.evaluate("(t) => window.renderAt(t)", t)
                  page.screenshot(path=str(frames_dir / f"frame_{i:05d}.png"), full_page=False)
      
              browser.close()
      
          subprocess.run(
              ["ffmpeg", "-y", "-loglevel", "warning",
               "-framerate", str(fps), "-i", str(frames_dir / "frame_%05d.png"),
               "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
               out_mp4],
              check=True,
          )
          print(f"Rendered {out_mp4} ({duration}s @ {fps}fps, {width}x{height})")
      
      
      if __name__ == "__main__":
          ap = argparse.ArgumentParser()
          ap.add_argument("html")
          ap.add_argument("out_mp4")
          ap.add_argument("duration", type=float)
          ap.add_argument("--fps", type=int, default=30)
          ap.add_argument("--width", type=int, default=1280)
          ap.add_argument("--height", type=int, default=720)
          a = ap.parse_args()
          render(a.html, a.out_mp4, a.duration, a.fps, a.width, a.height)
      
  • tests
    • smoke-test.md 1 KB
      # Smoke Test
      
      build_composition.py --config config.json --output hyperframe.html ; render_seekable_hyperframe.py hyperframe.html master-silent.mp4 <duration> --fps 30 --width 1280 --height 720 — dark stage, N-panel comparison grid, deterministic, $0.
      
      Pass when:
      - build_composition.py validates every cell path + the column count (2-4) and prints the total duration; render_seekable_hyperframe.py runs to a valid mp4 at the config canvas/fps/duration;
      - per beat: the monospace prompt fades in centered and is fully readable before the panels enter, then docks to the top strip; the 2-4 labeled panels stagger in with the correct label under each column (same order every beat); the end card has NO stats line;
      - video cells advance (sample two frames ≥0.5s apart inside one hold — the clip is frame-seeked, not a frozen first frame);
      - all state is computed in renderAt(t) (no CSS animation-delay), so scrubbing never traps a pre-state;
      - no paid provider host is called from this capability (cell images/clips + music are separate media capabilities).
      
  • SKILL.md 3.5 KB
    ---
    name: render-model-comparison-grid
    description: Render a 'model comparison grid' video from a config — a fal-style "same prompt, N contenders" showcase — a dark real-DOM stage where per beat a monospace prompt fades in centered, docks to a small top strip, then a labeled 2-4 panel grid (static images OR muted video clips, mixable per cell) staggers in and holds for comparison, plus a minimal end card — frame-stepped via Playwright (video cells are frame-seeked deterministically) and encoded with FFmpeg. Deterministic assembly, FREE (cell media comes from create-image-fal / create-video-fal, music from create-music-elevenlabs), text stays pixel-crisp. Use for the model-comparison-grid format.
    status: active
    ---
    
    # render-model-comparison-grid
    
    Render the 'model comparison grid' format from a config. The signature of this format is a
    **"Same prompt. N models."** gauntlet: a dark stage where, per beat, a `PROMPT` eyebrow +
    the (condensed) prompt **fades in** centered in monospace and holds readable ~0.8s, then
    docks to a small top strip while a **grid of 2-4 labeled panels** staggers in (0.15s apart)
    and holds for side-by-side comparison. A persistent model/variant label sits under each
    panel; column order is identical on every beat. Ends on a minimal end card (headline +
    column names only — **no meta-stats line**).
    
    The grid is **media-agnostic per cell**: any cell is a static image or a **muted video
    clip** (i2v outputs, screen recordings), mixable within one beat. Video cells loop during
    the hold and are **frame-seeked deterministically** (the renderer awaits each seek), so the
    render never depends on wall-clock playback timing.
    
    The renderer itself is FREE/deterministic (Playwright frame-step + FFmpeg). The paid inputs
    are separate capabilities: the cell **images** come from `create-image-fal`, the cell
    **clips** from `create-video-fal`, and the **music bed** from `create-music-elevenlabs`.
    Prompt text and labels are real DOM — never AI-rendered.
    
    Default shape: 5 beats × 4.5s + 2.5s end card = 25.0s @ 1280×720/30fps, all configurable
    from one `config.json`.
    
    ## Run
    build_composition.py --config config.json --output hyperframe.html ; render_seekable_hyperframe.py hyperframe.html master-silent.mp4 <duration> --fps 30 --width 1280 --height 720 — dark stage, staggered grid, deterministic, $0. The config schema is documented at the top of `scripts/build_composition.py`; `scripts/config.example.json` IS the shipped worked example (re-point the cell paths at your own media).
    
    `build_composition.py` validates every cell path and the column count (2-4), infers each
    cell's media type from its extension (`.png/.jpg/.jpeg/.webp` → image; `.mp4/.mov/.webm/.m4v`
    → muted video), and emits a self-contained HTML that exposes `window.mediaReady()` +
    `window.renderAt(t)`. `render_seekable_hyperframe.py` awaits both, so `<video>` cells seek
    to the right frame before each screenshot — never a frozen first frame.
    
    ## Contract
    - Deterministic + FREE (Playwright frame-step + FFmpeg); no paid calls in this capability.
    - Columns = panels-per-beat (2-4); every beat supplies exactly that many cells, same order.
    - The template recipe (DB) supplies the config; cell images/clips + music are separate
      capabilities.
    - State is computed entirely in `renderAt(t)` — never CSS `animation-delay`/transitions
      (Playwright scrubbing traps delayed animations in pre-state).
    - Video cells must decode in the render Chromium (H.264 yes, ProRes no — transcode `.mov`
      ProRes to H.264 first). An images-only grid has no decode dependency.
    
  • skill.meta.json 333 B
    {
      "slug": "render-model-comparison-grid",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-model-comparison-grid",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related