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
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-myth-vs-fact
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
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. Writesbeat-manifest.json+whisper/words-flat.jsoninto the work dir.--no-whisperkeeps the config durations un-snapped for a fully offline run.scripts/render_beats.py— the deterministic renderer. Permgbeat: pick the role template underhyperframes/, inject the beat's copy + palette + fonts aswindow.BEAT, drivewindow.renderAt(t)frame-by-frame via Playwright, screenshot each frame → ffmpeg at EXACTLY the configured fps (default 25/1). Theend-cardbeat is built from the pre-supplied brand PNG (scale/crop + a ~0.35s fade-up) — never generated per run.scripts/make_captions.py— karaoke.assfrom 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.assLAST → master mp4.scripts/config.example.json— the shape of the brandconfigthe 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(theinitRenderer/springScale/buildLineStrikes+strikeLinesper-line-strike /popIn/revealWordshelpers + 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 eitherfact_line(a[bracketed]phrase becomes the accented payload) ORfact_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.pyenforces + 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 theNamefield 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 dBunder 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; onlyrender_beats.pyneeds 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 paidcreate-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.
Reviews (0)
No reviews yet.
No comments yet.