CtrlK
BlogDocsLog inGet started
Tessl Logo

gamussa/reels-producer-skill

Write talking-head scripts and produce Instagram reels and YouTube shorts

94

2.36x
Quality

97%

Does it follow best practices?

Impact

85%

2.36x

Average score across 3 eval scenarios

SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

make_cards.pyskills/reel-builder/scripts/

#!/usr/bin/env python3
"""Render headline / logo cards for talking-head b-roll, via Remotion.

Talking-head b-roll is usually cards and screen recordings rather than footage,
and a static card held for three seconds reads as dead air. These render as
real motion clips at mezzanine spec, so the cut plan treats them like any other
segment.

Requires Node 18+. The first run installs Remotion into the project (a few
hundred MB including a headless Chrome), so it is slow once and fast after.
The template is copied out of the plugin into <outdir>/_project first —
node_modules never lands inside the installed skill.

Input JSON (a list, or {"cards": [...]}):
  [
    {"name": "acquisition", "title": "Garmin bought TrainingPeaks",
     "kicker": "July 2026", "subtitle": "The neutral ground changed hands",
     "duration": 3.0, "bg": "#0F1D2B", "accent": "#F26B21", "logo": "garmin.png"}
  ]

Only "title" is required. "logo" is a filename inside <outdir>/_project/public.

Usage:
  make_cards.py cards.json --outdir work/cards
  make_cards.py cards.json --outdir work/cards --resolution 4k
"""
import argparse, json, shutil, subprocess, sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
from render_reel import RESOLUTIONS       # noqa: E402
import card_sources                       # noqa: E402

TEMPLATE = Path(__file__).resolve().parent.parent / "assets/remotion-cards"
FPS = 30
DEFAULTS = {"kicker": "", "subtitle": "", "duration": 3.0,
            "bg": "#0F1D2B", "accent": "#F26B21", "fg": "#FFFFFF", "logo": ""}


def require_node():
    if not shutil.which("node"):
        sys.exit("Node not found — install Node 18+ (brew install node) or "
                 "build cards as stills and let normalize_clips.py apply Ken "
                 "Burns motion instead.")


def restore_components(proj):
    """Write any carried component the packaged template is missing.

    The registry ships .ts and drops .tsx, so Card.tsx and Root.tsx are absent
    from every installed copy and `npx remotion render Card` has no Card
    composition to render. card_sources.py carries them as data — a .py file
    ships — and they are written back here.

    Returns the names restored; empty when running from a repo checkout, where
    the real .tsx files are present and win.
    """
    written = []
    for name, text in card_sources.FILES.items():
        dst = proj / "src" / name
        if dst.exists():
            continue
        dst.parent.mkdir(parents=True, exist_ok=True)
        dst.write_text(text)
        written.append(name)
    return written


def ensure_project(outdir):
    """Copy the template out of the plugin and install deps once."""
    proj = outdir / "_project"
    if not proj.exists():
        shutil.copytree(TEMPLATE, proj)
        (proj / "public").mkdir(exist_ok=True)
    restored = restore_components(proj)
    if restored:
        print(f"Restored {', '.join(restored)} (dropped by packaging)",
              file=sys.stderr)
    if not (proj / "node_modules").exists():
        print("Installing Remotion (first run only, several minutes)...",
              flush=True)
        r = subprocess.run(["npm", "install", "--no-audit", "--no-fund"],
                           cwd=proj, capture_output=True, text=True)
        if r.returncode != 0:
            sys.exit(f"npm install failed:\n{r.stderr[-1200:]}")
    return proj


def render_card(proj, card, dims, outdir):
    name = card.get("name") or card["title"][:40].strip().replace(" ", "_")
    dst = outdir / f"{name}.mp4"
    props = {**DEFAULTS, **{k: v for k, v in card.items() if k != "name"}}
    duration = float(props.pop("duration"))
    frames = max(1, round(duration * FPS))
    w, h = dims
    cmd = ["npx", "remotion", "render", "Card", str(dst.resolve()),
           "--props", json.dumps(props),
           "--width", str(w), "--height", str(h),
           "--frames", f"0-{frames - 1}",
           "--log", "error"]
    r = subprocess.run(cmd, cwd=proj, capture_output=True, text=True)
    if r.returncode != 0:
        print(f"  FAILED {name}: {r.stderr.strip()[-500:]}", file=sys.stderr)
        return None
    return dst


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("cards_json")
    ap.add_argument("--outdir", default="work/cards")
    ap.add_argument("--resolution", choices=sorted(RESOLUTIONS), default="1080",
                    help="Match the mezzanine the project was normalized at.")
    args = ap.parse_args()

    require_node()
    spec = json.loads(Path(args.cards_json).read_text())
    cards = spec["cards"] if isinstance(spec, dict) else spec
    if not cards:
        sys.exit("No cards in the spec — nothing to render.")
    for c in cards:
        if not c.get("title"):
            sys.exit(f"Every card needs a 'title': {json.dumps(c)[:120]}")

    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)
    proj = ensure_project(outdir)
    dims = RESOLUTIONS[args.resolution]

    done, failed = [], []
    for c in cards:
        print(f"Rendering card: {c['title'][:60]}...", flush=True)
        out = render_card(proj, c, dims, outdir)
        (done if out else failed).append(c.get("name") or c["title"])

    print(json.dumps({"rendered": len(done), "failed": failed,
                      "outdir": str(outdir),
                      "resolution": f"{dims[0]}x{dims[1]}"}, indent=2))
    if failed:
        sys.exit(1)
    print(f"\nAdd these to raw/ (or reference directly) and probe them — they "
          f"are ordinary {dims[0]}x{dims[1]} clips from here on.",
          file=sys.stderr)


if __name__ == "__main__":
    main()

.mcp.json

tile.json