Claude Skill

render-myth-vs-fact

Assemble a myth-vs-fact kinetic-typography explainer video ad (≈29.5s, 9:16) from N myth/fact pairs + hook / turn / punch copy + palette + a brand end-card PNG + a VO track — a hook, 3 red-strike MYTH cards that flip to teal-check FACT cards (per-line strikethrough that crosses E

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

Full trust report

Download gooseworks-ai-goose-skills-skills_ads_capabilities_render-myth-vs-fact-e1592ee.zip · 31 KB
Part of gooseworks-ai/goose-skills — 44 skills

Install

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

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

Skill manifest

render-myth-vs-fact

The free, deterministic renderer for the myth-vs-fact video ad format — the calm, sound-off-safe kinetic-typography explainer that busts N common myths and hands the viewer a credible resolution. Red-strike MYTH cards flip to teal-check FACT cards over a calm-authority VO, then a "what actually works" turn + an optional proof reveal + a punch line + a static brand end card.

Every on-screen word is a deterministic HTML hyperframe — no AI image/video gen, no b-roll, no character. Visuals cost $0. The only metered spend is upstream (VO + Whisper word-timestamps + an optional music bed), gated to its own capabilities. This capability OWNS the whole FREE assembly: beat-snap → render → captions → mix → burn → master. Iterate the cut for free; re-roll only the offending paid audio beat.

It ports the validated build from the Clinikally "acne myths" run — brand-neutralised, config-driven, and portable (no /Users, no clients/; everything via --config + --work-dir).

SOUND-OFF SAFE is the whole point: every claim is legible on-screen and the VO only reinforces it. VO-FIRST: render the VO, extract Whisper word onsets on the RENDERED audio, then snap every beat boundary + strike wipe + reveal to those onsets.

The 8-beat spine (roles)

hook → 3× myth-fact (the flip triad — identical grammar so it reads as a pattern) → turn (the "what actually works" pivot) → proof (optional actives/proof reveal, omit if empty) → punch (full-frame closer) → end-card (the static brand PNG). Each beat carries its role, duration, and its copy; ONE role template renders any pair.

Scripts (free — Python + Playwright + ffmpeg, no paid calls)

  • scripts/beat_snap.py — VO-first alignment. FAL Whisper word-timestamps on the RENDERED VO → re-snap every beat boundary to the nearest word onset. Writes beat-manifest.json + whisper/words-flat.json into the work dir. --no-whisper keeps the config durations un-snapped for a fully offline run.
  • scripts/render_beats.py — the deterministic renderer. Per mg beat: pick the role template under hyperframes/, inject the beat's copy + palette + fonts as window.BEAT, drive window.renderAt(t) frame-by-frame via Playwright, screenshot each frame → ffmpeg at EXACTLY the configured fps (default 25/1). The end-card beat is built from the pre-supplied brand PNG (scale/crop + a ~0.35s fade-up) — never generated per run.
  • scripts/make_captions.py — karaoke .ass from the manifest + Whisper words. ≤3 words per cue; close on a >0.4s gap / beat-window edge / sentence-ending punctuation. Captions are burned ONLY in caption-allowed windows; the proof + end-card beats are suppressed.
  • scripts/compose.py — the assembler: concat the beats → mix VO + optional music (music −20 dB, amix normalize=0, ~0.8s tail fade) → burn the .ass LAST → master mp4.
  • scripts/config.example.json — the shape of the brand config the recipe binds (the brand-neutralised Clinikally values as a worked reference).
  • scripts/hyperframes/ — the bundled hyperframe scaffold: _shared.css (palette-tokened tokens + card/tag/fact/pill/chain type), _shared.js (the initRenderer / springScale / buildLineStrikes + strikeLines per-line-strike / popIn / revealWords helpers + config injection), and one template per role (beat-hook.html, beat-myth-fact.html, beat-turn.html, beat-proof.html, beat-punch.html).

Inputs (all via --config + a runtime work dir — NO hardcoded paths)

config.json carries: fps (25) / width / height; vo + optional music + mix{music_db:-20, tail_fade:0.8}; palette (the five CSS-var tokens bg, myth_strike, fact_accent, headline_ink, accent); brand_name; display_font; end_card_png + end_card_fade; caption_style; suppress_beats; and beats[] — each {n, role, duration, captions, cues{...}} plus the role's copy:

  • hook: eyebrow, hook_line, emphasis, strike_word
  • myth-fact: myth_index, myth_line, and either fact_line (a [bracketed] phrase becomes the accented payload) OR fact_clauses[] (a staggered clause chain)
  • turn: turn_slate, turn_sub ([brackets] → emphasis)
  • proof: proof_eyebrow, proof_items[] ({name, badge}), proof_footnote
  • punch: punch_line
  • end-card: none (built from end_card_png)

The recipe's myth_fact_pairs, hook_line, turn_slate, punch_line, palette, end_card_png, and optional actives_or_proof map onto these beats 1:1. See config.example.json.

Craft rules (load-bearing — faithful to the source molecule)

  • MYTH strikethrough is PER-LINE. Measure each wrapped line box (Range.getClientRects, deduped to one rect per visual line) and lay one red bar at each line's vertical MIDDLE, driven as ONE continuous L→R sweep. A single fixed-Y rule reads as an underline the moment the headline wraps.
  • The end card is the pre-built brand PNG — NEVER generated per run. Scale/crop to the canvas with a short fade-up; it carries its own baked logo + claim + CTA.
  • Animation is a pure function of beat-local time — no setTimeout, no CSS keyframes — so Playwright seeks frame-exact and the render is fully reproducible.
  • Every beat mp4 is exactly the configured fps (25/1). render_beats.py enforces + warns; a mismatch makes the concat demuxer silently drop frames.
  • All text fits the 88% safe area at the ~15% spring-overshoot PEAK, not at rest.
  • Captions burned LAST, ≤3 words/cue, closing on >0.4s gap / window edge / sentence end. The ASS Events Format: line MUST carry the Name field or every cue gets a leading-comma artifact. Suppress the proof/footnote + end-card beats (two text layers at one spot both go unreadable).
  • Mix constants are validated — music −20 dB under the VO, amix normalize=0 (with normalize on the bed pumps), ~0.8s tail fade. Sound-off must still work without the bed.
  • Keep the MYTH triad's flip grammar + internal timing identical so it reads as a pattern (anaphora).

Requires

  • Python 3 + Playwright chromium (pip install playwright && playwright install chromium) for the frame-exact hyperframe render, and ffmpeg/ffprobe on PATH. If Playwright is unavailable, compose.py + make_captions.py (the concat / mix / caption path) still run; only render_beats.py needs the browser.
  • watch (QC the final master — the red strike crosses the vertical MIDDLE of EVERY wrapped myth line, the VO is intelligible, captions are legible with no card collision, suppression is correct on the proof + end-card beats, framerate is uniform 25/1, no clipping, every claim is legible sound-off). The recipe gates the paid create-vo-eleven (VO), create-music-elevenlabs (bed), and FAL Whisper calls — this capability itself makes NO paid calls.
Files (goose-skills)
  • scripts
    • hyperframes
      • beat-hook.html 3.2 KB · in bundle
      • beat-myth-fact.html 4.1 KB · in bundle
      • beat-proof.html 2.9 KB · in bundle
      • beat-punch.html 1.2 KB · in bundle
      • beat-turn.html 1.9 KB · in bundle
      • _shared.css 6.7 KB · in bundle
      • _shared.js 7.8 KB
        // render-myth-vs-fact — shared helpers + renderer init for hyperframes.
        // Ported (generalized, brand-neutral) from the validated Clinikally "acne myths" run.
        //
        // Each beat's HTML calls initRenderer(duration, renderFn) at the end of its <script>.
        // initRenderer wires up window.renderAt(t) for the headless renderer (render_beats.py
        // drives it frame-by-frame) AND a preview auto-loop for in-browser viewing. The
        // auto-loop yields whenever renderAt(t) is called externally.
        //
        // The whole point: animation is a PURE FUNCTION of beat-local time. NO setTimeout, NO
        // CSS keyframes — so Playwright can seek any frame deterministically and the render is
        // frame-exact + reproducible.
        
        const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
        const lerp = (a, b, t) => a + (b - a) * t;
        const easeOut = (t) => 1 - Math.pow(1 - t, 3);
        const easeInOut = (t) => t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
        
        // Spring scale: starts 0.4, overshoots 1.08 at 0.55, settles 1.0 at 1.0.
        // The ~15% overshoot is why every beat sizes its text to fit the 88% safe area at the
        // PEAK of the overshoot, not at rest.
        function springScale(t) {
          if (t <= 0) return 0.4;
          if (t >= 1) return 1.0;
          if (t < 0.55) {
            const u = t / 0.55;
            return 0.4 + (1.08 - 0.4) * easeOut(u);
          } else {
            const u = (t - 0.55) / 0.45;
            return 1.08 - 0.08 * easeOut(u);
          }
        }
        
        // Stagger word-reveals over a span: given an array of elements and a `starts` array of
        // absolute (beat-local) times, set opacity + translateY. Used for FACT clause chains and
        // staggered proof pills.
        function revealWords(els, starts, t, dur = 0.24, yOffset = 28) {
          els.forEach((el, i) => {
            const u = clamp((t - starts[i]) / dur, 0, 1);
            const e = easeOut(u);
            el.style.opacity = e;
            el.style.transform = `translateY(${(1 - e) * yOffset}px)`;
          });
        }
        
        // Time-window helpers: eased / linear progress over [start, end].
        function tw(t, start, end) {
          if (end <= start) return t >= start ? 1 : 0;
          return easeOut(clamp((t - start) / (end - start), 0, 1));
        }
        function tw_lin(t, start, end) {
          if (end <= start) return t >= start ? 1 : 0;
          return clamp((t - start) / (end - start), 0, 1);
        }
        
        // Single-bar strikethrough wipe L->R (scale a left-anchored bar's X over [start,end]).
        // For MULTI-LINE myths use buildLineStrikes + strikeLines instead (a single fixed-Y rule
        // reads as an underline the moment the headline wraps).
        function strikeWipe(el, t, start, end) {
          const p = tw_lin(t, start, end);
          el.style.transform = `scaleX(${p})`;
          el.style.opacity = p > 0 ? 1 : 0;
        }
        
        // Per-line strikethrough (the signature MYTH mechanic). Headlines wrap to N visual lines,
        // so a single fixed-Y bar reads as an underline on line 1 and floats above line 2. This
        // measures the actual rendered line boxes (one rect per visual line via
        // Range.getClientRects, deduped) and lays one red bar per line at that line's vertical
        // MIDDLE, so the rule crosses the words on EVERY wrapped line. Bars are appended once into
        // `host` (an element overlaying the text, e.g. the .myth-line itself, position:relative).
        // The whole set wipes L->R as one continuous sweep over [start,end], proportional to total
        // ink width so the wipe speed reads identically on 1- or 2-line myths.
        function buildLineStrikes(textEl, host, opts) {
          opts = opts || {};
          const color = opts.color || getComputedStyle(document.documentElement)
            .getPropertyValue('--myth-strike').trim() || '#C0392B';
          const thickness = opts.thickness || 10;
          const radius = opts.radius || 6;
          // Clear any prior bars (idempotent across re-measures).
          Array.from(host.querySelectorAll('.line-strike')).forEach((b) => b.remove());
          const range = document.createRange();
          range.selectNodeContents(textEl);
          const hostBox = host.getBoundingClientRect();
          // Dedup rects to one per visual line (selectNodeContents can emit dupes).
          const rects = Array.from(range.getClientRects()).filter((r) => r.width > 2 && r.height > 2);
          const lines = [];
          rects.forEach((r) => {
            const hit = lines.find((l) => Math.abs(l.top - r.top) < r.height * 0.5);
            if (hit) {
              hit.left = Math.min(hit.left, r.left);
              hit.right = Math.max(hit.right, r.right);
              hit.bottom = Math.max(hit.bottom, r.bottom);
            } else {
              lines.push({ top: r.top, bottom: r.bottom, left: r.left, right: r.right });
            }
          });
          lines.sort((a, b) => a.top - b.top);
          const bars = [];
          let totalW = 0;
          lines.forEach((l) => { totalW += (l.right - l.left); });
          let acc = 0;
          lines.forEach((l) => {
            const w = l.right - l.left;
            const bar = document.createElement('span');
            bar.className = 'line-strike';
            bar.style.position = 'absolute';
            bar.style.left = (l.left - hostBox.left) + 'px';
            // vertical middle of THIS line, centered on the bar thickness
            bar.style.top = (((l.top + l.bottom) / 2) - hostBox.top - thickness / 2) + 'px';
            bar.style.width = w + 'px';
            bar.style.height = thickness + 'px';
            bar.style.background = color;
            bar.style.borderRadius = radius + 'px';
            bar.style.transformOrigin = 'left center';
            bar.style.transform = 'scaleX(0)';
            bar.style.opacity = '0';
            bar.style.pointerEvents = 'none';
            host.appendChild(bar);
            bars.push({ el: bar, frac0: acc / totalW, frac1: (acc + w) / totalW });
            acc += w;
          });
          return bars;
        }
        
        // Drive a set of per-line strike bars built by buildLineStrikes as one continuous L->R
        // sweep over [start,end]. Each bar fills proportional to its share of total ink.
        function strikeLines(bars, t, start, end) {
          const p = tw_lin(t, start, end);
          bars.forEach((b) => {
            let local;
            if (p <= b.frac0) local = 0;
            else if (p >= b.frac1) local = 1;
            else local = (p - b.frac0) / Math.max(1e-6, b.frac1 - b.frac0);
            b.el.style.transform = `scaleX(${local})`;
            b.el.style.opacity = local > 0 ? 1 : 0;
          });
        }
        
        // Pop-in with spring overshoot: returns {opacity, transform} for scale+fade.
        function popIn(t, start, end, fromY) {
          const p = clamp((t - start) / Math.max(0.0001, end - start), 0, 1);
          const s = springScale(p);
          const y = fromY ? (1 - easeOut(p)) * fromY : 0;
          return { opacity: clamp(p * 2, 0, 1), transform: `translateY(${y}px) scale(${s})` };
        }
        
        // ---------------------------------------------------------------------------
        // Config injection. render_beats.py injects the per-beat spec + palette as a global
        // `window.BEAT` before calling renderAt(t). A beat HTML reads window.BEAT to fill its
        // copy + timing, so ONE template renders any myth/fact pair. In-browser preview falls
        // back to whatever <script id="beat-spec"> the file ships with.
        // ---------------------------------------------------------------------------
        function loadBeatSpec() {
          if (window.BEAT) return window.BEAT;
          const tag = document.getElementById('beat-spec');
          if (tag) { try { return JSON.parse(tag.textContent); } catch (e) {} }
          return {};
        }
        
        function applyPalette(pal) {
          if (!pal) return;
          const root = document.documentElement;
          const map = {
            bg: '--bg', myth_strike: '--myth-strike', fact_accent: '--fact-accent',
            headline_ink: '--headline-ink', accent: '--accent',
          };
          Object.keys(map).forEach((k) => { if (pal[k]) root.style.setProperty(map[k], pal[k]); });
        }
        
        // Initialize the renderer. Beat HTMLs call this once with their duration + render fn.
        function initRenderer(duration, renderFn) {
          const _internal = renderFn;
          let lastExternalCallTime = 0;
        
          window.renderAt = function (t) {
            lastExternalCallTime = performance.now();
            _internal(t);
          };
        
          const LOOP_PAUSE = 0.6;
          let autoStart = null;
          function tick(now) {
            if (now - lastExternalCallTime > 400) {
              if (autoStart === null) autoStart = now;
              const elapsed = ((now - autoStart) / 1000) % (duration + LOOP_PAUSE);
              const tt = Math.min(elapsed, duration);
              _internal(tt);
            }
            requestAnimationFrame(tick);
          }
          _internal(0);
          requestAnimationFrame(tick);
        }
        
    • beat_snap.py 6.1 KB
      #!/usr/bin/env python3
      """beat_snap.py — align beat boundaries to VO word onsets (Whisper) + emit words-flat.
      
      VO-FIRST is the whole design: render the VO, transcribe it to word-level timestamps, then
      re-snap every beat boundary to the nearest word ONSET so a beat's on-screen turn lands
      exactly when the VO says it. This ports the re-snap step from the Clinikally run.
      
      Two outputs, written into the work dir:
        - whisper/words-flat.json : [{text, start, end}, ...] (what compose.py's captions read)
        - beat-manifest.json      : the config beats with re-snapped start/end/duration.
      
      Whisper word-timestamps come from fal-ai/whisper (chunk_level=word). Prefer the
      PROXY-ROUTED path so the call bills the Ads agent (never a raw FAL_KEY): the orchestrator
      hosts the rendered VO via MCP `get_upload_url` → `get_download_url` and passes the presigned
      url as `--vo-url` (transcribed through `media_proxy.fal_whisper`). Already have the words?
      pass `--words-file words.json` and beat_snap skips transcription entirely. Legacy: a raw
      `FAL_KEY` in the env still works via `fal_client`. Fully offline: `--no-whisper` keeps the
      config durations un-snapped (NOTE: no words ⇒ no karaoke captions). On-card text is the
      source of truth, so brand-name homophones in the transcript are fine.
      
      Usage:
        beat_snap.py --config config.json --work-dir DIR --vo-url <presigned-url>   # proxy (preferred)
        beat_snap.py --config config.json --work-dir DIR --words-file words.json    # pre-fetched
        beat_snap.py --config config.json --work-dir DIR --vo vo.mp3                 # legacy FAL_KEY
        beat_snap.py --config config.json --work-dir DIR --no-whisper               # offline
      
      ENV: GW_PROJECT_ID (attributes proxy spend to the ad project); FAL_KEY only for the legacy path.
      """
      import argparse, json, os
      from pathlib import Path
      
      
      def transcribe_proxy(vo_url):
          """Proxy-routed Whisper (bills the Ads agent). Needs media_proxy.py on sys.path —
          the orchestrator fetches it with the create-vo-elevenlabs / media-proxy capability."""
          from media_proxy import fal_whisper
          return fal_whisper(vo_url)
      
      
      def transcribe_fal(vo_path):
          """Legacy: a raw FAL_KEY in the env (NOT proxy-routed — does not bill the Ads agent)."""
          import fal_client
          if "FAL_KEY" not in os.environ and "FAL_API_KEY" in os.environ:
              os.environ["FAL_KEY"] = os.environ["FAL_API_KEY"]
          url = fal_client.upload_file(str(vo_path))
          res = fal_client.subscribe("fal-ai/whisper", arguments={
              "audio_url": url, "task": "transcribe", "language": "en", "chunk_level": "word"})
          words = []
          for ch in res.get("chunks", []):
              ts = ch.get("timestamp") or [None, None]
              words.append({"text": ch.get("text", "").strip(), "start": ts[0], "end": ts[1]})
          return words
      
      
      def ffprobe_dur(path):
          import subprocess
          r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                              "-of", "csv=p=0", str(path)], capture_output=True, text=True)
          return float(r.stdout.strip())
      
      
      def nearest_onset(t, words, tol=0.6):
          """Snap `t` to the closest word start within `tol` seconds; else return t unchanged."""
          best, bd = t, tol
          for w in words:
              if w.get("start") is None:
                  continue
              d = abs(w["start"] - t)
              if d < bd:
                  best, bd = w["start"], d
          return best
      
      
      def main():
          ap = argparse.ArgumentParser(description="Snap beats to VO word onsets.")
          ap.add_argument("--config", required=True)
          ap.add_argument("--work-dir", required=True)
          ap.add_argument("--vo", help="VO mp3 (defaults to config.vo) — legacy raw-FAL_KEY path")
          ap.add_argument("--vo-url", help="presigned PUBLIC url of the rendered VO → proxy Whisper (preferred)")
          ap.add_argument("--words-file", help="pre-fetched words-flat.json → skip transcription")
          ap.add_argument("--no-whisper", action="store_true",
                          help="skip transcription; keep config durations (offline; NO captions)")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          work = Path(a.work_dir)
          (work / "whisper").mkdir(parents=True, exist_ok=True)
          beats = cfg["beats"]
          vo = a.vo or cfg.get("vo")
      
          words = []
          if a.words_file:
              words = json.load(open(a.words_file))
              print(f"[whisper] loaded {len(words)} words from {a.words_file}")
          elif a.no_whisper:
              print("[whisper] skipped — using config beat durations un-snapped")
          elif a.vo_url:
              print("[whisper] transcribing via proxy (bills the Ads agent)")
              words = transcribe_proxy(a.vo_url)
              print(f"[whisper] {len(words)} words: " + " ".join(w["text"] for w in words)[:200])
          elif vo and os.path.exists(vo) and ("FAL_KEY" in os.environ or "FAL_API_KEY" in os.environ):
              print(f"[whisper] transcribing {vo} via legacy FAL_KEY (not proxy-billed)")
              words = transcribe_fal(vo)
              print(f"[whisper] {len(words)} words: " + " ".join(w["text"] for w in words)[:200])
          else:
              print("[whisper] no --vo-url / --words-file / FAL_KEY — durations un-snapped, no captions")
      
          (work / "whisper" / "words-flat.json").write_text(json.dumps(words, indent=2))
      
          # Re-snap: walk cumulative starts; snap each boundary to the nearest word onset.
          out_beats, t = [], 0.0
          for i, b in enumerate(beats):
              start = nearest_onset(t, words) if words else t
              dur = float(b["duration"])
              end = start + dur
              # snap the NEXT boundary too, so the beat can flex to the onset that follows.
              if words and i + 1 < len(beats):
                  nb = nearest_onset(t + dur, words)
                  if nb > start + 0.4:  # keep a sane minimum beat length
                      end = nb
              nb_out = dict(b)
              nb_out["start"] = round(start, 3)
              nb_out["end"] = round(end, 3)
              nb_out["duration"] = round(end - start, 3)
              out_beats.append(nb_out)
              t = end
      
          manifest = dict(cfg)
          manifest["beats"] = out_beats
          manifest["total_duration_s"] = round(t, 3)
          (work / "beat-manifest.json").write_text(json.dumps(manifest, indent=2))
          print(f"[manifest] {len(out_beats)} beats, total {t:.2f}s -> {work/'beat-manifest.json'}")
      
      
      if __name__ == "__main__":
          main()
      
    • compose.py 6.8 KB
      #!/usr/bin/env python3
      """compose.py — the deterministic FREE assembler for the myth-vs-fact ad.
      
      Ports the validated assembly recipe from the Clinikally "acne myths" run
      (working/build_master.py), generalized + portable (config-driven, no hardcoded paths):
      
        1. Concat the per-beat mp4s (already rendered by render_beats.py, all at the same fps)
           into a silent master. A framerate mismatch makes the concat demuxer silently drop
           frames, so every beat MUST be the configured fps (render_beats.py enforces + warns).
        2. Mix VO + optional music: music at -20 dB under the VO with a ~0.8s tail fade,
           `amix inputs=2 duration=first normalize=0` (normalize=1 pumps the bed under the VO).
           VO-only path skips the mix. Explicit `-map` so the silent placeholder AAC on the
           beat mp4s never wins the picker.
        3. Burn the karaoke .ass captions LAST onto the silent video + mixed audio -> master mp4.
      
      This capability makes NO paid calls. All inputs come via --config + the work dir; the
      recipe gates the paid VO / music / Whisper calls to their own capabilities.
      
      Usage:
        compose.py --config config.json --work-dir /path/to/work --out master.mp4
                   [--vo vo.mp3] [--music music.mp3] [--captions captions.ass]
      
      Requires: ffmpeg/ffprobe on PATH.
      """
      import argparse, json, os, subprocess, sys
      from pathlib import Path
      
      
      def run(cmd):
          r = subprocess.run(cmd, capture_output=True, text=True)
          if r.returncode:
              sys.stderr.write((r.stderr or "")[-2000:] + "\n")
              sys.exit(f"FAILED: {' '.join(str(c) for c in cmd[:6])} ...")
          return r
      
      
      def ffprobe_dur(path):
          r = subprocess.run(["ffprobe", "-v", "error", "-show_entries", "format=duration",
                              "-of", "csv=p=0", str(path)], capture_output=True, text=True)
          return float(r.stdout.strip())
      
      
      def concat_silent(beats, frames_dir, work, fps):
          lst = work / "concat.txt"
          lst.write_text("\n".join(f"file '{(frames_dir / f'beat-{b['n']}.mp4').resolve()}'"
                                   for b in beats) + "\n")
          out = work / "master-silent.mp4"
          run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0",
               "-i", str(lst), "-c", "copy", str(out)])
          pk = subprocess.check_output(
              ["ffprobe", "-v", "error", "-select_streams", "v:0", "-count_packets",
               "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0", str(out)]).decode().strip()
          dur = ffprobe_dur(out)
          print(f"[concat] {out} packets={pk} dur={dur:.2f}s expected~{dur*fps:.0f} frames")
          return out
      
      
      def mix_audio(vo, music, work, music_db, tail_fade, video_dur=None):
          """Mix VO (+ optional music) → an m4a as long as the VIDEO. The final `burn` uses
          -shortest, so if the audio is shorter than the video (the normal case — the end card
          holds a beat past the last spoken word) the end-card hold gets silently truncated.
          Pad the audio to `video_dur` so the hold survives. `apad` runs BEFORE amix's
          duration=first collapses length, so the padded VO drives the output length."""
          out = work / "mixed-audio.m4a"
          # apad the VO bus to the full video length (harmless no-op when video_dur is None).
          pad = f",apad=whole_dur={video_dur:.3f}" if video_dur else ""
          if music and os.path.exists(music):
              vo_dur = ffprobe_dur(vo)
              fc = (f"[0:a]apad=whole_dur={video_dur:.3f}[vo];" if video_dur else "[0:a]anull[vo];")
              fc += (f"[1:a]volume={music_db}dB,"
                     f"afade=t=out:st={max(0, (video_dur or vo_dur) - tail_fade):.2f}:d={tail_fade}[m];"
                     f"[vo][m]amix=inputs=2:duration=first:normalize=0[a]")
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(vo), "-i", str(music),
                   "-filter_complex", fc, "-map", "[a]",
                   "-c:a", "aac", "-b:a", "192k", "-ar", "44100", str(out)])
              print(f"[mix] {out} (VO + music@{music_db}dB, padded to {video_dur or vo_dur:.2f}s)")
          else:
              run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(vo),
                   "-af", f"anull{pad}", "-c:a", "aac", "-b:a", "192k", "-ar", "44100", str(out)])
              print(f"[mix] {out} (VO only, padded to {video_dur:.2f}s)" if video_dur
                    else f"[mix] {out} (VO only)")
          return out
      
      
      def burn(silent, audio, captions, out):
          ass = str(Path(captions).resolve()) if captions and os.path.exists(captions) else None
          if ass:
              vf = f"[0:v]ass='{ass}'[v]"
              run(["ffmpeg", "-y", "-loglevel", "error",
                   "-i", str(silent), "-i", str(audio),
                   "-filter_complex", vf, "-map", "[v]", "-map", "1:a:0",
                   "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
                   "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", str(out)])
          else:
              # no captions -> just mux the mixed audio over the silent video
              run(["ffmpeg", "-y", "-loglevel", "error",
                   "-i", str(silent), "-i", str(audio),
                   "-map", "0:v:0", "-map", "1:a:0",
                   "-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
                   "-shortest", "-movflags", "+faststart", str(out)])
          print(f"[final] {out} dur={ffprobe_dur(out):.2f}s")
          return out
      
      
      def main():
          ap = argparse.ArgumentParser(description="Assemble the myth-vs-fact master.")
          ap.add_argument("--config", required=True)
          ap.add_argument("--work-dir", required=True)
          ap.add_argument("--out", required=True)
          ap.add_argument("--vo", help="VO mp3 (defaults to config.vo)")
          ap.add_argument("--music", help="music bed (defaults to config.music)")
          ap.add_argument("--captions", help="captions .ass (defaults to work-dir/captions.ass)")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          work = Path(a.work_dir)
          work.mkdir(parents=True, exist_ok=True)
          frames_dir = work / "frames"
          fps = int(cfg.get("fps", 25))
      
          # Prefer the re-snapped manifest if beat_snap.py ran; else the raw config beats.
          manifest_path = work / "beat-manifest.json"
          beats = json.load(open(manifest_path))["beats"] if manifest_path.exists() else cfg["beats"]
      
          vo = a.vo or cfg.get("vo")
          if not vo or not os.path.exists(vo):
              sys.exit(f"VO not found: {vo} (pass --vo or set config.vo)")
          music = a.music or cfg.get("music")
          captions = a.captions or str(work / "captions.ass")
          mix_cfg = cfg.get("mix", {})
          music_db = mix_cfg.get("music_db", -20)
          tail_fade = mix_cfg.get("tail_fade", 0.8)
      
          silent = concat_silent(beats, frames_dir, work, fps)
          # Pad audio to the silent video's real length so -shortest keeps the full end-card hold.
          audio = mix_audio(vo, music, work, music_db, tail_fade, video_dur=ffprobe_dur(silent))
          final = burn(silent, audio, captions, a.out)
      
          md = ffprobe_dur(final)
          expected = sum(float(b["duration"]) for b in beats)
          print(f"WROTE {final}  {md:.2f}s (expected ~{expected:.2f}s, delta {md - expected:+.2f}s)")
      
      
      if __name__ == "__main__":
          main()
      
    • config.example.json 4 KB
      {
        "_comment": "Illustrative worked config — the values are the validated Clinikally 'acne myths' run, brand-neutralised. The recipe binds a NEW brand's own copy / VO / palette / end-card here. Every /abs/or/working/... path is a placeholder to REPLACE with an absolute path or a runtime work-dir path. Pipeline: (1) beat_snap.py re-snaps every beat boundary to the RENDERED VO's Whisper word onsets (writes beat-manifest.json + whisper/words-flat.json into the work dir); (2) render_beats.py renders each beat to frames/beat-NN.mp4 via Playwright at `fps`; (3) make_captions.py emits captions.ass from the manifest + words; (4) compose.py concats the beats + mixes VO/music + burns captions. VO-FIRST: render the VO, then beat_snap, then render_beats.",
      
        "fps": 25,
        "width": 1080,
        "height": 1920,
      
        "vo": "/abs/or/working/voice/vo.mp3",
        "music": "/abs/or/working/music/music.mp3",
        "mix": { "music_db": -20, "tail_fade": 0.8 },
      
        "brand_name": "Brand",
        "display_font": "DM Serif Display",
      
        "palette": {
          "bg": "#F4F1F7",
          "myth_strike": "#C0392B",
          "fact_accent": "#108474",
          "headline_ink": "#411A1F",
          "accent": "#5E3FA0"
        },
      
        "end_card_png": "/abs/or/working/assets/end_card.png",
        "end_card_fade": 0.35,
      
        "caption_style": {
          "font": "Inter",
          "size": 58,
          "primary": "&H001F1A41",
          "outline_colour": "&H00F7F1F4",
          "back_colour": "&H66F7F1F4",
          "border_style": 3,
          "outline": 10,
          "marginv": 150
        },
      
        "_suppress_note": "The proof beat + end card carry their own low-area text; captions there collide. beats flagged captions:false and any role:end-card are auto-suppressed; suppress_beats adds more by beat n.",
        "suppress_beats": ["06", "08"],
      
        "beats": [
          {
            "n": "01", "role": "hook", "duration": 3.97, "captions": true,
            "eyebrow": "3 ACNE \"TIPS\"",
            "hook_line": "Tips a dermatologist would never give.",
            "emphasis": "never",
            "strike_word": "TIPS",
            "cues": { "strike": [2.89, 3.40], "wordmark": [3.45, 3.85] }
          },
          {
            "n": "02", "role": "myth-fact", "duration": 3.44, "captions": true,
            "myth_index": "01",
            "myth_line": "“Pop it and it clears faster.”",
            "fact_line": "Popping pushes bacteria deeper — [scars + dark marks.]",
            "cues": { "strike": [1.36, 1.96], "fact": [2.14, 2.59] }
          },
          {
            "n": "03", "role": "myth-fact", "duration": 4.84, "captions": true,
            "myth_index": "02",
            "myth_line": "“Lemon / toothpaste dries it out.”",
            "fact_line": "DIY burns your skin barrier — [redness, worse marks.]",
            "cues": { "strike": [0.82, 1.42], "fact": [1.42, 1.91] }
          },
          {
            "n": "04", "role": "myth-fact", "duration": 3.54, "captions": true,
            "myth_index": "03",
            "myth_line": "“Scrub harder, wash more.”",
            "fact_clauses": [
              { "text": "Over-washing strips your oil" },
              { "text": "skin overproduces to fight back" },
              { "text": "more breakouts.", "hot": true }
            ],
            "cues": { "strike": [0.82, 1.42], "clauses": [1.42, 2.24, 2.72] }
          },
          {
            "n": "05", "role": "turn", "duration": 4.44, "captions": true,
            "turn_slate": "What actually works is [boring.]",
            "turn_sub": "Ingredients with [real clinical evidence.]",
            "cues": { "head": [0.10, 0.70], "sub": [2.18, 2.78] }
          },
          {
            "n": "06", "role": "proof", "duration": 5.06, "captions": false,
            "proof_eyebrow": "What works — with evidence",
            "proof_items": [
              { "name": "Salicylic acid", "badge": "Rx-grade" },
              { "name": "Benzoyl peroxide", "badge": "Rx-grade" },
              { "name": "Adapalene", "badge": "Rx-grade" }
            ],
            "proof_footnote": "Prescription-grade — [matched to your skin by a doctor.]",
            "cues": { "items": [0.00, 0.92, 2.00], "footnote": [2.58, 3.10] }
          },
          {
            "n": "07", "role": "punch", "duration": 1.12, "captions": true,
            "punch_line": "Stop guessing."
          },
          {
            "n": "08", "role": "end-card", "duration": 3.11, "captions": false
          }
        ]
      }
      
    • make_captions.py 4.6 KB
      #!/usr/bin/env python3
      """make_captions.py — emit a libass .ass karaoke caption file from Whisper words.
      
      Ports the validated caption recipe from the Clinikally run (build_master.build_ass):
        - <=3 words per cue; close a chunk on a >0.4s word gap, a beat-window edge, OR a
          sentence-ending punctuation mark (. ? !) so the last word of one beat never merges
          with the first of the next.
        - Captions are burned ONLY during caption-allowed beat windows. SUPPRESS_BEATS (the
          proof/footnote beat + the end card) carry their own low-area text — two text layers at
          one spot both go unreadable.
        - The ASS `Format:` line MUST carry the `Name` field or every cue gets a leading-comma
          artifact.
      
      Reads the SAME beat-manifest.json + words-flat.json that compose.py reads, so windows stay
      in lockstep with the cut. Suppressed beats come from config.suppress_beats (default: the
      proof beat + the end card).
      
      Usage:
        make_captions.py --manifest beat-manifest.json --words words-flat.json --out captions.ass
                         [--config config.json]
      """
      import argparse, json
      from pathlib import Path
      
      
      def sec_to_ass(t):
          h = int(t // 3600); m = int((t % 3600) // 60); s = t % 60
          return f"{h}:{m:02d}:{s:05.2f}"
      
      
      def caption_windows(beats, suppress):
          return [(b["start"], b["end"]) for b in beats if b["n"] not in suppress]
      
      
      def in_windows(t, wins):
          return any(s <= t < e for s, e in wins)
      
      
      def build_ass(beats, words, suppress, style):
          wins = caption_windows(beats, suppress)
          kept = [w for w in words
                  if w.get("start") is not None and w.get("end") is not None
                  and in_windows((w["start"] + w["end"]) / 2, wins)]
      
          chunks, cur = [], []
          for w in kept:
              if cur:
                  gap = w["start"] - cur[-1]["end"]
                  cross = not in_windows((cur[-1]["end"] + w["start"]) / 2, wins)
                  sent_end = cur[-1]["text"].rstrip().endswith((".", "?", "!"))
                  if gap > 0.4 or len(cur) >= 3 or cross or sent_end:
                      chunks.append(cur); cur = []
              cur.append(w)
          if cur:
              chunks.append(cur)
      
          font = style.get("font", "Inter")
          size = style.get("size", 58)
          prim = style.get("primary", "&H00FFFFFF")     # BGR + alpha; default white
          outline = style.get("outline_colour", "&H00141414")
          back = style.get("back_colour", "&H66000000")
          border = style.get("border_style", 3)          # 3 = opaque box
          outline_w = style.get("outline", 10)
          marginv = style.get("marginv", 150)
      
          L = []
          L += ["[Script Info]", "ScriptType: v4.00+", "PlayResX: 1080", "PlayResY: 1920",
                "WrapStyle: 2", "ScaledBorderAndShadow: yes", ""]
          L += ["[V4+ Styles]",
                ("Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
                 "OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, "
                 "ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, "
                 "MarginR, MarginV, Encoding"),
                (f"Style: Cap,{font},{size},{prim},{prim},{outline},{back},1,0,0,0,100,100,0,0,"
                 f"{border},{outline_w},0,2,90,90,{marginv},1"), ""]
          # NOTE: the Events Format line MUST include `Name` (empty field after Style) or every
          # burned cue gets a leading-comma artifact.
          L += ["[Events]",
                "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text"]
          for ch in chunks:
              txt = " ".join(w["text"] for w in ch).strip()
              L.append(f"Dialogue: 0,{sec_to_ass(ch[0]['start'])},{sec_to_ass(ch[-1]['end'])},"
                       f"Cap,,0,0,0,,{txt}")
          return "\n".join(L) + "\n", len(chunks), len(kept)
      
      
      def main():
          ap = argparse.ArgumentParser(description="Emit karaoke .ass from Whisper words.")
          ap.add_argument("--manifest", required=True)
          ap.add_argument("--words", required=True)
          ap.add_argument("--out", required=True)
          ap.add_argument("--config", help="optional config.json (for suppress_beats + caption_style)")
          a = ap.parse_args()
      
          manifest = json.load(open(a.manifest))
          beats = manifest["beats"]
          words = json.load(open(a.words))
          cfg = json.load(open(a.config)) if a.config else {}
          # Default suppression: any beat flagged captions:false, plus explicit config list.
          suppress = set(cfg.get("suppress_beats", []))
          suppress |= {b["n"] for b in beats if b.get("captions") is False or b.get("role") == "end-card"}
          style = cfg.get("caption_style", {})
      
          text, nchunks, nwords = build_ass(beats, words, suppress, style)
          Path(a.out).write_text(text)
          print(f"captions.ass written — {nchunks} chunks from {nwords} words "
                f"(suppressed beats {sorted(suppress)}) -> {a.out}")
      
      
      if __name__ == "__main__":
          main()
      
    • render_beats.py 7.3 KB
      #!/usr/bin/env python3
      """render_beats.py — deterministically render each myth-vs-fact beat to an mp4.
      
      Ports the validated Playwright + ffmpeg renderer from the Clinikally "acne myths" run,
      generalized to be config-driven and portable (no hardcoded /Users or clients/ paths).
      
      For every `mg` beat the renderer:
        1. picks the role template under hyperframes/ (hook | myth-fact | turn | proof | punch),
        2. injects the beat's spec + palette + fonts as `window.BEAT` (so ONE template renders
           any myth/fact pair — the copy + cue times all come from config),
        3. drives `window.renderAt(t)` frame-by-frame via Playwright, screenshots each frame,
        4. pipes the frames to ffmpeg at EXACTLY the configured fps (default 25/1 — a mismatch
           makes the concat demuxer silently drop frames), muxing a silent AAC placeholder track
           so the concat demuxer is happy.
      
      The final `end-card` beat is NOT rendered from HTML — it is built from the pre-supplied
      brand end_card_png (scaled/cropped to the canvas with a short fade-up). NEVER generate the
      end card per run.
      
      Animation is a PURE function of beat-local time (no setTimeout / CSS keyframes), so seeks
      are frame-exact and the render is fully reproducible.
      
      Usage:
        render_beats.py --config config.json --work-dir /path/to/work [--only 01 02]
      
      Requires: Playwright (chromium) + ffmpeg/ffprobe.  pip install playwright && playwright install chromium
      """
      import argparse, json, os, subprocess, sys, tempfile, shutil
      from pathlib import Path
      
      HERE = Path(__file__).resolve().parent
      HF = HERE / "hyperframes"
      
      # role -> template filename under hyperframes/
      ROLE_TEMPLATE = {
          "hook": "beat-hook.html",
          "myth-fact": "beat-myth-fact.html",
          "turn": "beat-turn.html",
          "proof": "beat-proof.html",
          "punch": "beat-punch.html",
      }
      
      
      def ffprobe_rate(path):
          r = subprocess.run(
              ["ffprobe", "-v", "error", "-select_streams", "v:0",
               "-show_entries", "stream=r_frame_rate", "-of", "csv=p=0", str(path)],
              capture_output=True, text=True)
          return r.stdout.strip()
      
      
      def render_html_beat(template, out_mp4, duration, beat_spec, fps, w, h):
          """Playwright: inject window.BEAT, step renderAt(t) per frame, screenshot -> ffmpeg."""
          from playwright.sync_api import sync_playwright
      
          # Copy the template + shared assets into an isolated dir so relative _shared.* resolve
          # and window.BEAT is injected before the page's own script runs.
          tmp = Path(tempfile.mkdtemp(prefix="mvf_"))
          frames_dir = tmp / "frames"
          frames_dir.mkdir()
          for asset in ("_shared.css", "_shared.js"):
              shutil.copy(HF / asset, tmp / asset)
          page_html = tmp / "page.html"
          src = (HF / template).read_text()
          inject = f"<script>window.BEAT = {json.dumps(beat_spec)};</script>\n"
          # inject BEFORE _shared.js so loadBeatSpec() picks up window.BEAT.
          src = src.replace('<script src="_shared.js"></script>',
                            inject + '<script src="_shared.js"></script>')
          page_html.write_text(src)
      
          with sync_playwright() as p:
              browser = p.chromium.launch()
              ctx = browser.new_context(viewport={"width": w, "height": h}, device_scale_factor=1)
              page = ctx.new_page()
              page.goto(f"file://{page_html}")
              page.wait_for_load_state("networkidle")
              page.evaluate("document.fonts.ready")
              page.wait_for_timeout(300)  # let web fonts settle after networkidle
              n_frames = int(round(duration * fps))
              for i in range(n_frames):
                  page.evaluate(f"window.renderAt({i / fps})")
                  page.screenshot(path=str(frames_dir / f"frame_{i:05d}.png"),
                                  clip={"x": 0, "y": 0, "width": w, "height": h})
              browser.close()
      
          subprocess.run([
              "ffmpeg", "-y", "-loglevel", "error",
              "-framerate", str(fps), "-i", str(frames_dir / "frame_%05d.png"),
              "-f", "lavfi", "-t", f"{duration}", "-i",
              "anullsrc=channel_layout=stereo:sample_rate=44100",
              "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
              "-r", str(fps), "-c:a", "aac", "-shortest", "-movflags", "+faststart",
              str(out_mp4),
          ], check=True)
          shutil.rmtree(tmp)
      
      
      def build_end_card(png, out_mp4, duration, fps, w, h, fade=0.35):
          """Static end card: scale/crop the pre-built brand PNG to the canvas + a short fade-up."""
          vf = (f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h},"
                f"format=yuv420p,fade=t=in:st=0:d={fade}")
          subprocess.run([
              "ffmpeg", "-y", "-loglevel", "error",
              "-loop", "1", "-framerate", str(fps), "-t", f"{duration}", "-i", str(png),
              "-f", "lavfi", "-t", f"{duration}", "-i",
              "anullsrc=channel_layout=stereo:sample_rate=44100",
              "-vf", vf, "-r", str(fps),
              "-c:v", "libx264", "-pix_fmt", "yuv420p", "-crf", "18", "-preset", "medium",
              "-c:a", "aac", "-shortest", "-movflags", "+faststart", str(out_mp4),
          ], check=True)
      
      
      def main():
          ap = argparse.ArgumentParser(description="Render each myth-vs-fact beat to mp4.")
          ap.add_argument("--config", required=True)
          ap.add_argument("--work-dir", required=True)
          ap.add_argument("--only", nargs="*", help="render only these beat ids (e.g. 01 02)")
          a = ap.parse_args()
      
          cfg = json.load(open(a.config))
          fps = int(cfg.get("fps", 25))
          w = int(cfg.get("width", 1080))
          h = int(cfg.get("height", 1920))
          palette = cfg.get("palette", {})
          fonts = cfg.get("fonts", {})
          display_font = cfg.get("display_font")
          brand_name = cfg.get("brand_name", "")
          end_card_png = cfg.get("end_card_png")
      
          work = Path(a.work_dir)
          frames = work / "frames"
          frames.mkdir(parents=True, exist_ok=True)
      
          failed = []
          for b in cfg["beats"]:
              n = b["n"]
              if a.only and n not in a.only:
                  continue
              role = b.get("role")
              dur = float(b["duration"])
              out = frames / f"beat-{n}.mp4"
      
              if role == "end-card":
                  if not end_card_png or not os.path.exists(end_card_png):
                      print(f"  FAILED beat-{n}: end_card_png missing ({end_card_png})")
                      failed.append(n); continue
                  print(f"[end-card] beat-{n} ({dur}s)")
                  build_end_card(end_card_png, out, dur, fps, w, h,
                                 fade=float(cfg.get("end_card_fade", 0.35)))
                  continue
      
              template = ROLE_TEMPLATE.get(role)
              if not template:
                  print(f"  FAILED beat-{n}: unknown role '{role}'")
                  failed.append(n); continue
      
              # Build the per-beat window.BEAT spec: palette + fonts + duration + the beat's copy.
              spec = dict(b)
              spec["palette"] = palette
              spec["brand_name"] = spec.get("brand_name", brand_name)
              if display_font:
                  spec["display_font"] = display_font
              if fonts:
                  spec["fonts"] = fonts
      
              print(f"[render] beat-{n} ({role}, {dur}s) -> {template}")
              try:
                  render_html_beat(template, out, dur, spec, fps, w, h)
              except Exception as e:
                  print(f"  FAILED beat-{n}: {e}")
                  failed.append(n); continue
      
              rate = ffprobe_rate(out)
              if rate != f"{fps}/1":
                  print(f"  WARN beat-{n} r_frame_rate={rate} (expected {fps}/1)")
      
          if failed:
              print(f"\nFailed beats: {failed}")
              sys.exit(1)
          print("\nAll requested beats rendered.")
      
      
      if __name__ == "__main__":
          main()
      
  • tests
    • smoke-test.md 5.6 KB
      # Smoke test — render-myth-vs-fact
      
      Verifies the free deterministic assembly end-to-end. No paid calls. Needs: Python 3,
      **Playwright chromium** (for the hyperframe render), and `ffmpeg`/`ffprobe`. If
      Playwright/browsers aren't installed, skip Phase A (the render) and validate the
      concat/mix/caption path (Phase B) with synthetic beat mp4s + a synthetic VO.
      
      ## Setup
      
      ```bash
      cd scripts
      python3 -m pip install playwright && playwright install chromium   # if not present
      mkdir -p /tmp/mvf-smoke
      # Copy the example config and REPLACE the paths / copy for a real run:
      #   - "vo"           : the RENDERED VO mp3 (measured; the timeline hangs off it)
      #   - "music"        : an instrumental bed (or delete the key for VO-only)
      #   - "end_card_png" : the pre-built brand end-card PNG (9:16)
      #   - "palette"      : the five CSS-var tokens (bg / myth_strike / fact_accent /
      #                      headline_ink / accent)
      #   - "beats[]"      : the myth/fact pairs + hook / turn / punch copy + cue times
      cp config.example.json /tmp/mvf-smoke/config.json
      ```
      
      ## Phase A — one myth→fact beat (Playwright present)
      
      Renders a single flip card to confirm the frame-exact render + the per-line strike.
      
      ```bash
      # render ONLY the myth-fact beat (id 02 in the example config)
      python3 render_beats.py --config /tmp/mvf-smoke/config.json \
                              --work-dir /tmp/mvf-smoke --only 02
      ffprobe -v error -select_streams v:0 \
        -show_entries stream=r_frame_rate,width,height,nb_frames \
        -show_entries format=duration -of default=nw=1 /tmp/mvf-smoke/frames/beat-02.mp4
      # eyeball a mid-beat frame (FACT resolved + strike done)
      ffmpeg -y -loglevel error -ss 2.4 -i /tmp/mvf-smoke/frames/beat-02.mp4 \
             -frames:v 1 /tmp/mvf-smoke/frame_check.png
      ```
      
      ## Phase B — beat-snap + captions + compose
      
      VO-first: snap beats to the VO, build captions, assemble. Use `--no-whisper` (no FAL) to
      run fully offline off the config durations.
      
      ```bash
      # 1) beat-snap (writes beat-manifest.json + whisper/words-flat.json)
      python3 beat_snap.py --config /tmp/mvf-smoke/config.json \
                           --work-dir /tmp/mvf-smoke --no-whisper     # or drop --no-whisper (needs FAL_KEY)
      
      # 2) captions (from the manifest + words)
      python3 make_captions.py --manifest /tmp/mvf-smoke/beat-manifest.json \
                               --words /tmp/mvf-smoke/whisper/words-flat.json \
                               --out /tmp/mvf-smoke/captions.ass \
                               --config /tmp/mvf-smoke/config.json
      
      # 3) assemble the master (concat beats -> mix VO/music -> burn captions)
      python3 compose.py --config /tmp/mvf-smoke/config.json \
                         --work-dir /tmp/mvf-smoke \
                         --out /tmp/mvf-smoke/master.mp4
      ```
      
      For a Playwright-free assembly check, synthesize the beat mp4s + VO first:
      
      ```bash
      # a 1080x1920 solid-color beat + a matching-length VO tone, per beat n in the manifest
      ffmpeg -y -f lavfi -i color=c=0xF4F1F7:s=1080x1920:d=3.44 -r 25 \
        -f lavfi -t 3.44 -i anullsrc=cl=stereo:r=44100 \
        -c:v libx264 -pix_fmt yuv420p -shortest /tmp/mvf-smoke/frames/beat-02.mp4
      ffmpeg -y -f lavfi -i "sine=frequency=220:duration=3.44" -ar 44100 -ac 2 /tmp/mvf-smoke/vo.mp3
      # then run steps 1-3 above (a single-beat manifest)
      ```
      
      ## Expect
      
      - `render_beats.py` writes `frames/beat-02.mp4`: **1080×1920, `r_frame_rate=25/1`**,
        `nb_frames == round(duration × 25)`, duration within ±0.04s. The mid-beat frame shows a
        red MYTH pill, the myth line struck through with a red bar crossing the vertical MIDDLE of
        **every** wrapped line, and the teal FACT check + payload with the `[bracketed]` phrase
        accented — NOT an underline on line 1 only, NO AI-rendered text.
      - `beat_snap.py` writes `beat-manifest.json` (beats with `start`/`end`/`duration`) +
        `whisper/words-flat.json`.
      - `make_captions.py` writes `captions.ass` — reports `N chunks from M words` and the
        suppressed beats. The `Events Format:` line carries the `Name` field (no leading-comma
        artifact); the proof + end-card beats are suppressed.
      - `compose.py` prints the concat packet count, the mix line (`VO + music@-20dB` or
        `VO only`), and `WROTE … (expected ~Xs, delta ±…)`. `master.mp4` is 1080×1920, 25fps,
        h264+aac; duration within ±0.1s of the summed beat durations; `volumedetect` mean is well
        above silence (VO present).
      - Run the `watch` skill on `master.mp4`: the red strike crosses every wrapped myth line, the
        VO is intelligible, captions are legible with no card collision, suppression is correct on
        the proof + end-card beats, framerate is uniform 25/1, no clipping, every claim is legible
        sound-off.
      
      ## Fail signals
      
      - **Strike reads as an underline on line 1 / floats above line 2** → a single fixed-Y rule
        was used instead of `buildLineStrikes` + `strikeLines`; the per-line measure is
        load-bearing on any wrapped myth.
      - **Concat drops frames / audio desyncs** → a beat mp4 isn't the configured fps.
        `render_beats.py` warns on any `r_frame_rate != <fps>/1`; re-render the offending beat.
      - **Every caption has a leading comma** → the ASS `Events Format:` line is missing the
        `Name` field.
      - **Captions collide with the proof footnote or the end-card text** → those beats weren't
        suppressed; add them to `suppress_beats` or set `captions:false` on the beat.
      - **Music pumps under the VO** → `amix normalize=0` got flipped to `normalize=1`.
      - **Master is silent** (`volumedetect` mean ≈ −90 dB) → the beat mp4s' silent placeholder
        AAC won the picker; `compose.py` maps the mixed audio explicitly — confirm `--vo` resolved.
      - **End card shows AI-rendered art / smeared text** → `end_card_png` points at a generated
        image; the end card is ALWAYS the pre-built brand PNG, never generated per run.
      ```
      
  • SKILL.md 7.9 KB
    ---
    name: render-myth-vs-fact
    description: Assemble a myth-vs-fact kinetic-typography explainer video ad (≈29.5s, 9:16) from N myth/fact pairs + hook / turn / punch copy + palette + a brand end-card PNG + a VO track — a hook, 3 red-strike MYTH cards that flip to teal-check FACT cards (per-line strikethrough that crosses EVERY wrapped line), a "what actually works" turn, an optional proof reveal, a punch line, and a static end card. DETERMINISTIC assembly with ZERO AI-gen visuals — HTML hyperframes rendered frame-exact via Playwright (`window.renderAt(t)`, animation a pure function of beat-local time), Whisper beat-snap to VO word onsets, concat at a uniform fps, karaoke `.ass` captions burned last (suppressed on the proof + end-card beats), and a VO + optional music mix (music −20 dB, `amix normalize=0`, tail fade). FREE (Python + Playwright + ffmpeg); the recipe supplies the copy / palette / end-card / VO and gates the paid VO / music / Whisper calls to their own capabilities. Use for the myth-vs-fact format.
    status: active
    ---
    
    # render-myth-vs-fact
    
    The free, deterministic renderer for the **myth-vs-fact** video ad format — the calm,
    sound-off-safe kinetic-typography explainer that busts N common myths and hands the viewer
    a credible resolution. Red-strike **MYTH** cards flip to teal-check **FACT** cards over a
    calm-authority VO, then a "what actually works" **turn** + an optional **proof reveal** + a
    **punch** line + a static brand **end card**.
    
    Every on-screen word is a **deterministic HTML hyperframe** — **no AI image/video gen, no
    b-roll, no character**. Visuals cost **$0**. The only metered spend is upstream (VO +
    Whisper word-timestamps + an optional music bed), gated to its own capabilities. This
    capability OWNS the whole FREE assembly: beat-snap → render → captions → mix → burn →
    master. Iterate the cut for free; re-roll only the offending paid audio beat.
    
    It ports the validated build from the Clinikally "acne myths" run — brand-neutralised,
    config-driven, and portable (no `/Users`, no `clients/`; everything via `--config` +
    `--work-dir`).
    
    **SOUND-OFF SAFE is the whole point:** every claim is legible on-screen and the VO only
    reinforces it. **VO-FIRST:** render the VO, extract Whisper word onsets on the RENDERED
    audio, then snap every beat boundary + strike wipe + reveal to those onsets.
    
    ## The 8-beat spine (roles)
    
    `hook` → 3× `myth-fact` (the flip triad — identical grammar so it reads as a pattern) →
    `turn` (the "what actually works" pivot) → `proof` (optional actives/proof reveal, omit if
    empty) → `punch` (full-frame closer) → `end-card` (the static brand PNG). Each beat carries
    its `role`, `duration`, and its copy; ONE role template renders any pair.
    
    ## Scripts (free — Python + Playwright + ffmpeg, no paid calls)
    
    - `scripts/beat_snap.py` — VO-first alignment. FAL Whisper word-timestamps on the RENDERED
      VO → re-snap every beat boundary to the nearest word onset. Writes
      `beat-manifest.json` + `whisper/words-flat.json` into the work dir. `--no-whisper` keeps
      the config durations un-snapped for a fully offline run.
    - `scripts/render_beats.py` — the deterministic renderer. Per `mg` beat: pick the role
      template under `hyperframes/`, inject the beat's copy + palette + fonts as `window.BEAT`,
      drive `window.renderAt(t)` frame-by-frame via Playwright, screenshot each frame → ffmpeg
      at EXACTLY the configured fps (default 25/1). The `end-card` beat is built from the
      pre-supplied brand PNG (scale/crop + a ~0.35s fade-up) — **never generated per run**.
    - `scripts/make_captions.py` — karaoke `.ass` from the manifest + Whisper words. ≤3 words
      per cue; close on a >0.4s gap / beat-window edge / sentence-ending punctuation. Captions
      are burned ONLY in caption-allowed windows; the proof + end-card beats are suppressed.
    - `scripts/compose.py` — the assembler: concat the beats → mix VO + optional music (music
      −20 dB, `amix normalize=0`, ~0.8s tail fade) → burn the `.ass` LAST → master mp4.
    - `scripts/config.example.json` — the shape of the brand `config` the recipe binds (the
      brand-neutralised Clinikally values as a worked reference).
    - `scripts/hyperframes/` — the bundled hyperframe scaffold: `_shared.css` (palette-tokened
      tokens + card/tag/fact/pill/chain type), `_shared.js` (the `initRenderer` /
      `springScale` / `buildLineStrikes` + `strikeLines` per-line-strike / `popIn` /
      `revealWords` helpers + config injection), and one template per role (`beat-hook.html`,
      `beat-myth-fact.html`, `beat-turn.html`, `beat-proof.html`, `beat-punch.html`).
    
    ## Inputs (all via `--config` + a runtime work dir — NO hardcoded paths)
    
    `config.json` carries: `fps` (25) / `width` / `height`; `vo` + optional `music` +
    `mix{music_db:-20, tail_fade:0.8}`; `palette` (the five CSS-var tokens `bg`, `myth_strike`,
    `fact_accent`, `headline_ink`, `accent`); `brand_name`; `display_font`; `end_card_png` +
    `end_card_fade`; `caption_style`; `suppress_beats`; and `beats[]` — each `{n, role,
    duration, captions, cues{...}}` plus the role's copy:
    - **hook:** `eyebrow`, `hook_line`, `emphasis`, `strike_word`
    - **myth-fact:** `myth_index`, `myth_line`, and either `fact_line` (a `[bracketed]` phrase
      becomes the accented payload) OR `fact_clauses[]` (a staggered clause chain)
    - **turn:** `turn_slate`, `turn_sub` (`[brackets]` → emphasis)
    - **proof:** `proof_eyebrow`, `proof_items[]` (`{name, badge}`), `proof_footnote`
    - **punch:** `punch_line`
    - **end-card:** none (built from `end_card_png`)
    
    The recipe's `myth_fact_pairs`, `hook_line`, `turn_slate`, `punch_line`, `palette`,
    `end_card_png`, and optional `actives_or_proof` map onto these beats 1:1. See
    `config.example.json`.
    
    ## Craft rules (load-bearing — faithful to the source molecule)
    
    - **MYTH strikethrough is PER-LINE.** Measure each wrapped line box (`Range.getClientRects`,
      deduped to one rect per visual line) and lay one red bar at each line's vertical MIDDLE,
      driven as ONE continuous L→R sweep. A single fixed-Y rule reads as an underline the moment
      the headline wraps.
    - **The end card is the pre-built brand PNG — NEVER generated per run.** Scale/crop to the
      canvas with a short fade-up; it carries its own baked logo + claim + CTA.
    - **Animation is a pure function of beat-local time** — no `setTimeout`, no CSS keyframes —
      so Playwright seeks frame-exact and the render is fully reproducible.
    - **Every beat mp4 is exactly the configured fps (25/1).** `render_beats.py` enforces +
      warns; a mismatch makes the concat demuxer silently drop frames.
    - **All text fits the 88% safe area at the ~15% spring-overshoot PEAK**, not at rest.
    - **Captions burned LAST**, ≤3 words/cue, closing on >0.4s gap / window edge / sentence end.
      The ASS `Events Format:` line MUST carry the `Name` field or every cue gets a
      leading-comma artifact. Suppress the proof/footnote + end-card beats (two text layers at
      one spot both go unreadable).
    - **Mix constants are validated** — music `−20 dB` under the VO, `amix normalize=0` (with
      normalize on the bed pumps), ~0.8s tail fade. Sound-off must still work without the bed.
    - **Keep the MYTH triad's flip grammar + internal timing identical** so it reads as a
      pattern (anaphora).
    
    ## Requires
    
    - **Python 3** + **Playwright chromium** (`pip install playwright && playwright install
      chromium`) for the frame-exact hyperframe render, and **ffmpeg/ffprobe** on PATH. If
      Playwright is unavailable, `compose.py` + `make_captions.py` (the concat / mix / caption
      path) still run; only `render_beats.py` needs the browser.
    - `watch` (QC the final master — the red strike crosses the vertical MIDDLE of EVERY wrapped
      myth line, the VO is intelligible, captions are legible with no card collision,
      suppression is correct on the proof + end-card beats, framerate is uniform 25/1, no
      clipping, every claim is legible sound-off). The recipe gates the paid `create-vo-eleven`
      (VO), `create-music-elevenlabs` (bed), and FAL Whisper calls — this capability itself
      makes NO paid calls.
    
  • skill.meta.json 315 B
    {
      "slug": "render-myth-vs-fact",
      "category": "capabilities",
      "domain": "ads",
      "tags": [
        "ads"
      ],
      "installation": {
        "base_command": "npx goose-skills install render-myth-vs-fact",
        "supports": [
          "claude",
          "cursor",
          "codex"
        ]
      },
      "requires_skills": [
        "watch"
      ]
    }
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related