Claude Cursor Skill

release-video

Turn a product release (the list of shipped items plus real screen recordings) into a motion recap video and one explained demo per feature, with sound effects tied to on-screen motion and a composed music bed. Deterministic HTML scenes rendered frame by frame, ElevenLabs for sou

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

Full trust report

Download huytieu-cog-second-brain-skills_release-video-4cdb601.zip · 21 KB
Part of huytieu/cog-second-brain — 108 skills

Install

skills CLI npx skills add https://github.com/huytieu/COG-second-brain/tree/main/skills/release-video
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install huytieu-cog-second-brain@llmmart
Git git clone https://github.com/huytieu/COG-second-brain.git

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

Skill manifest

release-video

Two outputs per release:

  • Recap: a 45-75 s motion video, one scene per shipped feature, vertical (1080x1920) by default.
  • Explained demos: one 30-60 s clip per feature, a real recording of the product with step captions, eased zoom on the payoff, and intro/outro cards (1920x1080).

Every frame of the recap is a pure function of time (window.renderAt(t) in scripts/engine.js), so a re-render is identical, renders split across workers, and any single frame can be pulled as a still for review before the full render.

This skill makes motion scenes and composes demos from recordings. It does not cut raw footage of people talking. For narrated takes, founder voice-overs or interviews, use browser-use/video-use, which cuts on word boundaries from a transcript, and drop the recap and demo clips into its folder as B-roll.

Requirements

ffmpeg, Python 3 with playwright, numpy, Pillow, and ELEVENLABS_API_KEY for audio. If Playwright's bundled Chromium doesn't launch, point CHROME_PATH at any Chromium or headless-shell binary.

Rules from review

These came from a real review of the first cut. Each one cost a full re-render when it was missed.

  1. Sound comes from the motion. Every sound effect is declared on the element that moves (data-sfx="pop,0.5") and lands on that element's animation start. A looping background track as the only audio reads as filler.
  2. Music is composed to the timeline. Write a sectioned plan whose section lengths match the scenes: a quieter intro under the title, the full groove from the first feature, a lift on the summary, a final hit on the outro. Pick a tempo where scene boundaries land on beats (120 BPM puts a beat on every half second). Generate two or three candidates and keep the one music_check.py scores least repetitive.
  3. Do not reuse the reference video's ideas. If you were shown a video to match, take its pacing and polish. Its visual metaphors belong to its subject. A radar chart made sense for a tech-radar release and means nothing for yours.
  4. Each illustration shows its feature. A filter feature shows the filter conditions. A speed feature shows the before and after number. Generic shapes that could sit in any scene get replaced.
  5. Complete shapes only. Draw-on strokes must finish closed and whole. Half-drawn arcs and dangling lines at rest read as low quality.
  6. Pace for reading. Feature scenes run 6-8 s, long enough to read every line twice. In demos, sped-up stretches say so in the caption ("sped up 2.5x"), and the payoff plays at 1x with a zoom and a highlight ring.
  7. Real product, real data. Demos come from recordings of the live product on a demo project. Numbers on screen match what the recording shows.

Workflow

  1. Inventory. List the shipped items from the release milestone or changelog. Pick the ones a user would notice and leave internal fixes to the written notes. For each, write one sentence of what changed and one of why it matters.
  2. Record. Capture each feature end to end in the real product. Any recorder works. scripts/frames_from_video.sh rec.mp4 <name> turns a recording into the frames folder and manifest the composer reads.
  3. Storyboard. Copy templates/ into a working folder with scripts/engine.js. One <section class="scene"> per beat, with data-start and data-dur in seconds. Element timings are relative to their scene. The comment at the top of templates/index.html lists every animation attribute.
  4. Review stills before rendering. render.py stills <t1> <t2> ... --dir <folder> writes PNGs at chosen times. Take one per scene midpoint, tile them into a contact sheet with ffmpeg hstack, and look at it against the rules above. Fix, then re-take the stills.
  5. Sound effects. gen_audio.sh sfx <name> "<prompt>" <seconds> <folder>/sfx once per sound (whoosh, pop, tick, chime, thump, a soft riser). Keep them short and quiet in character. mix_sfx.py --dir <folder> reads every data-sfx cue from the page and builds sfx-track.wav.
  6. Music. Adapt templates/music-plan.example.json so section durations sum to the video length, then gen_audio.sh music plan.json bed-a.mp3. Run music_check.py bed-*.mp3, which prints scores and rejects nothing itself, and keep the candidate with the lowest far-similarity and an intro quieter than the body.
  7. Render and mix. render.py video --dir <folder> --out recap.mp4, then mix_final.sh recap.mp4 sfx-track.wav bed-a.mp3 recap-final.mp4. The mix ducks music under each effect, normalizes toward -18 LUFS (one loudnorm pass lands within about 1 LU) with true peak under -1.5 dBTP, writes a smaller -web.mp4, and lists any silence over 1 s.
  8. Demos. Write a spec per feature from templates/demo-spec.example.json: the recording's manifest, segments with source time ranges, speed, a caption per step, and zoom plus ring boxes on the payoff. compose_demo.py spec.json renders it. Pull a frame from each segment and check the captions are readable and the ring sits on the right element.
  9. Self-review. Watch the final at 1x before handing it over. Check each rule, then check that every number on screen matches the release notes.

Files

Path What it does
scripts/engine.js Scene engine: timing attributes, easing, draw-on, typewriter, counters, canvas burst, SFX cue export
scripts/render.py Stills for review, or a parallel frame render encoded with ffmpeg
scripts/mix_sfx.py Builds the effects track from data-sfx cues
scripts/gen_audio.sh ElevenLabs sound effects and composed music
scripts/music_check.py Tempo, beat phase, loudness curve and repetition score per candidate
scripts/mix_final.sh Sidechain mix, loudness check, web copy, silence check
scripts/compose_demo.py Recording, window frame, captions, zoom, ring, intro and outro cards
scripts/frames_from_video.sh Recording to frames plus manifest
templates/ Starter scenes, theme tokens, demo spec, music plan

Theme lives in CSS variables in templates/base.css and in spec["theme"] for demos. Swap in your product's colors and fonts there; the rest of the pipeline carries no brand.

Files (cog-second-brain)
  • scripts
    • compose_demo.py 8.7 KB
      #!/usr/bin/env python3
      """Compose a real screen recording into an explained demo (1920x1080, 30fps).
      
      Layers: a background with a browser window, the recording (variable speed,
      eased zoom, highlight ring), step captions under the window, intro/outro cards.
      Text layers are rendered once with headless Chromium so they use the same fonts
      as the recap video; frames are composited with Pillow and piped to ffmpeg.
      
      The recording is a folder of numbered JPEG frames plus a manifest
      ({frames_dir, fps, frame_count, img_w, img_h, duration}); frames_from_video.sh
      makes one from any .mp4/.mov. Theme (colors, fonts) comes from spec["theme"].
      
      Intro/outro title, body and kicker are inserted as HTML so <em> works; escape & < > yourself.
      
      usage: compose_demo.py <spec.json>   (see templates/demo-spec.example.json)
      """
      import json, math, pathlib, subprocess, sys, html, os
      from PIL import Image, ImageDraw, ImageFilter
      from playwright.sync_api import sync_playwright
      
      HERE = pathlib.Path.cwd()
      W, H, FPS = 1920, 1080, 30
      WIN = (80, 36, 1840, 36 + 48 + 814)          # window box
      CONTENT = (80, 36 + 48, 1840, 36 + 48 + 814)  # recording area (1760 x 814)
      
      DEFAULT_THEME = {
          "bg": "#eef0f3", "ink": "#111318", "paper": "#e5e7eb", "accent": "#6366f1", "accent_soft": "#a5b4fc",
          "fonts_css": "", "sans": "Inter, system-ui, sans-serif", "serif": "Georgia, serif", "mono": "ui-monospace, Menlo, monospace",
      }
      
      
      def css_for(t):
          link = f'<link rel="stylesheet" href="{t["fonts_css"]}">' if t.get("fonts_css") else ""
          return f"""{link}
      <style>
      * {{ margin:0; padding:0; box-sizing:border-box; }}
      html,body {{ width:{W}px; height:{H}px; background:transparent; }}
      .bg {{ position:absolute; inset:0; background:{t["bg"]}; }}
      .win {{ position:absolute; left:{WIN[0]}px; top:{WIN[1]}px; width:{WIN[2]-WIN[0]}px; height:{WIN[3]-WIN[1]}px; border-radius:18px; background:#fff;
        box-shadow:0 1px 0 rgba(0,0,0,.05),0 40px 80px -30px rgba(0,0,0,.45),0 0 0 1px rgba(0,0,0,.08); }}
      .bar {{ height:48px; display:flex; align-items:center; gap:10px; padding:0 20px; border-bottom:1px solid #e6e8ec; }}
      .bar i {{ width:13px; height:13px; border-radius:50%; background:#d0d7de; display:block; }}
      .pill {{ margin-left:18px; background:#f6f8fa; border-radius:999px; padding:7px 18px; font:500 16px/1 {t["mono"]}; color:#57606a; }}
      .cap {{ position:absolute; left:80px; top:922px; display:flex; align-items:center; gap:20px; max-width:1760px;
        background:{t["ink"]}; color:{t["paper"]}; border-radius:18px; padding:20px 30px; }}
      .cap b {{ font:500 20px/1 {t["mono"]}; letter-spacing:.14em; color:{t["accent_soft"]}; white-space:nowrap; }}
      .cap span {{ font:400 30px/1.3 {t["sans"]}; }}
      .card {{ position:absolute; inset:0; background:{t["ink"]}; color:{t["paper"]}; padding:120px 140px; }}
      .card .k {{ font:500 24px/1 {t["mono"]}; letter-spacing:.16em; text-transform:uppercase; opacity:.74; display:flex; gap:16px; align-items:center; }}
      .card .k i {{ width:12px; height:12px; border-radius:50%; background:{t["accent"]}; display:block; }}
      .card h1 {{ margin-top:70px; font:600 104px/1.02 {t["sans"]}; letter-spacing:-.035em; }}
      .card h1 em {{ font:italic 400 110px/1 {t["serif"]}; color:{t["accent_soft"]}; }}
      .card p {{ margin-top:44px; max-width:1500px; font:300 42px/1.38 {t["serif"]}; opacity:.82; }}
      .card .f {{ position:absolute; left:140px; bottom:100px; font:500 22px/1 {t["mono"]}; letter-spacing:.14em; text-transform:uppercase; opacity:.6; }}
      </style>"""
      
      
      def render_layers(spec, out):
          out.mkdir(parents=True, exist_ok=True)
          CSS = css_for({**DEFAULT_THEME, **spec.get("theme", {})})
          pages = {"bg": f'<div class="bg"></div><div class="win"><div class="bar"><i></i><i></i><i></i><span class="pill">{html.escape(spec["address"])}</span></div></div>'}
          for key in ("intro", "outro"):
              c = spec[key]
              pages[key] = f'<div class="card"><div class="k"><i></i>{c["kicker"]}</div><h1>{c["title"]}</h1><p>{c["body"]}</p><div class="f">{c.get("foot","")}</div></div>'
          for i, seg in enumerate(spec["segments"]):
              if seg.get("caption"):
                  pages[f"cap{i}"] = f'<div class="cap"><b>{seg.get("step","")}</b><span>{seg["caption"]}</span></div>'
          with sync_playwright() as p:
              exe = os.environ.get("CHROME_PATH")
              b = p.chromium.launch(executable_path=exe) if exe else p.chromium.launch()
              pg = b.new_page(viewport={"width": W, "height": H})
              for k, body in pages.items():
                  f = out / f"{k}.html"
                  f.write_text(f"<!doctype html><html><head><meta charset='utf-8'>{CSS}</head><body>{body}</body></html>")
                  pg.goto(f.as_uri()); pg.evaluate("document.fonts.ready"); pg.wait_for_timeout(150)
                  pg.screenshot(path=str(out / f"{k}.png"), omit_background=(k not in ("bg", "intro", "outro")))
              b.close()
          return {k: Image.open(out / f"{k}.png").convert("RGBA") for k in pages}
      
      
      def ease(p):
          p = max(0.0, min(1.0, p))
          return 4 * p ** 3 if p < 0.5 else 1 - (-2 * p + 2) ** 3 / 2
      
      
      def main():
          spec = json.load(open(sys.argv[1]))
          accent = spec.get("theme", {}).get("accent", DEFAULT_THEME["accent"]).lstrip("#")
          ACCENT = tuple(int(accent[i:i + 2], 16) for i in (0, 2, 4))
          man = json.load(open(spec["manifest"]))
          fdir = pathlib.Path(man["frames_dir"]); sfps = man["fps"]; nfr = man["frame_count"]
          srcw, srch = man["img_w"], man["img_h"]
          layers = render_layers(spec, HERE / "layers" / spec["name"])
          cw, ch = CONTENT[2] - CONTENT[0], CONTENT[3] - CONTENT[1]
          mask = Image.new("L", (cw, ch), 0)
          ImageDraw.Draw(mask).rounded_rectangle((0, -30, cw, ch), radius=18, fill=255)
      
          # timeline: intro card, segments, outro card
          plan = [("card", "intro", spec["intro"]["dur"])]
          for i, seg in enumerate(spec["segments"]):
              dur = (seg["src"][1] - seg["src"][0]) / seg.get("speed", 1)
              plan.append(("seg", i, dur))
          plan.append(("card", "outro", spec["outro"]["dur"]))
          total = sum(d for _, _, d in plan)
          out = HERE / f'{spec["name"]}.mp4'
          ff = subprocess.Popen(["ffmpeg", "-v", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{W}x{H}", "-r", str(FPS), "-i", "-",
                                 "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium", "-movflags", "+faststart", str(out)], stdin=subprocess.PIPE)
          cache = {}
      
          def src_frame(t):
              i = min(nfr - 1, max(0, int(round(t * sfps))))
              if i not in cache:
                  if len(cache) > 40: cache.clear()
                  cache[i] = Image.open(fdir / f"{i:05d}.jpg").convert("RGB")
              return cache[i]
      
          full = (0, 0, srcw, srch)
          t0 = 0.0
          prev_zoom = full
          for kind, key, dur in plan:
              nloc = int(round(dur * FPS))
              for k in range(nloc):
                  lt = k / FPS
                  if kind == "card":
                      card = layers[key]
                      a = min(1.0, lt / 0.35) if key == "intro" else min(1.0, lt / 0.35)
                      frame = layers["bg"].copy().convert("RGB")
                      frame = Image.blend(frame, card.convert("RGB"), a) if key == "outro" else card.convert("RGB")
                  else:
                      seg = spec["segments"][key]
                      sp = seg.get("speed", 1)
                      st = seg["src"][0] + lt * sp
                      img = src_frame(st)
                      z = seg.get("zoom")
                      box = full
                      if z:
                          zin = ease((lt - z.get("at", 0)) / z.get("dur", 0.8))
                          tgt = z["box"]
                          box = tuple(full[j] + (tgt[j] - full[j]) * zin for j in range(4))
                      crop = img.crop(tuple(int(v) for v in box)).resize((cw, ch), Image.LANCZOS)
                      ring = seg.get("ring")
                      if ring and lt >= ring["at"]:
                          d = ImageDraw.Draw(crop)
                          sx = cw / (box[2] - box[0]); sy = ch / (box[3] - box[1])
                          r = [(ring["box"][0] - box[0]) * sx, (ring["box"][1] - box[1]) * sy, (ring["box"][2] - box[0]) * sx, (ring["box"][3] - box[1]) * sy]
                          grow = ease((lt - ring["at"]) / 0.35)
                          pad = 10 + 30 * (1 - grow)
                          d.rounded_rectangle((r[0] - pad, r[1] - pad, r[2] + pad, r[3] + pad), radius=16, outline=ACCENT + (255,), width=6)
                      frame = layers["bg"].copy()
                      frame.paste(crop, (CONTENT[0], CONTENT[1]), mask)
                      cap = layers.get(f"cap{key}")
                      if cap:
                          a = min(1.0, lt / 0.3)
                          if a < 1:
                              c2 = cap.copy(); c2.putalpha(cap.getchannel("A").point(lambda v: int(v * a)))
                              frame.alpha_composite(c2)
                          else:
                              frame.alpha_composite(cap)
                      frame = frame.convert("RGB")
                  ff.stdin.write(frame.tobytes())
              t0 += dur
          ff.stdin.close(); ff.wait()
          print(f"{out} {total:.1f}s")
      
      
      if __name__ == "__main__":
          main()
      
    • engine.js 9.1 KB
      // Deterministic scene engine: every frame is a pure function of t (seconds).
      // Scenes: <section class="scene" data-start data-dur data-enter>. Element
      // attributes take times relative to their scene start.
      (function () {
        const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
        const E = {
          out: p => 1 - Math.pow(1 - p, 3),
          inout: p => (p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2),
          back: p => { const c1 = 1.70158, c3 = c1 + 1; return 1 + c3 * Math.pow(p - 1, 3) + c1 * Math.pow(p - 1, 2); },
          expo: p => (p === 1 ? 1 : 1 - Math.pow(2, -10 * p)),
          lin: p => p,
        };
        const nums = s => s.split(',').map(v => v.trim()).map(v => (isNaN(+v) ? v : +v));
        const prog = (lt, s, d, ease = 'out') => E[ease](clamp(d > 0 ? (lt - s) / d : lt >= s ? 1 : 0));
      
        function mulberry(seed) { return function () { seed |= 0; seed = seed + 0x6D2B79F5 | 0; let t = Math.imul(seed ^ seed >>> 15, 1 | seed); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
      
        // Radial particle burst, drawn per frame from a seed. Palette comes from data-burst JSON.
        function burst(canvas, lt0, opts) {
          const lt = lt0 + (opts.t0 || 0);
          const ctx = canvas.getContext('2d');
          const W = canvas.width, H = canvas.height;
          ctx.clearRect(0, 0, W, H);
          const rnd = mulberry(opts.seed || 7);
          const n = opts.n || 520, cx = W / 2, cy = H / 2, R = Math.min(W, H) * (opts.r || 0.46);
          const grow = E.expo(clamp((lt - (opts.s || 0)) / (opts.d || 1.6)));
          const implode = opts.implodeAt != null ? E.inout(clamp((lt - opts.implodeAt) / (opts.implodeDur || 0.6))) : 0;
          const spin = lt * (opts.spin || 0.05);
          const palette = opts.palette || ['#6366f1', '#a5b4fc', '#e5e7eb', '#c7d2fe', '#4338ca'];
          for (let i = 0; i < n; i++) {
            const a = rnd() * Math.PI * 2 + spin * (0.4 + rnd());
            const rr = Math.pow(rnd(), 0.55) * R;
            const delay = rnd() * 0.35;
            const g = clamp((grow - delay) / (1 - delay));
            const k = E.out(g) * (1 - implode);
            const wob = Math.sin(lt * (0.8 + rnd()) + i) * 4 * k;
            const x = cx + Math.cos(a) * (rr * k + wob), y = cy + Math.sin(a) * (rr * k + wob);
            const size = (0.8 + rnd() * 3.4) * (opts.scale || 1);
            const col = palette[Math.floor(rnd() * palette.length)];
            if (rnd() < 0.55) {
              ctx.strokeStyle = (opts.ray || 'rgba(229,231,235,') + (0.05 + 0.08 * k) + ')';
              ctx.lineWidth = 0.7;
              ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(x, y); ctx.stroke();
            }
            ctx.globalAlpha = clamp(k * 1.4);
            ctx.fillStyle = col;
            ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fill();
            ctx.globalAlpha = 1;
          }
        }
      
        let scenes = [];
        function init() {
          scenes = [...document.querySelectorAll('.scene')].map(el => ({
            el, start: +el.dataset.start, dur: +el.dataset.dur, enter: el.dataset.enter || 'cut',
            nodes: [...el.querySelectorAll('[data-draw],[data-fade],[data-rise],[data-pop],[data-count],[data-type],[data-rot],[data-wipe],[data-unwipe],[data-zoom],[data-pulse],[data-out],[data-float],[data-blink],[data-grow],[data-slide],canvas[data-burst]')],
          }));
          document.querySelectorAll('[data-type]').forEach(n => { n.dataset.full = n.textContent; });
          document.querySelectorAll('[data-draw]').forEach(n => {
            n.querySelectorAll ? null : null;
            if (n.tagName !== 'g') n.setAttribute('pathLength', '1');
          });
          window.TOTAL = Math.max(...scenes.map(s => s.start + s.dur));
        }
      
        function applyNode(n, lt) {
          const d = n.dataset;
          let op = 1, ty = 0, tx = 0, sc = 1, rot = null;
          if (d.draw) {
            const [s, du, ease] = nums(d.draw);
            const p = prog(lt, s, du, ease || 'inout');
            const targets = n.tagName === 'g' ? n.querySelectorAll('path,line,circle,rect,polyline,polygon,ellipse') : [n];
            targets.forEach(t => { t.setAttribute('pathLength', '1'); t.style.strokeDasharray = '1 1'; t.style.strokeDashoffset = String(1 - p); });
          }
          if (d.fade) { const [s, du] = nums(d.fade); op *= prog(lt, s, du); }
          if (d.rise) { const [s, du, px] = nums(d.rise); const p = prog(lt, s, du); op *= p; ty += (1 - p) * (px || 40); }
          if (d.slide) { const [s, du, px] = nums(d.slide); const p = prog(lt, s, du); op *= clamp(p * 2); tx += (1 - p) * (px || -60); }
          if (d.pop) { const [s, du] = nums(d.pop); const p = clamp((lt - s) / du); op *= clamp(p * 3); sc *= p <= 0 ? 0.6 : 0.6 + 0.4 * E.back(p); }
          if (d.grow) { const [s, du, axis] = nums(d.grow); const p = prog(lt, s, du, 'inout'); n.style.transformOrigin = axis === 'y' ? '50% 100%' : '0% 50%'; n.style.transform = axis === 'y' ? `scaleY(${p})` : `scaleX(${p})`; }
          if (d.wipe) { const [s, du] = nums(d.wipe); const p = prog(lt, s, du, 'inout'); n.style.clipPath = `inset(0 ${100 - p * 100}% 0 0)`; }
          if (d.out) { const [s, du] = nums(d.out); op *= 1 - prog(lt, s, du); }
          if (d.unwipe) { const [s, du] = nums(d.unwipe); const p = prog(lt, s, du, 'inout'); n.style.clipPath = `inset(0 0 0 ${p * 100}%)`; }
          if (d.zoom) {
            const [s, du, from, to, ease] = nums(d.zoom);
            const k = from + (to - from) * prog(lt, s, du, ease || 'inout');
            n.style.transformBox = 'view-box'; n.style.transformOrigin = d.origin || '50% 50%';
            n.style.transform = `scale(${k})`;
          }
          if (d.pulse) {
            const [s, du] = nums(d.pulse); const p = clamp((lt - s) / du);
            if (!n.dataset.r0) n.dataset.r0 = n.getAttribute('r');
            const r0 = +n.dataset.r0;
            n.setAttribute('r', String(r0 * (1 + 2.6 * E.out(p))));
            n.style.opacity = lt < s || p >= 1 ? '0' : String(1 - p);
          }
          if (d.float) { const [amp, per] = nums(d.float); ty += Math.sin((lt / (per || 3)) * Math.PI * 2) * (amp || 6); }
          if (d.blink) { const per = +d.blink || 1; op *= (lt % per) < per / 2 ? 1 : 0; }
          if (d.count) {
            const [s, du, from, to, dec, suffix] = nums(d.count);
            const v = from + (to - from) * prog(lt, s, du, 'out');
            n.textContent = v.toFixed(dec || 0) + (suffix || '');
          }
          if (d.type) {
            const [s, du] = nums(d.type); const full = d.full || '';
            const k = Math.floor(full.length * clamp((lt - s) / du));
            n.textContent = full.slice(0, k);
          }
          if (d.rot) {
            const [s, du, from, to, cx, cy, ease] = nums(d.rot);
            const a = from + (to - from) * prog(lt, s, du, ease || 'back');
            n.setAttribute('transform', `rotate(${a} ${cx} ${cy})`);
          }
          if (n.tagName === 'CANVAS' && d.burst != null) {
            burst(n, lt, JSON.parse(d.burst || '{}'));
          }
          if (d.rise || d.pop || d.slide || d.float) {
            const isSvg = n instanceof SVGElement;
            if (isSvg) { n.style.transformBox = 'fill-box'; n.style.transformOrigin = 'center'; }
            n.style.transform = `translate(${tx}px, ${ty}px) scale(${sc})`;
          }
          if (d.fade || d.rise || d.pop || d.out || d.blink || d.slide) n.style.opacity = String(op);
        }
      
        window.renderAt = function (t) {
          for (const s of scenes) {
            const lt = t - s.start;
            const hold = s.el.dataset.hold != null ? +s.el.dataset.hold : 0.6;
            const visible = lt >= 0 && lt < s.dur + hold;
            s.el.style.display = visible ? 'block' : 'none';
            if (!visible) continue;
            // scene entrance
            const TR = 0.42;
            let tf = '';
            if (s.enter === 'up' && lt < TR) tf = `translateY(${(1 - E.out(lt / TR)) * window.innerHeight * 0.18}px)`;
            if (s.enter === 'left' && lt < TR) tf = `translateX(${(1 - E.out(lt / TR)) * window.innerWidth * 0.22}px)`;
            s.el.style.transform = tf;
            const TW = 0.55;
            let clip = '';
            if (s.enter === 'mask' && lt < TR) clip = `inset(${(1 - E.inout(lt / TR)) * 100}% 0 0 0)`;
            const edge = s.el.querySelector(':scope > .wipe-edge');
            if (s.enter === 'wipe') {
              const p = E.inout(clamp(lt / TW));
              if (p < 1) clip = `inset(0 0 0 ${(1 - p) * 100}%)`;
              if (edge) { edge.style.left = `${(1 - p) * window.innerWidth - 4}px`; edge.style.opacity = p < 1 ? '1' : '0'; }
            }
            s.el.style.clipPath = clip;
            for (const n of s.nodes) applyNode(n, lt);
          }
          const g = document.getElementById('grain');
          if (g) g.style.backgroundPosition = `${(Math.floor(t * 24) * 37) % 200}px ${(Math.floor(t * 24) * 53) % 200}px`;
        };
      
        const ANIM = ['pop','rise','fade','type','draw','wipe','slide','pulse','grow','unwipe','zoom','out'];
        window.sfxCues = function () {
          const cues = [];
          for (const s of scenes) {
            const els = [s.el, ...s.el.querySelectorAll('[data-sfx]')];
            for (const el of els) {
              if (!el.dataset.sfx) continue;
              for (const spec of el.dataset.sfx.split('|')) {
                const [name, gain, rate, at, dur] = spec.split(',').map(v => v.trim());
                let t0 = 0;
                if (at !== undefined && at !== '') t0 = +at;
                else if (el !== s.el) { for (const k of ANIM) { if (el.dataset[k]) { t0 = +el.dataset[k].split(',')[0]; break; } } }
                cues.push({ t: s.start + t0, name, gain: gain ? +gain : 1, rate: rate ? +rate : 1, dur: dur ? +dur : 0 });
              }
            }
          }
          return cues.sort((a, b) => a.t - b.t);
        };
        window.addEventListener('DOMContentLoaded', () => { init(); window.renderAt(+(new URLSearchParams(location.search).get('t') || 0)); window.ENGINE_READY = true; });
      })();
      
    • frames_from_video.sh 849 B
      #!/usr/bin/env bash
      # Turn a screen recording into the frames + manifest compose_demo.py reads.
      #   frames_from_video.sh <recording.mp4|.mov> <name> [fps]   -> <name>.frames/ and <name>.manifest.json
      set -euo pipefail
      src=$1; name=$2; fps=${3:-30}
      mkdir -p "$name.frames"
      ffmpeg -v error -y -i "$src" -vf "fps=$fps" -q:v 3 -start_number 0 "$name.frames/%05d.jpg"
      python3 - "$name" "$fps" <<'PY'
      import json, os, sys
      from PIL import Image
      name, fps = sys.argv[1], int(sys.argv[2])
      d = f"{name}.frames"; n = len([f for f in os.listdir(d) if f.endswith(".jpg")])
      w, h = Image.open(f"{d}/00000.jpg").size
      json.dump({"frames_dir": os.path.abspath(d), "fps": fps, "frame_count": n, "img_w": w, "img_h": h, "duration": n / fps},
                open(f"{name}.manifest.json", "w"), indent=1)
      print(f"{name}.manifest.json  {n} frames  {w}x{h}  {n / fps:.1f}s")
      PY
      
    • gen_audio.sh 994 B
      #!/usr/bin/env bash
      # ElevenLabs audio for a release video. Needs ELEVENLABS_API_KEY.
      #   gen_audio.sh sfx <name> "<prompt>" <seconds> [outdir]    one sound effect -> outdir/<name>.mp3
      #   gen_audio.sh music <plan.json> <out.mp3>                 composed track from a sectioned plan
      set -euo pipefail
      : "${ELEVENLABS_API_KEY:?set ELEVENLABS_API_KEY}"
      case "${1:-}" in
        sfx)
          out="${5:-sfx}"; mkdir -p "$out"
          body=$(python3 -c 'import json,sys; print(json.dumps({"text": sys.argv[1], "duration_seconds": float(sys.argv[2]), "prompt_influence": 0.6}))' "$3" "$4")
          curl -sf -X POST "https://api.elevenlabs.io/v1/sound-generation" -H "xi-api-key: $ELEVENLABS_API_KEY" \
            -H "Content-Type: application/json" -d "$body" -o "$out/$2.mp3"
          echo "$out/$2.mp3" ;;
        music)
          curl -sf -X POST "https://api.elevenlabs.io/v1/music" -H "xi-api-key: $ELEVENLABS_API_KEY" \
            -H "Content-Type: application/json" -d @"$2" -o "$3"
          echo "$3" ;;
        *) sed -n 2,4p "$0"; exit 2 ;;
      esac
      
    • mix_final.sh 1.9 KB
      #!/usr/bin/env bash
      # Mix picture + SFX track + optional music bed, duck the music under every effect, hit loudness.
      #   mix_final.sh <video.mp4> <sfx-track.wav> [music.mp3] <out.mp4>
      # Normalizes toward -18 LUFS integrated (single pass, within about 1 LU) with true peak under -1.5 dBTP (loudnorm), then reports both. Also writes <out>-web.mp4 (CRF 23).
      set -euo pipefail
      v=$1; fx=$2
      if [ $# -eq 4 ]; then music=$3; out=$4; else music=""; out=$3; fi
      dur=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "$v")
      fo=$(python3 -c "print(max(0, $dur - 1.6))")
      if [ -n "$music" ]; then
        L=$(ffmpeg -i "$music" -af ebur128 -f null - 2>&1 | grep -E "^\s+I:" | tail -1 | awk '{print $2}')
        G=$(python3 -c "print(round(-22 - ($L), 1))")   # bed ~4 dB under the effects
        fc="[1:a]asplit=2[fx][sc];[2:a]atrim=0:$dur,volume=${G}dB,afade=t=in:d=0.6,afade=t=out:st=$fo:d=1.6[m];[m][sc]sidechaincompress=threshold=0.05:ratio=2:attack=6:release=220:makeup=1[md];[fx][md]amix=inputs=2:duration=first:normalize=0,loudnorm=I=-18:TP=-1.5:LRA=11,aresample=48000[a]"
        ffmpeg -v error -y -i "$v" -i "$fx" -i "$music" -filter_complex "$fc" -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$out"
      else
        ffmpeg -v error -y -i "$v" -i "$fx" -filter_complex "[1:a]afade=t=out:st=$fo:d=1.6,loudnorm=I=-18:TP=-1.5:LRA=11,aresample=48000[a]" -map 0:v -map "[a]" -c:v copy -c:a aac -b:a 192k -movflags +faststart "$out"
      fi
      ffmpeg -v error -y -i "$out" -c:v libx264 -crf 23 -preset slow -pix_fmt yuv420p -c:a aac -b:a 160k -movflags +faststart "${out%.mp4}-web.mp4"
      for f in "$out" "${out%.mp4}-web.mp4"; do
        echo "$f  $(ffmpeg -i "$f" -af ebur128=peak=true -f null - 2>&1 | grep -E '^\s+(I|Peak):' | tr -s ' ' | tr '\n' ' ')"
      done
      echo "silences over 1s (should be none mid-video):"
      ffmpeg -i "$out" -af "silencedetect=n=-50dB:d=1" -f null - 2>&1 | grep -E "silence_(start|end)" || echo "  none"
      
    • mix_sfx.py 3.3 KB
      #!/usr/bin/env python3
      """Build the SFX track from the cues declared in index.html (data-sfx).
      
      Each cue lands at the start time of the animation it belongs to, so every sound
      is tied to something moving on screen. Writes SCENES/sfx-track.wav (48 kHz stereo).
      
      usage: mix_sfx.py [--dir SCENES] [--sfx SFX_DIR] [--size 1080x1920]
        data-sfx="name[,gain[,rate[,at[,dur]]]]|..."  name = SFX_DIR/<name>.mp3
      """
      import json, pathlib, subprocess, os, argparse
      import numpy as np
      from playwright.sync_api import sync_playwright
      
      HERE = pathlib.Path.cwd()
      SFX = HERE / "sfx"
      SR = 48000
      W, H = 1080, 1920
      
      
      def cues():
          with sync_playwright() as p:
              exe = os.environ.get("CHROME_PATH")
              b = p.chromium.launch(executable_path=exe) if exe else p.chromium.launch()
              pg = b.new_page(viewport={"width": W, "height": H})
              pg.goto((HERE / "index.html").as_uri())
              pg.wait_for_function("window.ENGINE_READY === true")
              out = pg.evaluate("({cues: window.sfxCues(), total: window.TOTAL})")
              b.close()
          return out
      
      
      def load(name):
          raw = subprocess.run(["ffmpeg", "-v", "error", "-i", str(SFX / f"{name}.mp3"), "-ac", "2", "-ar", str(SR), "-f", "f32le", "-"],
                               capture_output=True, check=True).stdout
          a = np.frombuffer(raw, dtype=np.float32).reshape(-1, 2).copy()
          env = np.abs(a).max(1)
          thr = 10 ** (-40 / 20) * max(env.max(), 1e-6)
          idx = np.argmax(env > thr)
          a = a[max(0, idx - int(0.004 * SR)):]
          peak = np.abs(a).max()
          return a / peak if peak > 0 else a
      
      
      def main():
          global HERE, SFX, W, H
          ap = argparse.ArgumentParser()
          ap.add_argument("--dir", default=".")
          ap.add_argument("--sfx")
          ap.add_argument("--size", default="1080x1920")
          a = ap.parse_args()
          HERE = pathlib.Path(a.dir).resolve()
          SFX = pathlib.Path(a.sfx).resolve() if a.sfx else HERE / "sfx"
          W, H = (int(v) for v in a.size.lower().split("x"))
          data = cues()
          total = data["total"]
          track = np.zeros((int(total * SR) + SR, 2), dtype=np.float32)
          cache = {}
          for c in data["cues"]:
              name = c["name"]
              if name not in cache:
                  cache[name] = load(name)
              a = cache[name]
              rate = c.get("rate") or 1
              if rate != 1:
                  n = int(len(a) / rate)
                  x = np.linspace(0, len(a) - 1, n)
                  a = np.stack([np.interp(x, np.arange(len(a)), a[:, ch]) for ch in (0, 1)], 1).astype(np.float32)
              dur = c.get("dur") or 0
              if dur:
                  a = a[: int(dur * SR)].copy()
                  f = min(len(a), int(0.08 * SR))
                  a[-f:] *= np.linspace(1, 0, f)[:, None]
              start = int(c["t"] * SR)
              end = min(len(track), start + len(a))
              track[start:end] += a[: end - start] * float(c["gain"]) * 0.5
          track = track[: int(total * SR)]
          track = np.tanh(track * 1.2) / np.tanh(1.2)
          peak = np.abs(track).max()
          if peak > 0:
              track *= (10 ** (-1.5 / 20)) / peak
          else:
              print("warning: no data-sfx cues found, writing a silent track")
          out = HERE / "sfx-track.wav"
          pcm = (track * 32767).astype("<i2").tobytes()
          subprocess.run(["ffmpeg", "-v", "error", "-y", "-f", "s16le", "-ar", str(SR), "-ac", "2", "-i", "-", str(out)], input=pcm, check=True)
          json.dump(data, open(HERE / "sfx-cues.json", "w"), indent=1)
          print(f"{len(data['cues'])} cues, {total:.2f}s -> {out}")
      
      
      if __name__ == "__main__":
          main()
      
    • music_check.py 2.2 KB
      #!/usr/bin/env python3
      """Check music candidates before mixing: tempo, beat phase, loudness curve, and repetition.
      
      usage: music_check.py bed-a.mp3 bed-b.mp3 ...
      Prints bpm and beat phase (so scene cuts can be placed on beats), RMS per 2 s (so the intro
      is quieter than the body), and the max similarity between 2 s windows at least 8 s apart
      (high = the track loops; pick the candidate with the lowest value).
      """
      import subprocess, numpy as np, sys
      def load(f, sr=22050):
          raw = subprocess.run(['ffmpeg','-v','error','-i',f,'-ac','1','-ar',str(sr),'-f','f32le','-'],capture_output=True).stdout
          return np.frombuffer(raw,dtype=np.float32), sr
      for f in sys.argv[1:]:
          y,sr=load(f); hop=256; n=1024
          if len(y) < 8*sr or np.abs(y).max() == 0:
              print(f, 'skipped: shorter than 8 s or silent'); continue
          frames=np.lib.stride_tricks.sliding_window_view(y,n)[::hop]*np.hanning(n)
          S=np.abs(np.fft.rfft(frames,axis=1)); S=np.log1p(S*10)
          flux=np.maximum(0,np.diff(S,axis=0)).sum(1); flux=(flux-flux.mean())/(flux.std()+1e-9)
          fps=sr/hop
          ac=np.correlate(flux,flux,'full')[len(flux)-1:]
          lags=np.arange(len(ac))/fps; bpm_range=(lags>60/180)&(lags<60/80)
          lag=lags[bpm_range][np.argmax(ac[bpm_range])]; bpm=60/lag
          # phase: best offset for a 2.0s grid (and beat grid) using mean flux at grid points
          period=lag; best=None
          for off in np.arange(0,period,1/fps):
              idx=((np.arange(off,len(flux)/fps,period))*fps).astype(int); idx=idx[idx<len(flux)]
              s=flux[idx].mean()
              if best is None or s>best[0]: best=(s,off)
          rms=[20*np.log10(np.sqrt(np.mean(y[int(t*sr):int((t+2)*sr)]**2))+1e-9) for t in range(0,int(len(y)/sr),2)]
          print(f, f"bpm={bpm:.2f} beat_period={period:.4f}s phase={best[1]:.3f}s")
          print('  rms/2s:', ' '.join(f"{r:.0f}" for r in rms))
          win=2*sr; segs=[y[i:i+win] for i in range(0,len(y)-win,win)]
          F=[]
          for sg in segs:
              Sp=np.abs(np.fft.rfft(sg*np.hanning(len(sg))))[:6000]; Sp=np.log1p(Sp.reshape(-1,50).mean(1)); F.append((Sp-Sp.mean())/(Sp.std()+1e-9))
          F=np.array(F); C=F@F.T/F.shape[1]; k=len(F)
          far=[C[i,j] for i in range(k) for j in range(k) if j-i>=4]
          if far: print(f"  repetition: max far-similarity {max(far):.3f}, mean {np.mean(far):.3f}")
      
    • render.py 3.7 KB
      #!/usr/bin/env python3
      """Render a scene folder's index.html frame by frame with headless Chromium, then encode.
      
      Every frame is window.renderAt(t) from engine.js, so renders are deterministic and split
      across workers. Frame size comes from --size (default 1080x1920, vertical).
      
      usage:
        render.py stills 0.5 3.2 ... [--dir SCENES]     # PNG stills into SCENES/stills/ for visual QA
        render.py video [--dir SCENES] [--fps 30] [--workers 6] [--audio track.wav] [--out recap.mp4]
      """
      import argparse, subprocess, sys, pathlib, math, shutil, os
      from concurrent.futures import ProcessPoolExecutor
      from playwright.sync_api import sync_playwright
      
      HERE = pathlib.Path.cwd()
      URL = None
      W, H = 1080, 1920
      
      
      def _launch(p):
          """Playwright's bundled Chromium, or CHROME_PATH if set."""
          exe = os.environ.get("CHROME_PATH")
          args = ["--font-render-hinting=none", "--disable-lcd-text"]
          return p.chromium.launch(executable_path=exe, args=args) if exe else p.chromium.launch(args=args)
      
      
      def _open(p):
          b = _launch(p)
          pg = b.new_page(viewport={"width": W, "height": H}, device_scale_factor=1)
          pg.goto(URL)
          pg.wait_for_function("window.ENGINE_READY === true")
          pg.evaluate("document.fonts.ready")
          return b, pg
      
      
      def render_slice(args):
          frames, fps, outdir, url, size = args
          global URL, W, H
          URL, (W, H) = url, size
          with sync_playwright() as p:
              b, pg = _open(p)
              for f in frames:
                  pg.evaluate(f"window.renderAt({f / fps})")
                  pg.screenshot(path=str(outdir / f"f{f:05d}.png"), type="png")
              b.close()
          return len(frames)
      
      
      def total_seconds():
          with sync_playwright() as p:
              b, pg = _open(p)
              t = pg.evaluate("window.TOTAL")
              b.close()
          return t
      
      
      def main():
          ap = argparse.ArgumentParser()
          ap.add_argument("mode", choices=["stills", "video"])
          ap.add_argument("times", nargs="*", type=float)
          ap.add_argument("--fps", type=int, default=30)
          ap.add_argument("--workers", type=int, default=6)
          ap.add_argument("--audio")
          ap.add_argument("--out", default="recap.mp4")
          ap.add_argument("--dir", default=".")
          ap.add_argument("--size", default="1080x1920")
          a = ap.parse_args()
          global HERE, URL, W, H
          HERE = pathlib.Path(a.dir).resolve()
          URL = (HERE / "index.html").as_uri()
          W, H = (int(v) for v in a.size.lower().split("x"))
      
          if a.mode == "stills":
              out = HERE / "stills"; out.mkdir(exist_ok=True)
              with sync_playwright() as p:
                  b, pg = _open(p)
                  for t in a.times:
                      pg.evaluate(f"window.renderAt({t})")
                      pg.screenshot(path=str(out / f"t{t:06.2f}.png"))
                      print(out / f"t{t:06.2f}.png")
                  b.close()
              return
      
          total = total_seconds()
          n = int(math.ceil(total * a.fps))
          fdir = HERE / "frames"
          shutil.rmtree(fdir, ignore_errors=True); fdir.mkdir()
          slices = [list(range(i, n, a.workers)) for i in range(a.workers)]
          with ProcessPoolExecutor(a.workers) as ex:
              done = sum(ex.map(render_slice, [(s, a.fps, fdir, URL, (W, H)) for s in slices]))
          print(f"rendered {done} frames ({total:.2f}s)")
          cmd = ["ffmpeg", "-v", "error", "-y", "-framerate", str(a.fps), "-i", str(fdir / "f%05d.png")]
          if a.audio:
              fade_st = max(0, total - 2.2)
              cmd += ["-i", a.audio, "-filter_complex",
                      f"[1:a]atrim=0:{total},afade=t=in:d=0.4,afade=t=out:st={fade_st}:d=2.2,volume=0.9[a]",
                      "-map", "0:v", "-map", "[a]", "-c:a", "aac", "-b:a", "192k"]
          cmd += ["-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "17", "-preset", "slow",
                  "-movflags", "+faststart", "-t", f"{total}", str(HERE / a.out)]
          subprocess.run(cmd, check=True)
          print(HERE / a.out)
      
      
      if __name__ == "__main__":
          main()
      
  • templates
    • base.css 1.4 KB · in bundle
    • demo-spec.example.json 1.2 KB
      {
        "name": "saved-filters-explained",
        "manifest": "raw/saved-filters.manifest.json",
        "address": "app.example.com/projects/demo",
        "theme": {"accent": "#6366f1", "accent_soft": "#a5b4fc", "ink": "#111318", "paper": "#e5e7eb", "bg": "#eef0f3"},
        "intro": {"dur": 5.5, "kicker": "Release 2026-10-01", "title": "Saved <em>filters</em>",
                  "body": "Save any filter combination on the results page and reopen it from the sidebar. Before, filters reset on every visit.",
                  "foot": "Live demo on a demo project"},
        "segments": [
          {"src": [0, 6.0], "speed": 1, "step": "STEP 1", "caption": "Filter the results to failed runs on Chrome"},
          {"src": [6.0, 21.0], "speed": 2.5, "step": "STEP 2", "caption": "Name the filter and save it. Sped up 2.5x"},
          {"src": [21.0, 33.0], "speed": 1, "step": "STEP 3", "caption": "It appears in the sidebar and reopens with both conditions applied",
           "zoom": {"box": [560, 185, 2120, 906], "at": 0.3, "dur": 1.0}, "ring": {"box": [600, 430, 2080, 536], "at": 1.8}}
        ],
        "outro": {"dur": 4.5, "kicker": "Available now", "title": "Filters that <em>stay</em>", "body": "Saved filters are per user and per project.", "foot": "Release 2026-10-01"}
      }
      
    • index.html 2.8 KB · in bundle
    • music-plan.example.json 1.3 KB
      {
       "composition_plan": {
        "positive_global_styles": [
         "upbeat instrumental product launch music",
         "120 bpm",
         "bright and playful",
         "punchy modern drums",
         "bouncy synth bass",
         "clear downbeats for video cuts"
        ],
        "negative_global_styles": [
         "vocals",
         "sleepy",
         "ambient",
         "lo-fi",
         "aggressive",
         "repetitive loop"
        ],
        "sections": [
         {
          "section_name": "Title intro",
          "positive_local_styles": [
           "introductory",
           "rising filtered synth swell",
           "snare roll into the downbeat"
          ],
          "negative_local_styles": [
           "full drums"
          ],
          "duration_ms": 4000,
          "lines": []
         },
         {
          "section_name": "Features",
          "positive_local_styles": [
           "full groove",
           "new hook every 8 bars"
          ],
          "negative_local_styles": [
           "repeating the intro"
          ],
          "duration_ms": 56000,
          "lines": []
         },
         {
          "section_name": "Summary lift",
          "positive_local_styles": [
           "bigger brighter chords",
           "energy lift"
          ],
          "negative_local_styles": [],
          "duration_ms": 6000,
          "lines": []
         },
         {
          "section_name": "Outro",
          "positive_local_styles": [
           "final hit then ring out"
          ],
          "negative_local_styles": [
           "fade to nothing"
          ],
          "duration_ms": 5000,
          "lines": []
         }
        ]
       }
      }
  • SKILL.md 6.6 KB
    ---
    name: release-video
    description: Turn a product release (the list of shipped items plus real screen recordings) into a motion recap video and one explained demo per feature, with sound effects tied to on-screen motion and a composed music bed. Deterministic HTML scenes rendered frame by frame, ElevenLabs for sound effects and music, ffmpeg for the mix. Use when asked for a release video, a changelog video, a feature recap reel, or demo clips for release notes and docs.
    ---
    
    # release-video
    
    Two outputs per release:
    
    - **Recap**: a 45-75 s motion video, one scene per shipped feature, vertical (1080x1920) by default.
    - **Explained demos**: one 30-60 s clip per feature, a real recording of the product with step captions, eased zoom on the payoff, and intro/outro cards (1920x1080).
    
    Every frame of the recap is a pure function of time (`window.renderAt(t)` in `scripts/engine.js`), so a re-render is identical, renders split across workers, and any single frame can be pulled as a still for review before the full render.
    
    This skill makes motion scenes and composes demos from recordings. It does not cut raw footage of people talking. For narrated takes, founder voice-overs or interviews, use [browser-use/video-use](https://github.com/browser-use/video-use), which cuts on word boundaries from a transcript, and drop the recap and demo clips into its folder as B-roll.
    
    ## Requirements
    
    `ffmpeg`, Python 3 with `playwright`, `numpy`, `Pillow`, and `ELEVENLABS_API_KEY` for audio. If Playwright's bundled Chromium doesn't launch, point `CHROME_PATH` at any Chromium or headless-shell binary.
    
    ## Rules from review
    
    These came from a real review of the first cut. Each one cost a full re-render when it was missed.
    
    1. **Sound comes from the motion.** Every sound effect is declared on the element that moves (`data-sfx="pop,0.5"`) and lands on that element's animation start. A looping background track as the only audio reads as filler.
    2. **Music is composed to the timeline.** Write a sectioned plan whose section lengths match the scenes: a quieter intro under the title, the full groove from the first feature, a lift on the summary, a final hit on the outro. Pick a tempo where scene boundaries land on beats (120 BPM puts a beat on every half second). Generate two or three candidates and keep the one `music_check.py` scores least repetitive.
    3. **Do not reuse the reference video's ideas.** If you were shown a video to match, take its pacing and polish. Its visual metaphors belong to its subject. A radar chart made sense for a tech-radar release and means nothing for yours.
    4. **Each illustration shows its feature.** A filter feature shows the filter conditions. A speed feature shows the before and after number. Generic shapes that could sit in any scene get replaced.
    5. **Complete shapes only.** Draw-on strokes must finish closed and whole. Half-drawn arcs and dangling lines at rest read as low quality.
    6. **Pace for reading.** Feature scenes run 6-8 s, long enough to read every line twice. In demos, sped-up stretches say so in the caption ("sped up 2.5x"), and the payoff plays at 1x with a zoom and a highlight ring.
    7. **Real product, real data.** Demos come from recordings of the live product on a demo project. Numbers on screen match what the recording shows.
    
    ## Workflow
    
    1. **Inventory.** List the shipped items from the release milestone or changelog. Pick the ones a user would notice and leave internal fixes to the written notes. For each, write one sentence of what changed and one of why it matters.
    2. **Record.** Capture each feature end to end in the real product. Any recorder works. `scripts/frames_from_video.sh rec.mp4 <name>` turns a recording into the frames folder and manifest the composer reads.
    3. **Storyboard.** Copy `templates/` into a working folder with `scripts/engine.js`. One `<section class="scene">` per beat, with `data-start` and `data-dur` in seconds. Element timings are relative to their scene. The comment at the top of `templates/index.html` lists every animation attribute.
    4. **Review stills before rendering.** `render.py stills <t1> <t2> ... --dir <folder>` writes PNGs at chosen times. Take one per scene midpoint, tile them into a contact sheet with ffmpeg `hstack`, and look at it against the rules above. Fix, then re-take the stills.
    5. **Sound effects.** `gen_audio.sh sfx <name> "<prompt>" <seconds> <folder>/sfx` once per sound (whoosh, pop, tick, chime, thump, a soft riser). Keep them short and quiet in character. `mix_sfx.py --dir <folder>` reads every `data-sfx` cue from the page and builds `sfx-track.wav`.
    6. **Music.** Adapt `templates/music-plan.example.json` so section durations sum to the video length, then `gen_audio.sh music plan.json bed-a.mp3`. Run `music_check.py bed-*.mp3`, which prints scores and rejects nothing itself, and keep the candidate with the lowest far-similarity and an intro quieter than the body.
    7. **Render and mix.** `render.py video --dir <folder> --out recap.mp4`, then `mix_final.sh recap.mp4 sfx-track.wav bed-a.mp3 recap-final.mp4`. The mix ducks music under each effect, normalizes toward -18 LUFS (one loudnorm pass lands within about 1 LU) with true peak under -1.5 dBTP, writes a smaller `-web.mp4`, and lists any silence over 1 s.
    8. **Demos.** Write a spec per feature from `templates/demo-spec.example.json`: the recording's manifest, segments with source time ranges, speed, a caption per step, and zoom plus ring boxes on the payoff. `compose_demo.py spec.json` renders it. Pull a frame from each segment and check the captions are readable and the ring sits on the right element.
    9. **Self-review.** Watch the final at 1x before handing it over. Check each rule, then check that every number on screen matches the release notes.
    
    ## Files
    
    | Path | What it does |
    |---|---|
    | `scripts/engine.js` | Scene engine: timing attributes, easing, draw-on, typewriter, counters, canvas burst, SFX cue export |
    | `scripts/render.py` | Stills for review, or a parallel frame render encoded with ffmpeg |
    | `scripts/mix_sfx.py` | Builds the effects track from `data-sfx` cues |
    | `scripts/gen_audio.sh` | ElevenLabs sound effects and composed music |
    | `scripts/music_check.py` | Tempo, beat phase, loudness curve and repetition score per candidate |
    | `scripts/mix_final.sh` | Sidechain mix, loudness check, web copy, silence check |
    | `scripts/compose_demo.py` | Recording, window frame, captions, zoom, ring, intro and outro cards |
    | `scripts/frames_from_video.sh` | Recording to frames plus manifest |
    | `templates/` | Starter scenes, theme tokens, demo spec, music plan |
    
    Theme lives in CSS variables in `templates/base.css` and in `spec["theme"]` for demos. Swap in your product's colors and fonts there; the rest of the pipeline carries no brand.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related