Skip to content

Sizzle Reel Kit: How an Agent Makes a 30-Second Feature Video

Agent instructions for a 30-second Salesforce feature sizzle reel: the full kit source, how to run it, and the writing rubric.

• Matt Dennis

This post is for you, the agent. You have been asked to make a 30-second, 9:16 feature reel: voiceover, music, and sound effects, from a JSON spec. The pictures are mock UI rendered from reel.html. You write the spec. build.py does the rest.


Everything you need is in this post. There is no repo to clone. Create a directory named sizzle-reel-kit and write each file in Kit files exactly as shown. The writing rubric is below, verbatim. A worked reel for Opportunity.Line_of_Business__c is the spec examples/lob.json.


Get the kit


Create this layout. Paths are relative to sizzle-reel-kit/:


sizzle-reel-kit/
  .gitignore
  README.md
  GUIDELINE.md
  setup.sh
  synth.py
  build.py
  reel.html
  examples/lob.json

GUIDELINE.md is the rubric section. The other seven files are under Kit files. Copy each fenced body with no extra wrapping. setup.sh must be executable (chmod +x setup.sh).


build.py imports synth.py from the same directory. It serves reel.html from that directory over a local HTTP server, then points a headless browser at it. Put new specs in examples/ so the output directory stays inside the kit. The server root is the kit directory, and a spec outside it will not load.


Run it


Run these from the kit directory. setup.sh is once per machine. It installs agent-browser, kokoro-onnx, soundfile, numpy, and imageio-ffmpeg, and downloads the Kokoro model (~340 MB) from GitHub releases into ~/.cache/sizzle-kit (override with KOKORO_DIR). Hugging Face is blocked by the agent proxy, which is why the model comes from GitHub.


bash setup.sh
cp examples/lob.json examples/my-feature.json
# edit examples/my-feature.json. start from this copy.
python3 build.py examples/my-feature.json --no-record
python3 build.py examples/my-feature.json

--no-record finishes in seconds. It writes voice clips, examples/out-my-feature/spec.resolved.json, and examples/out-my-feature/audio.wav, and prints a timeline:


timeline: [('pov', 0.0, 4.0), ('title', 4.0, 8.0), ...] total 30.0

If the voiceover is too long, stderr prints !! script is Ns over target 30s — trim VO lines or drop a scene. Cut words. Do not raise the voice speed to hide a long script.


The full command takes about five minutes. It records at 0.25× speed, recovers real time from the white progress bar in each frame, resamples to 30 fps, and muxes audio. Output:


  • examples/out-my-feature/my-feature.mp4
  • examples/out-my-feature/contact.png (8 frames, one image)

Review contact.png only. Do not open the individual frames or the video. Fix the spec, rerun. Send the MP4 to the person who asked. Do not commit out-*/ or any MP4. The kit .gitignore already excludes them. Commit the spec JSON once the reel is approved.


setup.sh checks that Chromium exists at /opt/pw-browsers/chromium-*/chrome-linux/chrome. Cloud agent VMs have that browser. Do not run playwright install. On a machine without that path, set AGENT_BROWSER_EXECUTABLE_PATH to a Chrome or Chromium binary before build.py. build.py puts imageio-ffmpeg’s ffmpeg on PATH for agent-browser. Leave the #bar element in reel.html. The retimer reads it.


Token budget for one reel: read examples/lob.json once, write one new spec, look at one contact sheet per iteration.


Rubric


This is GUIDELINE.md from the kit, verbatim. It is the rubric for the spec: story beats, word budgets, field names, and the failures already paid for. Write this exact text to GUIDELINE.md. Score the spec against it before you record.


# Feature sizzle reel — guideline

30 s, 9:16, voiceover + music, from a ~50-line JSON spec. You write the spec; `build.py` does everything else.
Worked example: `examples/lob.json` (Opportunity.Line_of_Business__c) builds to a 30 s reel — run it once to see the output shape.

## Make one (cheap path)

```bash
bash setup.sh                                   # once per VM: agent-browser, Kokoro TTS + model, ffmpeg (~3 min)
cp examples/lob.json my-feature.json            # edit copy; never start from scratch
python3 build.py my-feature.json --no-record    # seconds: TTS + timeline + audio. Prints scene times and any "over target" warning
python3 build.py my-feature.json                # ~5 min wall (slow-mo recording). Output: out-my-feature/my-feature.mp4 + contact.png
```

Review with **`contact.png` only** (one image, 8 scene frames). Don't view individual frames or the video. Fix, rerun.
Token budget for a whole reel: read the LOB spec once, write one new spec, view one contact sheet per iteration.

## Story: 7 beats, ~4 s each

| # | Scene type | Job | VO words |
|---|---|---|---|
| 1 | `pov` | **Hook.** The pain the user already feels, shown as mock UI + an error/toast. Real error text > invented copy. | ≤12 |
| 2 | `title` | **Reveal.** "Not X. It's <feature>." Name + API name on a card. | ≤10 |
| 3 | `slams` | **What you get.** One full-screen color flip per option/benefit (2–4 items, ≥1 s each, 1 VO line each). | 3–6 each |
| 4 | `ladder` | **The one rule to remember** (priority, tiebreaker, default). | ≤14 |
| 5 | `versus` | **Gotcha / how it differs** from the thing people will confuse it with. `stamp` for the guardrail. | ≤18 |
| 6 | `pips` | **When/where it applies** (stages, steps, rollout). | ≤10 |
| 7 | `cta` | **Do the thing.** Picklist/button demo + confetti + field/route name. Short: "So, pick your L O B." | ≤6 |

Drop scenes you don't need (min 4); the builder stretches the rest to `target`. Reorder freely; keep `pov` first and `cta` last.

## Writing rules (this is what makes it engaging)

- **Open on pain, not on the feature.** The first second is a problem the viewer has had. Feature name arrives at ~4 s.
- **One idea per scene, one sentence of VO.** ~2.5 words/s → **65–75 words total for 30 s**. Builder warns if over; cut words, don't speed up.
- **Captions carry the joke/hook** (`cap`, `<b>` = yellow highlight). Short, lowercase-feeling, never a transcript of the VO. Keep under 6 words.
- **Only claim what you verified** in the metadata/org (values, validation messages, required stages). Quote real labels and error strings.
- **Fictional demo data** (Acme Health…), no real customers, no numbers unless you queried them.
- **Gender-neutral / no hype filler** ("revolutionary", "game-changing"). Specific beats loud.
- **TTS pronunciation:** spell acronyms with spaces in `vo` only (`A S O`, `P E O`, `L O B`); on-screen text stays normal. Numbers as words ("Stage one"). Put a period after a single word for a beat ("Medicaid.").
- End on the **action + the API/field name** so someone can screenshot it.

## Spec reference (`spec.json`)

Top: `target` (s, default 30), `voice` `{name:"af_heart", speed:1.08}`, `theme.accent`. Every scene: `type`, `vo`, optional `cap` (string or list, split evenly), optional `bg:["#c1","#c2"]`.

| type | fields |
|---|---|
| `pov` | `pill`, `text` (html), `card{icon,kicker,title,stages[],active,fields[[k,v]],button}`, `toast{title,text}` |
| `title` | `lines[]` (html, big stacked), optional `card{tag,kicker,title,chip}` |
| `slams` | `items[{word,emoji,sub,c1,c2,fs,vo,cap}]` — **vo/cap live on the items, not the scene** |
| `ladder` | `head[]`, `kicker`, `top`, `bottom`, `note` (leave out if a caption is showing) |
| `versus` | `left{title,sub}`, `right{title,sub}`, `symbol`, `stamp`, `note` — **not `a`/`b`** (reserved for scene times) |
| `pips` | `head`, `pill`, `steps[]` (≤8), `foot`, `big` |
| `cta` | `head`, `label`, `options[]`, `pick` (index), `chip` |

Timing is automatic: scene length = VO length snapped to the 0.5 s beat grid (120 BPM), leftover beats go to the last scene first. Music/SFX hits are generated from the same events, so picture, VO and audio stay locked.
New visual? Add a template to `T` in `reel.html` (each is ~10 lines, returns `update(lt)`) and an event in `timeline()`/`audio()` in `build.py`.

## Gotchas we already paid for

- **Never use `filter: blur()`** in `reel.html` — software rendering drops to 11 fps. Radial gradients look the same.
- `agent-browser record` writes a fixed **10 fps** and drops frames under load. The builder records at 0.25× and reads the progress bar (t/DUR) in each frame to recover real time, then resamples to 30 fps. Don't remove `#bar`.
- `agent-browser` shells out to `ffmpeg` — it must be on PATH (builder handles it via `imageio-ffmpeg`). Don't run `playwright install`.
- Don't put inline `top:` on `.abs` elements — positions are `translate` offsets from screen center (540, 960). Safe zone: keep key content between y≈200 and y≈1500; captions sit at y 1460.
- Captions wrap at 980 px; ~28 chars/line at 74 px. Don't put content at y > 400 (from center) in a scene that has a caption.
- ffmpeg can't crop 1 px high from yuv420 — convert to rgb24 first (already done).
- Don't `pkill -f <pattern>` from the same shell command that contains the pattern; it kills the shell.
- Kokoro model (~340 MB) comes from GitHub releases; Hugging Face is blocked by the agent proxy.

## Output & placement

Everything lands in `out-<name>/` next to the spec (gitignored by the kit's `.gitignore`; never commit MP4s). Deliver the MP4 with `SendUserFile`. Put new specs in `examples/`; commit only the spec.

Kit files


Each heading is the path relative to the kit root. Write the fenced body to that path. GUIDELINE.md is the rubric section above. setup.sh must be executable.


.gitignore


out-*/
__pycache__/

README.md


# Sizzle reel kit

Turns a ~50-line JSON spec into a 30-second, 9:16 launch video (voiceover, music, sound effects) for a Salesforce feature we just shipped. Built for TikTok / Reels / Shorts-style posting. You write the spec; `build.py` does the rest.

[`GUIDELINE.md`](GUIDELINE.md) is the authority on how to write a spec: the 7-beat story, word budgets, pronunciation rules, and the gotchas. Read it before writing one.

## What it should do

1. Take a feature that shipped (a field, a flow, a button) and tell it as a 30 s story that opens on the pain, not the feature.
2. Use only facts checked in the metadata or the org: real labels, real error text, real API names. Demo data is fictional.
3. End on the action and the API/field name so a viewer can screenshot it.
4. Produce an MP4 and a one-image contact sheet so a reviewer can check a reel without watching it.

It is not a screen recorder of the real org. Every scene is a mock UI rendered from `reel.html`.

## Quick start

```bash
bash setup.sh                                   # once per VM: agent-browser, Kokoro TTS + model, ffmpeg (~3 min)
cp examples/lob.json examples/my-feature.json   # copy the worked example; never start from scratch
python3 build.py examples/my-feature.json --no-record   # seconds: TTS, timeline, audio. Prints scene times and any "over target" warning
python3 build.py examples/my-feature.json               # ~5 min: slow-mo recording. Writes examples/out-my-feature/
```

Review `out-my-feature/contact.png` (8 scene frames), fix the spec, rerun. Send the MP4 to the requester; don't commit it.

## Files

| File | Job |
|---|---|
| `GUIDELINE.md` | How to write a spec. Story beats, writing rules, spec reference, gotchas |
| `build.py` | Spec to MP4: TTS, scene timing on a 0.5 s beat grid, audio mix, recording, frame-time recovery, mux |
| `reel.html` | Scene templates (`pov`, `title`, `slams`, `ladder`, `versus`, `pips`, `cta`) rendered in headless Chromium |
| `synth.py` | Small numpy synth for drums, bass, risers, impacts; music and SFX are generated from scene events |
| `setup.sh` | One-time install: `agent-browser`, `kokoro-onnx`, ffmpeg, Kokoro model files |
| `examples/lob.json` | Worked example (`Opportunity.Line_of_Business__c`) |

## Output and what to commit

- Build output lands in `out-<name>/` next to the spec. The kit's `.gitignore` excludes `out-*/`.
- Commit the spec (`examples/<name>.json`) once the reel is approved. Never commit MP4s.

## Requirements

- Python 3 with `kokoro-onnx`, `soundfile`, `numpy`, `imageio-ffmpeg` (installed by `setup.sh`).
- Node with `agent-browser`; Chromium at `/opt/pw-browsers` (pre-installed in cloud sessions; don't run `playwright install`).
- Network through the agent proxy for the Kokoro model download. The model comes from GitHub releases because Hugging Face is blocked.

examples/lob.json


Worked spec for Opportunity Line of Business. Copy this file and edit the copy. Do not start from an empty JSON object.


{
  "target": 30,
  "voice": {"name": "af_heart", "speed": 1.08},
  "theme": {"accent": "#ffe14d"},
  "scenes": [
    {"type": "pov", "vo": "Dragged a channel deal to Stage one? Save says no.",
     "pill": "POV", "text": "you moved a channel deal<br>to <b>Stage 1</b>…",
     "card": {"icon": "👑", "kicker": "Opportunity", "title": "Acme Health – New Logo", "stages": ["Stage 0", "Stage 1", "Stage 2", "Stage 3"], "active": 1,
              "fields": [["Channel Influenced", "Aetna"], ["Line of Business", "—"]], "button": "Save"},
     "toast": {"title": "Review the errors on this page.", "text": "Line of Business is required from Stage 1 onward on channel-influenced deals"},
     "cap": "when <b>Save</b> says <b>NO</b>"},
    {"type": "title", "vo": "Not a bug. It's the new Line of Business field.",
     "lines": ["THAT ERROR?", "NOT A <span style='background:#000;padding:0 16px;border-radius:14px'>BUG.</span>"],
     "card": {"tag": "NEW FIELD DROPPED", "kicker": "Opportunity", "title": "Line of<br>Business", "chip": "Opportunity.Line_of_Business__c"},
     "cap": ["wait… that <b>error</b>?", "it's a <b>feature</b>"]},
    {"type": "slams", "items": [
      {"word": "ASO", "emoji": "🏢", "sub": "SELF-FUNDED", "c1": "#1f6bff", "c2": "#0b2a8a", "fs": 360, "vo": "A S O. Self-funded.", "cap": "employer bears the <b>claims risk</b>"},
      {"word": "FI", "emoji": "🛡️", "sub": "FULLY INSURED", "c1": "#8a3dff", "c2": "#3a0f8a", "fs": 460, "vo": "F I. Fully insured.", "cap": "payer bears the <b>claims risk</b>"},
      {"word": "PEO", "emoji": "🤝", "sub": "SOLD VIA A PEO", "c1": "#ff7a1a", "c2": "#8a2a00", "fs": 360, "vo": "P E O.", "cap": "sold through a <b>PEO</b>?"},
      {"word": "Medicaid", "emoji": "🏥", "sub": "MEDICAID DEAL", "c1": "#12b981", "c2": "#04513a", "fs": 210, "vo": "Medicaid.", "cap": "Medicaid? <b>pick it</b>"}]},
    {"type": "ladder", "vo": "Medicaid or P E O? They outrank funding. Pick those first.",
     "head": ["MEDICAID", "<span style='color:#ffe14d'>or PEO?</span>"], "kicker": "THEY OUTRANK FUNDING",
     "top": "👑 MEDICAID · PEO", "bottom": "ASO · FI", "cap": "pick the top tier <b>first</b>"},
    {"type": "versus", "vo": "Different from P G Line of Business, which picks your templates. Mismatch? Blocked.",
     "left": {"title": "PG LINE OF BUSINESS", "sub": "Picks your <b style='color:#fff'>PG templates</b>"},
     "right": {"title": "LINE OF BUSINESS", "sub": "How we <b style='color:#fff'>report the deal</b>"},
     "symbol": "≠", "stamp": "BLOCKED", "note": "real exception? there’s an override for that",
     "cap": ["two fields. <b>two jobs.</b>", "mismatch = <b>blocked</b>"]},
    {"type": "pips", "vo": "Required from Stage one, on channel-influenced deals.",
     "head": "STAGE 1<br>→ CLOSED WON", "pill": "Line of Business: <span style='background:#1f6bff;padding:4px 22px;border-radius:14px'>ASO</span>",
     "steps": ["S1", "S2", "S3", "S4", "S5", "S6", "CW"], "foot": "on channel-influenced deals", "big": "SET IT BY<br><b>STAGE 1.</b>"},
    {"type": "cta", "vo": "So, pick your L O B.",
     "head": "PICK YOUR<br><b>LOB.</b>", "label": "Line of Business", "options": ["ASO", "FI", "PEO", "Medicaid"], "pick": 0,
     "chip": "Opportunity.Line_of_Business__c"}
  ]
}

setup.sh


#!/usr/bin/env bash
# One-time setup for the sizzle-reel kit (idempotent). Needs: node/npm, python3, pip, network via the agent proxy.
set -euo pipefail
CACHE="${KOKORO_DIR:-$HOME/.cache/sizzle-kit}"; mkdir -p "$CACHE"
command -v agent-browser >/dev/null || npm install -g agent-browser
pip install -q kokoro-onnx soundfile numpy imageio-ffmpeg 2>&1 | grep -v WARNING || true
REL=https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0
[ -s "$CACHE/voices-v1.0.bin" ]  || curl -sSL -m 300 -o "$CACHE/voices-v1.0.bin"  "$REL/voices-v1.0.bin"
[ -s "$CACHE/kokoro-v1.0.onnx" ] || curl -sSL -m 600 -o "$CACHE/kokoro-v1.0.onnx" "$REL/kokoro-v1.0.onnx"
ls /opt/pw-browsers/chromium-*/chrome-linux/chrome >/dev/null   # Chromium is pre-installed; do NOT run playwright install
python3 -c "import kokoro_onnx, imageio_ffmpeg; print('kit ready:', imageio_ffmpeg.get_ffmpeg_exe())"

synth.py


"""Tiny numpy synth: drums, bass, stabs, bells, risers, impacts, buzzers. 44.1 kHz mono float arrays."""
import numpy as np

SR = 44100
rng = np.random.default_rng(11)


def _t(length):
    return np.arange(int(length * SR)) / SR


def kick(length=0.4):
    t = _t(length)
    ph = 2 * np.pi * np.cumsum(45 + 110 * np.exp(-t * 28)) / SR
    return np.sin(ph) * np.exp(-t * 9) * np.minimum(1, t / 0.002)


def clap(length=0.22):
    t = _t(length)
    x = rng.uniform(-1, 1, t.size)
    hp = x - 0.6 * np.concatenate(([0], x[:-1]))
    burst = ((t % 0.012) < 0.006) & (t < 0.036)
    return hp * (np.exp(-t * 22) * 0.8 + burst * 0.4)


def hat(length=0.07, open_=False):
    t = _t(length * (3 if open_ else 1))
    x = rng.uniform(-1, 1, t.size)
    return (x - np.concatenate(([0], x[:-1]))) * np.exp(-t * (28 if open_ else 95))


def tone(freq, length, shape='saw', decay=6.0):
    t = _t(length)
    p = 2 * np.pi * freq * t
    if shape == 'sine':
        v = np.sin(p)
    elif shape == 'saw':
        v = sum(np.sin(p * h) / h for h in range(1, 6)) * 0.55
    elif shape == 'sq':
        v = sum(np.sin(p * h) / h for h in (1, 3, 5, 7)) * 0.8
    else:  # bell
        v = np.sin(p) + 0.5 * np.sin(p * 2.76) * np.exp(-t * 6) + 0.25 * np.sin(p * 5.4) * np.exp(-t * 10)
    return v * np.minimum(1, t / 0.004) * np.exp(-t * decay)


def impact(length=1.0):
    t = _t(length)
    ph = 2 * np.pi * np.cumsum(38 + 60 * np.exp(-t * 6)) / SR
    x = rng.uniform(-1, 1, t.size)
    hp = x - 0.5 * np.concatenate(([0], x[:-1]))
    return np.sin(ph) * np.exp(-t * 3.2) * 1.2 + hp * np.exp(-t * 5) * 0.45


def riser(length):
    n = int(length * SR)
    u = np.arange(n) / n
    x = rng.uniform(-1, 1, n)
    hp = x - np.convolve(x, np.ones(6) / 6, mode='same')
    return hp * (u ** 2.2) * 0.9 + np.sin(2 * np.pi * np.cumsum(200 + 1800 * u * u) / SR) * 0.08 * u


def buzzer(length=0.32):
    t = _t(length)
    v = np.sign(np.sin(2 * np.pi * 110 * t)) * 0.5 + np.sign(np.sin(2 * np.pi * 117 * t)) * 0.5
    return v * np.minimum(1, (length - t) / 0.03) * 0.55


def blip(freq, length=0.14):
    return tone(freq, length, 'sine', decay=18)


def note(m):
    return 440.0 * 2 ** ((m - 69) / 12)


class Mix:
    def __init__(self, dur):
        self.n = int(dur * SR)
        self.buf = np.zeros(self.n)

    def add(self, t0, x, gain=1.0):
        i = int(t0 * SR)
        if i >= self.n or i + len(x) <= 0:
            return
        a = max(0, -i)
        seg = x[a:a + self.n - max(i, 0)]
        self.buf[max(i, 0):max(i, 0) + len(seg)] += seg * gain


ROOTS = [33, 29, 36, 31]                       # A1 F1 C2 G1, one per 2 s bar (Am F C G)
CHORDS = [(57, 60, 64), (53, 57, 60), (48, 52, 55), (55, 59, 62)]


def groove(m, a, b, hats=True, clap_on=True, bass=True, vol=1.0):
    K, C, H = kick(), clap(), hat()
    t = a
    while t < b - 1e-6:
        beat = round(t / 0.5)
        r = ROOTS[int(t // 2) % 4]
        m.add(t, K, .95 * vol)
        if clap_on and beat % 2 == 1:
            m.add(t, C, .5)
        if hats:
            m.add(t + .25, H, .35)
        if bass:
            m.add(t, tone(note(r), .24, 'saw', 9), .42)
            m.add(t + .25, tone(note(r + 12), .2, 'saw', 12), .28)
        t += 0.5


def arp(m, a, b, vol=.2, step=.125):
    t, i = a, 0
    while t < b - 1e-6:
        ch = CHORDS[int(t // 2) % 4]
        m.add(t, tone(note(ch[i % 3] + 12 * (1 + (i // 3) % 2)), .3, 'bell', 8), vol)
        t += step
        i += 1


def snare_roll(m, a, b, vol=.5):
    n = int(round((b - a) / 0.0625))
    for i in range(n):
        m.add(a + i * 0.0625, clap(.12), vol * (0.4 + 0.6 * i / max(1, n)))

build.py


#!/usr/bin/env python3
"""spec.json -> finished 9:16 MP4 with voiceover, music, SFX.   usage: build.py spec.json [--rate 0.25] [--no-record]

1 TTS each VO line (Kokoro)   2 scene lengths = VO length snapped to the 0.5 s beat grid, padded to spec.target
3 music/SFX cues from scene events, VO mixed on top with ducking   4 record reel.html with agent-browser at slow-mo
5 recover true frame times from the progress bar, resample to 30 fps   6 mux -> out/<name>.mp4 (+ contact.png)
"""
import glob, hashlib, json, math, os, shutil, subprocess, sys, tempfile, time, wave
import numpy as np
import synth as sy

KIT = os.path.dirname(os.path.abspath(__file__))
MODEL_DIR = os.environ.get('KOKORO_DIR', os.path.expanduser('~/.cache/sizzle-kit'))
MIN_DUR = dict(pov=2.5, title=2.5, ladder=3.5, versus=4.5, pips=3.0, cta=3.0)
BEAT = 0.5
snap = lambda x: math.ceil(x / BEAT - 1e-9) * BEAT


def sh(cmd, **kw):
    return subprocess.run(cmd, shell=isinstance(cmd, str), check=True, capture_output=True, text=True, **kw)


# ---------------------------------------------------------------- 1 TTS
def tts(spec, out):
    from kokoro_onnx import Kokoro
    k = Kokoro(f'{MODEL_DIR}/kokoro-v1.0.onnx', f'{MODEL_DIR}/voices-v1.0.bin')
    v = spec.get('voice', {})
    os.makedirs(f'{out}/vo', exist_ok=True)
    cache = {}

    def line(text):
        key = hashlib.md5(f"{text}|{v.get('name')}|{v.get('speed')}".encode()).hexdigest()[:12]
        p = f'{out}/vo/{key}.npy'
        if not os.path.exists(p):
            x, sr = k.create(text, voice=v.get('name', 'af_heart'), speed=v.get('speed', 1.08), lang='en-us')
            x = np.interp(np.arange(int(len(x) * sy.SR / sr)) / sy.SR, np.arange(len(x)) / sr, x)
            keep = np.where(np.abs(x) > 0.012)[0]                      # trim silence at both ends
            x = x[keep[0]:keep[-1] + 1] if keep.size else x
            np.save(p, x / (np.abs(x).max() or 1) * 0.92)
        return np.load(p)
    return line


# ---------------------------------------------------------------- 2 timeline
def timeline(spec, line):
    sc = spec['scenes']
    need, vo = [], {}
    for i, s in enumerate(sc):
        if s['type'] == 'slams':
            d = 0
            for j, it in enumerate(s['items']):
                x = line(it['vo']); vo[(i, j)] = x
                it['_d'] = max(1.0, snap(len(x) / sy.SR + .3)); d += it['_d']
            need.append(d)
        else:
            x = line(s['vo']); vo[(i, None)] = x
            need.append(max(MIN_DUR.get(s['type'], 2.5), snap(len(x) / sy.SR + .12 + .35)))
    extra = spec.get('target', 30) - sum(need)
    if extra < -1e-6:
        print(f'!! script is {-extra:.1f}s over target {spec.get("target", 30)}s — trim VO lines or drop a scene', file=sys.stderr)
    order = [len(sc) - 1] + list(range(len(sc) - 1))                      # extra beats: last scene first, then round-robin
    k = 0
    while extra >= BEAT - 1e-6:
        need[order[k % len(order)]] += BEAT; extra -= BEAT; k += 1
    t = 0.0
    for i, s in enumerate(sc):
        s['a'], s['b'] = t, t + need[i]; t += need[i]
        d = need[i]
        if s['type'] == 'slams':                                          # extra time goes to the last item
            s['items'][-1]['_d'] += need[i] - sum(it['_d'] for it in s['items'])
            o = 0
            for it in s['items']:
                it['t0'] = o; o += it['_d']
        ty, e = s['type'], {}
        if ty == 'pov': e['toast'] = min(.9, d * .4)
        if ty == 'title': e['card'] = max(1.0, snap(d * .45))
        if ty == 'ladder': e['top'] = 1.5
        if ty == 'versus': e['stamp'] = max(2.5, d - 2.0)
        if ty == 'pips': e['pips0'] = .5; e['big'] = max(1.6, d - 1.4)
        if ty == 'cta': e['pick'] = 1.3; e['chip'] = 1.9
        s['ev'] = e
    spec['dur'] = t
    caps = []                                                             # captions: per item for slams, else per scene (list = even split)
    for s in sc:
        if s['type'] == 'slams':
            for it in s['items']:
                if it.get('cap'): caps.append([s['a'] + it['t0'], s['a'] + it['t0'] + it['_d'], it['cap']])
        elif s.get('cap'):
            cs = s['cap'] if isinstance(s['cap'], list) else [s['cap']]
            w = (s['b'] - s['a']) / len(cs)
            caps += [[s['a'] + i * w, s['a'] + (i + 1) * w, c] for i, c in enumerate(cs)]
    spec['caps'] = caps
    return vo


# ---------------------------------------------------------------- 3 audio
def audio(spec, vo, path):
    dur = spec['dur'] + 0.4
    m, sc = sy.Mix(dur), spec['scenes']
    end = dur
    m.add(0, sy.kick(), .5)
    for t in np.arange(0, sc[0]['b'], .5): m.add(t, sy.kick(), .5)
    stamps = []
    for i, s in enumerate(sc):
        a, b, e, ty = s['a'], s['b'], s['ev'], s['type']
        if i: m.add(a, sy.impact(), .9)
        if i + 1 < len(sc): m.add(b - .65, sy.riser(.65), .7)
        if ty == 'pov': m.add(a + e['toast'], sy.buzzer(), .9); m.add(a + e['toast'], sy.impact(.5), .35)
        if ty == 'title' and 'card' in s:
            for j, n in enumerate((81, 84, 88, 93)): m.add(a + e['card'] + j * .09, sy.tone(sy.note(n), .6, 'bell', 5), .35)
        if ty == 'slams':
            for j, it in enumerate(s['items']):
                n0 = (69, 72, 76, 79, 81, 84)[j % 6]
                m.add(a + it['t0'], sy.impact(.7), .6)
                for d in (0, 4, 7): m.add(a + it['t0'] + .1, sy.tone(sy.note(n0 + d), .4, 'sq', 7), .18)
                m.add(a + it['t0'] + .1, sy.tone(sy.note(n0 + 12), .4, 'bell', 5), .3)
        if ty == 'ladder':
            m.add(a + e['top'], sy.impact(.6), .6)
            for j, n in enumerate((81, 84, 88)): m.add(a + e['top'] + j * .05, sy.tone(sy.note(n), .8, 'bell', 4), .35)
        if ty == 'versus':
            m.add(a + e['stamp'], sy.impact(.9), 1.0); m.add(a + e['stamp'], sy.buzzer(.35), .8); stamps.append((a + e['stamp'], a + e['stamp'] + .5))
        if ty == 'pips':
            for j in range(len(s['steps'])): m.add(a + e['pips0'] + j * .16, sy.blip(sy.note(76 + (0, 2, 3, 5, 7, 8, 12, 14)[j % 8])), .5)
            m.add(a + e['big'], sy.impact(.6), .7)
        if ty == 'cta':
            c = a + e['pick']
            m.add(c, sy.impact(.7), .7)
            for j, n in enumerate((69, 72, 76, 81, 84, 88)): m.add(c + j * .04, sy.tone(sy.note(n), 1.6, 'bell', 2.4), .35)
            for j in range(12): m.add(c + .02 + sy.rng.uniform(0, .5), sy.hat(.06, True), .35)
    # groove everywhere after the hook, dropping out briefly on stamps
    cuts = [sc[1]['a'] if len(sc) > 1 else 0] + [x for s_ in stamps for x in s_] + [end - .6]
    segs = [(cuts[0], stamps[0][0])] + [(stamps[i][1], stamps[i + 1][0]) for i in range(len(stamps) - 1)] + [(stamps[-1][1], end - .6)] if stamps else [(cuts[0], end - .6)]
    for a, b in segs:
        if b - a > .4: sy.groove(m, a, b)
    sy.arp(m, sc[2]['a'] if len(sc) > 2 else 0, end - 1.0, vol=.14)
    music = m.buf.copy()
    # VO + ducking
    v = np.zeros_like(music); duck = np.zeros_like(music)
    for (i, j), x in vo.items():
        s = sc[i]; t0 = s['a'] + (.12 if j is None else s['items'][j]['t0'] + .05)
        i0 = int(t0 * sy.SR); n = min(len(x), len(v) - i0)
        v[i0:i0 + n] += x[:n]; duck[max(0, i0 - 1500):i0 + n + 3000] = 1
    duck = np.convolve(duck, np.ones(1500) / 1500, mode='same')
    music *= (1 - .55 * duck) * .8
    out = np.tanh((music / max(1e-9, np.abs(music).max()) * .85 + v * 1.0) * 1.1)
    fade = int(.7 * sy.SR); out[-fade:] *= np.linspace(1, 0, fade)
    with wave.open(path, 'wb') as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(sy.SR)
        w.writeframes((np.clip(out, -1, 1) * 32000).astype('<i2').tobytes())


# ---------------------------------------------------------------- 4-6 record / retime / mux
def record(spec, spec_rel, rate, out, env):
    import socket, atexit
    s = socket.socket(); s.bind(('127.0.0.1', 0)); port = s.getsockname()[1]; s.close()
    srv = subprocess.Popen([sys.executable, '-m', 'http.server', str(port)], cwd=KIT, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    atexit.register(srv.kill); time.sleep(1)
    url = f'http://127.0.0.1:{port}/reel.html?rate={rate}&spec={spec_rel}'
    ab = lambda *a: subprocess.run(['agent-browser', *a], env=env, capture_output=True, text=True).stdout.strip()
    ab('close', '--all'); ab('open', url); ab('set', 'viewport', '1080', '1920')
    ab('wait', '1500')
    raw = f'{out}/raw.webm'
    print(ab('record', 'start', raw, url)); ab('wait', '2500')
    ab('eval', 'startReel()')
    for _ in range(int(spec['dur'] / rate) + 60):
        if ab('eval', 'window.__reel.done').endswith('true'): break
        time.sleep(1.5)
    print(ab('record', 'stop')); ab('close', '--all'); srv.kill()
    return raw


def retime(raw, spec, out, ff):
    DUR, W, FPS = spec['dur'], 1000, 30
    d = subprocess.run([ff, '-hide_banner', '-loglevel', 'error', '-y', '-i', raw, '-vf', 'format=rgb24,crop=1000:2:40:64', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], capture_output=True, check=True).stdout
    a = np.frombuffer(d, np.uint8).reshape(-1, 2, W, 3)[:, 0].min(axis=2)            # min channel of first row
    raw_t = (a >= 180).sum(axis=1) / W * DUR                                          # bar width -> reel time
    start = int(np.argmax(raw_t > 0.05)); idx = list(range(max(0, start - 1), len(raw_t))); ts = [float(raw_t[i]) for i in idx]
    good, prev = [None] * len(ts), 0.0
    for k, t in enumerate(ts):                                                        # reject flash-corrupted readings
        if prev - .03 <= t <= prev + .5: good[k] = max(t, prev); prev = good[k]
    last = None
    for k in range(len(good)):
        if good[k] is not None:
            if last is not None and k - last > 1:
                for m_ in range(last + 1, k): good[m_] = good[last] + (good[k] - good[last]) * (m_ - last) / (k - last)
            last = k
    for k in range(last + 1, len(good)): good[k] = good[last]
    fr = f'{out}/frames'; shutil.rmtree(fr, ignore_errors=True); os.makedirs(fr)
    sh([ff, '-hide_banner', '-loglevel', 'error', '-y', '-i', raw, '-q:v', '2', f'{fr}/f_%05d.jpg'])
    n_out, j, lines = int(round((DUR + .4) * FPS)), 0, []
    for f in range(n_out):
        while j + 1 < len(good) and good[j + 1] <= f / FPS + 1e-6: j += 1
        lines.append(f"file '{fr}/f_{idx[j] + 1:05d}.jpg'\nduration {1 / FPS:.6f}")
    lines.append(lines[-1].split('\n')[0])
    open(f'{out}/list.txt', 'w').write('\n'.join(lines) + '\n')
    print(f'retime: {len(idx)} captured frames -> {n_out} @30fps, {len(set(l for l in lines))} unique')


def main():
    import argparse
    ap = argparse.ArgumentParser(); ap.add_argument('spec'); ap.add_argument('--rate', type=float, default=0.25); ap.add_argument('--no-record', action='store_true')
    args = ap.parse_args(); rate = args.rate
    spec_path = os.path.abspath(args.spec); spec = json.load(open(spec_path))
    name = os.path.splitext(os.path.basename(spec_path))[0]
    out = os.path.join(os.path.dirname(spec_path), 'out-' + name); os.makedirs(out, exist_ok=True)
    import imageio_ffmpeg
    ff = imageio_ffmpeg.get_ffmpeg_exe(); bindir = tempfile.mkdtemp(); os.symlink(ff, f'{bindir}/ffmpeg')
    env = dict(os.environ, PATH=f'{bindir}:{os.environ["PATH"]}')
    env.setdefault('AGENT_BROWSER_EXECUTABLE_PATH', (glob.glob('/opt/pw-browsers/chromium-*/chrome-linux/chrome') or [''])[0])
    line = tts(spec, out); vo = timeline(spec, line)
    json.dump(spec, open(f'{out}/spec.resolved.json', 'w'), indent=1)
    print('timeline:', [(s['type'], round(s['a'], 1), round(s['b'], 1)) for s in spec['scenes']], 'total', spec['dur'])
    audio(spec, vo, f'{out}/audio.wav')
    if args.no_record: return
    raw = record(spec, os.path.relpath(f'{out}/spec.resolved.json', KIT), rate, out, env)
    retime(raw, spec, out, ff)
    mp4 = f'{out}/{name}.mp4'
    sh([ff, '-hide_banner', '-loglevel', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', f'{out}/list.txt', '-i', f'{out}/audio.wav',
        '-vf', 'fps=30,format=yuv420p', '-c:v', 'libx264', '-preset', 'slow', '-crf', '18', '-movflags', '+faststart', '-c:a', 'aac', '-b:a', '192k', '-ac', '2', '-shortest', mp4])
    # one contact sheet (8 frames, 4x2) so review costs a single image view
    ts_ = [s['a'] + (s['b'] - s['a']) * .6 for s in spec['scenes']][:8]
    for i, t in enumerate(ts_): sh([ff, '-hide_banner', '-loglevel', 'error', '-y', '-ss', f'{t:.2f}', '-i', mp4, '-frames:v', '1', '-vf', 'scale=360:-1', f'{out}/c{i}.png'])
    n = len(ts_); cols = 4; lay = '|'.join(f'{(i % cols) * 360}_{(i // cols) * 640}' for i in range(n))
    sh([ff, '-hide_banner', '-loglevel', 'error', '-y', *sum([['-i', f'{out}/c{i}.png'] for i in range(n)], []), '-filter_complex', f'xstack=inputs={n}:layout={lay}', f'{out}/contact.png'])
    for f in glob.glob(f'{out}/c?.png') + [raw]: os.remove(f)
    shutil.rmtree(f'{out}/frames', ignore_errors=True)
    print('DONE', mp4)


if __name__ == '__main__':
    main()

reel.html


<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>sizzle reel</title>
<style>
html,body{margin:0;height:100%;background:#000;overflow:hidden}
:root{--acc:#ffe14d}
#stage{position:absolute;left:50%;top:50%;width:1080px;height:1920px;overflow:hidden;font-family:"Liberation Sans",Arial,"DejaVu Sans",sans-serif;color:#fff}
.scene{position:absolute;left:0;top:0;width:1080px;height:1920px;display:none}
.abs{position:absolute;left:540px;top:960px;transform:translate(-50%,-50%);white-space:nowrap;text-align:center}
#content{position:absolute;left:0;top:0;width:1080px;height:1920px}
#blob1,#blob2{position:absolute;border-radius:50%;opacity:.35}  /* NO filter:blur — it drops recording to 11fps */
#blob1{width:1300px;height:1300px;background:radial-gradient(circle,#5b6cff 0%,rgba(91,108,255,0) 68%)}
#blob2{width:1200px;height:1200px;background:radial-gradient(circle,#ff3d9a 0%,rgba(255,61,154,0) 68%)}
#grid{position:absolute;inset:0;opacity:.07;background-image:linear-gradient(#fff 2px,transparent 2px),linear-gradient(90deg,#fff 2px,transparent 2px);background-size:90px 90px}
.big{font-weight:900;letter-spacing:-2px;line-height:1}
.mono{font-family:"DejaVu Sans Mono","Liberation Mono",monospace}
.stroke{text-shadow:0 8px 0 rgba(0,0,0,.35),0 0 40px rgba(0,0,0,.35)}
.card{border-radius:36px;box-shadow:0 30px 80px rgba(0,0,0,.45)}
b{color:var(--acc)}
#cap{white-space:normal;width:980px;font-size:74px;font-weight:900;text-transform:uppercase;letter-spacing:-1px;line-height:1.05;
  text-shadow:-4px -4px 0 #000,4px -4px 0 #000,-4px 4px 0 #000,4px 4px 0 #000,0 8px 0 #000,0 10px 30px rgba(0,0,0,.6);z-index:50}
#wipe{position:absolute;left:540px;top:960px;width:1500px;height:2600px;background:linear-gradient(90deg,#ff3d9a,#7c5cff);transform:translate(-50%,-50%) translateX(-4000px) skewX(-12deg);z-index:40}
#bar{position:absolute;left:40px;top:60px;height:10px;width:1000px;background:rgba(255,255,255,.25);border-radius:6px;z-index:60}
#barfill{height:100%;width:0;background:#fff;border-radius:6px}
#flash{position:absolute;inset:0;background:#fff;opacity:0;z-index:45;pointer-events:none}
#redflash{position:absolute;inset:0;background:radial-gradient(circle at center,rgba(255,40,60,0) 30%,rgba(255,40,60,.75) 100%);opacity:0;z-index:44;pointer-events:none}
</style></head><body>
<div id="stage"><div id="bg" style="position:absolute;inset:0"></div><div id="blob1"></div><div id="blob2"></div><div id="grid"></div>
<div id="content"></div><div id="cap" class="abs"></div><div id="wipe"></div><div id="flash"></div><div id="redflash"></div>
<div id="bar"><div id="barfill"></div></div></div>
<script>
/* ===== helpers ===== */
const Q=new URLSearchParams(location.search), RATE=parseFloat(Q.get('rate')||'1');
const cl=(x,a=0,b=1)=>Math.min(b,Math.max(a,x));
const eb=x=>{const c=1.70158,d=c+1;return 1+d*Math.pow(x-1,3)+c*Math.pow(x-1,2)};
const eo=x=>1-Math.pow(1-x,3);
const pop=(t,t0,d=.4)=>t<t0?0:eb(cl((t-t0)/d));
const fade=(t,t0,d=.2)=>cl((t-t0)/d);
const slam=(t,t0,from=3,d=.16)=>t<t0?0:1+(from-1)*(1-eo(cl((t-t0)/d)));
const shake=(t,t0,a=18,d=.5)=>{const u=t-t0;if(u<0||u>d)return[0,0];const k=1-u/d;return[Math.sin(u*90)*a*k,Math.cos(u*77)*a*k*.6]};
const $=id=>document.getElementById(id), stage=$('stage'), content=$('content');
function mk(p,cls,html,css){const d=document.createElement('div');d.className='abs '+(cls||'');if(html!=null)d.innerHTML=html;if(css)Object.assign(d.style,css);p.appendChild(d);return d}
function P(el,x=0,y=0,s=1,r=0,o=1){el.style.transform=`translate(-50%,-50%) translate(${x}px,${y}px) rotate(${r}deg) scale(${Math.max(s,.0001)})`;el.style.opacity=o}
const flashRed=(on,v)=>$('redflash').style.opacity=on?v:0;
const BG=[['#0b0d14','#1b1f3a'],['#ff3d9a','#4b2bd6'],['#0d1230','#241a52'],['#101528','#2a1140'],['#052e22','#0b4a36'],['#7c5cff','#ff3d9a']];

/* ===== scene templates: each returns update(lt) — lt = seconds since scene start; sc.ev = event times ===== */
const T={
pov(r,sc){const e=sc.ev,c=sc.card||{},n=c.stages||[];
  const pill=mk(r,'',sc.pill||'POV',{fontSize:'44px',fontWeight:900,background:'var(--acc)',color:'#111',padding:'10px 28px',borderRadius:'40px'});
  const txt=mk(r,'big stroke',sc.text,{fontSize:'78px',lineHeight:1.1});
  const card=mk(r,'card',`<div style="width:900px;height:560px;background:#fff;color:#16325c;text-align:left;box-sizing:border-box;padding:40px 46px;position:relative;white-space:normal">
   <div style="display:flex;align-items:center;gap:20px"><div style="width:64px;height:64px;border-radius:16px;background:#f88962;display:flex;align-items:center;justify-content:center;font-size:36px">${c.icon||'📄'}</div>
   <div><div style="font-size:30px;color:#5c6b7d">${c.kicker||''}</div><div style="font-size:44px;font-weight:800">${c.title||''}</div></div></div>
   <div style="display:flex;gap:8px;margin-top:44px">${n.map((s,i)=>`<div style="flex:1;text-align:center;padding:16px 0;font-size:28px;font-weight:800;border-radius:10px;background:${i<c.active?'#2e844a':i==c.active?'#0176d3':'#e5e9ef'};color:${i<=c.active?'#fff':'#8a96a5'}">${i<c.active?'✓ ':''}${s}</div>`).join('')}</div>
   <div style="margin-top:38px;font-size:30px;color:#5c6b7d;line-height:1.5">${(c.fields||[]).map(f=>`${f[0]}: <b style="color:${f[1]=='—'?'#b0b8c4':'#16325c'}">${f[1]}</b>`).join('<br>')}</div>
   <div class="sv" style="position:absolute;right:46px;bottom:36px;background:#0176d3;color:#fff;font-weight:800;font-size:36px;padding:20px 54px;border-radius:14px">${c.button||'Save'}</div></div>`);
  const t=sc.toast||{};
  const toast=mk(r,'card',`<div style="width:940px;background:#c23934;color:#fff;text-align:left;padding:34px 40px;box-sizing:border-box;white-space:normal;border-radius:30px;display:flex;gap:26px;align-items:flex-start;border:6px solid #fff"><div style="font-size:70px;line-height:1">⚠️</div><div><div style="font-size:40px;font-weight:900">${t.title||''}</div><div style="font-size:38px;font-weight:600;margin-top:10px;line-height:1.2">${t.text||''}</div></div></div>`);
  return lt=>{P(pill,0,-640,pop(lt,.05,.3),0,fade(lt,.05,.1));P(txt,0,-500,1,0,fade(lt,.1,.2));
   const k=e.toast,[cx,cy]=shake(lt,k,14,.5);P(card,lt>k?cx:0,-120+(lt>k?cy:0),pop(lt,.2,.4),0,fade(lt,.2,.1));
   card.querySelector('.sv').style.transform=`scale(${lt>k-.15&&lt<k?.9:1})`;
   const [sx,sy]=shake(lt,k,24,.6);P(toast,sx,310+sy-(1-eo(cl((lt-k)/.14)))*900,1,0,fade(lt,k,.05));flashRed(lt>k&&lt<k+.6,.9*(1-cl((lt-k)/.6)))}},

title(r,sc){const e=sc.ev,L=(sc.lines||[]).map(t=>mk(r,'big stroke',t,{fontSize:(sc.fs||150)+'px'})),c=sc.card;
  const tag=c&&mk(r,'big',c.tag||'',{fontSize:'56px',background:'var(--acc)',color:'#111',padding:'14px 34px',borderRadius:'50px'});
  const card=c&&mk(r,'card',`<div style="width:900px;background:#fff;color:#16325c;padding:50px;box-sizing:border-box;border-radius:36px;text-align:left"><div style="font-size:34px;color:#5c6b7d;font-weight:700">${c.kicker||''}</div><div style="font-size:96px;font-weight:900;line-height:1.05;margin:6px 0 22px">${c.title||''}</div><div class="mono" style="font-size:36px;background:#eef4ff;color:#0b5cab;padding:16px 24px;border-radius:14px;display:inline-block">${c.chip||''}</div></div>`);
  return lt=>{const out=c?fade(lt,e.card-.15,.2):0;
   L.forEach((l,i)=>P(l,0,(i-(L.length-1)/2)*200-out*900,pop(lt,.05+i*.45,.35),i%2?2:-3,fade(lt,.05+i*.45,.08)*(1-out)));
   if(c){const [sx,sy]=shake(lt,e.card,20,.5);P(tag,sx,-620+sy,pop(lt,e.card,.35),-2,fade(lt,e.card,.06));P(card,sx,-60+sy,pop(lt,e.card+.05,.45),-1.5,fade(lt,e.card+.05,.06))}}},

slams(r,sc){const it=sc.items,n=it.length;
  const P_=it.map((v,i)=>{const p=document.createElement('div');p.style.cssText=`position:absolute;left:0;top:0;width:1080px;height:1920px;background:linear-gradient(165deg,${v.c1},${v.c2});display:none`;r.appendChild(p);
   return{p,v,idx:mk(p,'big',`${i+1}/${n}`,{fontSize:'44px'}),em:mk(p,'',v.emoji||'',{fontSize:'230px'}),w:mk(p,'big stroke',v.word,{fontSize:(v.fs||360)+'px'}),
   sub:v.sub?mk(p,'big',v.sub,{fontSize:'70px',background:'#fff',color:v.c2,padding:'12px 34px',borderRadius:'20px'}):null}});
  return lt=>P_.forEach((o,i)=>{const l=lt-o.v.t0;if(l<0){o.p.style.display='none';return}
   o.p.style.display='block';o.p.style.clipPath=`circle(${eo(cl(l/.32))*2200}px at 540px 900px)`;
   const [sx,sy]=shake(lt,o.v.t0+.12,16,.4);
   P(o.em,sx,-400+sy,pop(l,.05,.4),l<.5?-12+l*24:0,fade(l,.05,.05));P(o.w,sx,-10+sy,slam(l,.12,2.6,.14),0,fade(l,.12,.04));
   if(o.sub)P(o.sub,sx,300+sy,pop(l,.3,.35),-2,fade(l,.3,.05));P(o.idx,0,-700,1,0,.85)})},

ladder(r,sc){const e=sc.ev;
  const h1=mk(r,'big stroke',sc.head.join('<br>'),{fontSize:'170px',lineHeight:1});
  const k=mk(r,'big',sc.kicker||'',{fontSize:'70px',color:'var(--acc)'});
  const top=mk(r,'card big',sc.top,{fontSize:'70px',width:'900px',height:'230px',lineHeight:'230px',background:'linear-gradient(120deg,#ffe14d,#ff9d00)',color:'#2a1800'});
  const ar=mk(r,'','⬇️',{fontSize:'120px'});
  const bot=mk(r,'card big',sc.bottom,{fontSize:'62px',width:'760px',height:'170px',lineHeight:'170px',background:'#2a3350',color:'#aab4d4'});
  const nt=mk(r,'',sc.note||'',{fontSize:'44px',color:'#aab4d4',fontWeight:700});
  return lt=>{P(h1,0,-620,pop(lt,.08,.4),-2,fade(lt,.08,.08));P(k,0,-300,pop(lt,.7,.35),0,fade(lt,.7,.08));
   const [sx,sy]=shake(lt,e.top,16,.4);P(top,sx,-70+sy,pop(lt,e.top,.4),1,fade(lt,e.top,.06));
   P(ar,0,150,pop(lt,e.top+.35,.35),0,fade(lt,e.top+.35,.06));P(bot,0,340,pop(lt,e.top+.55,.4),0,fade(lt,e.top+.55,.06));P(nt,0,470,1,0,fade(lt,e.top+.9,.2))}},

versus(r,sc){const e=sc.ev,card=(o,c1,c2)=>`<div style="width:900px;height:300px;background:linear-gradient(120deg,${c1},${c2});border-radius:36px;padding:0 44px;display:flex;flex-direction:column;justify-content:center;box-sizing:border-box;text-align:left;white-space:normal"><div style="font-size:54px;font-weight:900">${o.title}</div><div style="font-size:44px;margin-top:14px;font-weight:600">${o.sub}</div></div>`;
  const a=mk(r,'card',card(sc.left,'#0fb5ae','#0a7f8c')),sym=mk(r,'big',sc.symbol||'≠',{fontSize:'240px',color:'var(--acc)'}),b=mk(r,'card',card(sc.right,'#8a3dff','#5b21c7'));
  const st=mk(r,'big',sc.stamp||'BLOCKED',{fontSize:'150px',color:'#ff2d3d',border:'14px solid #ff2d3d',padding:'0 40px',borderRadius:'24px',background:'rgba(20,0,4,.55)'});
  const nt=mk(r,'',sc.note||'',{fontSize:'42px',color:'#d7dcf0',fontWeight:700});
  return lt=>{const hit=lt>e.stamp,[sx,sy]=shake(lt,e.stamp,22,.5);
   P(a,hit?sx:0,-450+(hit?sy:0),pop(lt,.08,.4),-1.5,fade(lt,.08,.08));P(sym,0,-190,pop(lt,.5,.4),0,fade(lt,.5,.06));P(b,hit?-sx:0,70-(hit?sy:0),pop(lt,.9,.4),1.5,fade(lt,.9,.08));
   P(st,0,-100,slam(lt,e.stamp,3.2,.14),-10,fade(lt,e.stamp,.04));P(nt,0,330,1,0,fade(lt,e.stamp+.6,.25));flashRed(hit&&lt<e.stamp+.5,.8*(1-cl((lt-e.stamp)/.5)))}},

pips(r,sc){const e=sc.ev,N=sc.steps;
  const h=mk(r,'big stroke',sc.head,{fontSize:'104px',lineHeight:1.05});
  const pill=mk(r,'big',sc.pill||'',{fontSize:'54px',background:'#fff',color:'#16325c',padding:'18px 34px',borderRadius:'60px'});
  const sp=Math.min(145,880/N.length);
  const pips=N.map(n=>mk(r,'big',`<div style="width:118px;height:118px;border-radius:50%;background:#12b981;display:flex;align-items:center;justify-content:center;font-size:44px;border:6px solid #fff">✓</div><div style="font-size:34px;margin-top:10px;color:#cfe">${n}</div>`));
  const ft=mk(r,'',sc.foot||'',{fontSize:'44px',color:'#bfe9d8',fontWeight:700}),big=mk(r,'big stroke',sc.big||'',{fontSize:'150px',lineHeight:1});
  return lt=>{P(h,0,-590,pop(lt,.05,.4),-1,fade(lt,.05,.08));P(pill,0,-260,pop(lt,.2,.4),0,fade(lt,.2,.08));
   pips.forEach((p,i)=>{const t0=e.pips0+i*.16;P(p,(i-(N.length-1)/2)*sp,-20,pop(lt,t0,.3),0,fade(lt,t0,.05))});
   P(ft,0,150,1,0,fade(lt,e.pips0+.2,.3));const [sx,sy]=shake(lt,e.big,16,.4);P(big,sx,430+sy,slam(lt,e.big,2.6,.15),-1.5,fade(lt,e.big,.05))}},

cta(r,sc){const e=sc.ev,O=sc.options;
  const h=mk(r,'big stroke',sc.head,{fontSize:'160px',lineHeight:.95}),lab=mk(r,'',sc.label||'',{fontSize:'42px',fontWeight:800});
  const sel=mk(r,'card',`<div class="sb" style="width:860px;height:130px;background:#fff;border-radius:24px;color:#16325c;font-size:52px;font-weight:800;display:flex;align-items:center;justify-content:space-between;padding:0 40px;box-sizing:border-box"><span class="st" style="color:#8a96a5">--None--</span><span>▾</span></div>`);
  const list=mk(r,'card',`<div style="width:860px;background:#fff;border-radius:24px;overflow:hidden;color:#16325c;font-size:50px;font-weight:800;text-align:left">${O.map(o=>`<div class="opt" style="height:110px;line-height:110px;padding-left:40px">${o}</div>`).join('')}</div>`);
  const cur=mk(r,'',`<svg width="80" height="80" viewBox="0 0 24 24"><path d="M4 2l16 9-7 2-3 7z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>`);
  const chip=mk(r,'mono big',sc.chip||'',{fontSize:'40px',background:'#fff',color:'#0b5cab',padding:'20px 34px',borderRadius:'18px',letterSpacing:0});
  const rnd=(()=>{let a=7;return()=>{a=(a*16807)%2147483647;return a/2147483647}})(),cols=['#ffe14d','#ff3d9a','#1f6bff','#12b981','#ff7a1a','#fff'];
  const conf=Array.from({length:70},(_,i)=>({d:mk(r,'','',{width:'22px',height:'34px',background:cols[i%6],borderRadius:'4px'}),a:-Math.PI*(.1+.8*rnd()),v:500+rnd()*1100,rot:rnd()*720,w:(rnd()-.5)*900}));
  const rowY=50+110*(sc.pick||0), open0=e.pick-.6, click=e.pick;
  return lt=>{const [sx,sy]=shake(lt,.05,18,.5);P(h,sx,-640+sy,slam(lt,.05,2.4,.16),-2,fade(lt,.05,.05));
   P(lab,-300,-190,1,0,fade(lt,.3,.2));P(sel,0,-80,pop(lt,.3,.35),0,fade(lt,.3,.06));
   const open=lt>open0&&lt<click+.2;list.style.transform=`translate(-50%,-50%) translate(0px,215px) scale(${open?1:.0001})`;list.style.opacity=open?1:0;
   const mv=eo(cl((lt-(open0-.35))/.5));P(cur,250+(-60-250)*mv+40,300+(rowY-300)*mv+40,lt>click&&lt<click+.15?.85:1,0,lt>open0-.5&&lt<click+.6?fade(lt,open0-.5,.1):0);
   list.querySelectorAll('.opt').forEach((o,i)=>o.style.background=(i==(sc.pick||0)&&lt>open0+.4&&lt<click+.2)?'#dbe8ff':'#fff');
   const ok=lt>=click+.05,st=sel.querySelector('.st');st.textContent=ok?O[sc.pick||0]+' ✓':'--None--';st.style.color=ok?'#1f6bff':'#8a96a5';sel.querySelector('.sb').style.boxShadow=ok?'0 0 0 10px #12b981':'none';
   conf.forEach(c=>{const u=lt-click;if(u<0){P(c.d,0,0,1,0,0);return}P(c.d,Math.cos(c.a)*c.v*u+c.w*u*.3,-80+Math.sin(c.a)*c.v*u+900*u*u,1,c.rot*u*2,u>2?0:1)});
   P(chip,0,430,pop(lt,e.chip,.4),0,fade(lt,e.chip,.06))}}
};

/* ===== engine ===== */
let S,DUR,scenes=[],B=[],capIdx=-1;
function render(t){
  let bi=0;for(let i=scenes.length-1;i>=0;i--)if(t>=scenes[i].a){bi=i;break}
  scenes.forEach((s,i)=>s.root.style.display=i===bi?'block':'none');
  $('bg').style.background=`linear-gradient(165deg,${scenes[bi].bg[0]},${scenes[bi].bg[1]})`;
  scenes[bi].update(t-scenes[bi].a);
  const pulse=Math.exp(-((t%.5)/.5)*7);content.style.transform=`scale(${1+.014*pulse})`;
  $('blob1').style.cssText+=`;left:${-450+Math.sin(t*.9)*220}px;top:${-100+Math.cos(t*.7)*260}px;opacity:${.3+.25*pulse}`;
  $('blob2').style.cssText+=`;left:${100+Math.cos(t*.8)*200}px;top:${800+Math.sin(t*.6)*260}px`;
  let wx=-4000;B.forEach(b=>{const u=(t-(b-.14))/.34;if(u>0&&u<1)wx=-1800+3600*u});
  $('wipe').style.transform=`translate(-50%,-50%) translateX(${wx}px) skewX(-12deg)`;
  $('flash').style.opacity=B.reduce((m,b)=>{const u=t-b;return u>=0&&u<.12?Math.max(m,.5*(1-u/.12)):m},0);
  const c=S.caps.findIndex(x=>t>=x[0]&&t<x[1]),cap=$('cap');
  if(c!==capIdx){capIdx=c;cap.innerHTML=c>=0?S.caps[c][2]:''}
  if(c>=0){const u=t-S.caps[c][0];P(cap,0,500,pop(u,0,.25)*.999+.001,0,fade(u,0,.05))}else cap.style.opacity=0;
  $('barfill').style.width=(cl(t/DUR)*100)+'%';
}
function fit(){stage.style.transform=`translate(-50%,-50%) scale(${Math.min(innerWidth/1080,innerHeight/1920)})`}
addEventListener('resize',fit);fit();
window.__reel={done:false,t:0,started:false};
let t0=null;
function loop(now){if(t0===null)t0=now;const t=(now-t0)/1000*RATE;window.__reel.t=t;render(Math.min(t,DUR+.4));
  if(t>=DUR+.4){window.__reel.done=true;return}requestAnimationFrame(loop)}
window.startReel=()=>{if(!window.__reel.started){window.__reel.started=true;requestAnimationFrame(loop)}};
fetch(Q.get('spec')||'spec.resolved.json').then(r=>r.json()).then(spec=>{
  S=spec;DUR=S.dur;document.documentElement.style.setProperty('--acc',(S.theme&&S.theme.accent)||'#ffe14d');
  S.scenes.forEach((sc,i)=>{const d=document.createElement('div');d.className='scene';content.appendChild(d);
    scenes.push({a:sc.a,b:sc.b,root:d,bg:sc.bg||BG[i%BG.length],update:T[sc.type](d,sc)})});
  B=scenes.slice(1).map(s=>s.a);
  if(Q.has('seek'))render(parseFloat(Q.get('seek')));else{render(0);if(Q.has('autoplay'))startReel()}
  window.__reel.ready=true;
});
</script></body></html>

Done


You are done when contact.png matches the spec you wrote, the timeline print is at or under target, and the MP4 is the file you hand back. The last frame should show the action and the API name a person can screenshot.