diff --git a/brands/things-worth-doing/brand.json b/brands/things-worth-doing/brand.json new file mode 100644 index 0000000..6d1e960 --- /dev/null +++ b/brands/things-worth-doing/brand.json @@ -0,0 +1,36 @@ +{ + "name": "Things Worth Doing", + "description": "Field guide / documentary / editorial-magazine aesthetic for a men's competence-and-doing content brand. Deliberately avoids neon, gamer, generic-AI-futuristic, and overly polished corporate looks — see the vault's Brand Strategy and Visual Identity sections.", + "version": "1.0.0", + + "colors": { + "primary": "#B5651D", + "primaryLight": "#D98C4A", + "textDark": "#2A2620", + "textMedium": "#4A4438", + "textLight": "#8A8172", + "bgLight": "#F4EFE6", + "bgDark": "#171410", + "bgOverlay": "rgba(23, 20, 16, 0.85)", + "divider": "#3A352B", + "shadow": "rgba(0, 0, 0, 0.35)" + }, + + "fonts": { + "primary": "Arial, Helvetica, sans-serif", + "mono": "ui-monospace, Menlo, monospace" + }, + + "spacing": { "xs": 8, "sm": 16, "md": 24, "lg": 48, "xl": 80, "xxl": 120 }, + "borderRadius": { "sm": 7, "md": 12, "lg": 16 }, + + "typography": { + "h1": { "size": 88, "weight": 700 }, + "h2": { "size": 72, "weight": 700 }, + "h3": { "size": 48, "weight": 700 }, + "body": { "size": 44, "weight": 400 }, + "label": { "size": 34, "weight": 500, "letterSpacing": 3 } + }, + + "_proposed_default": "Colors are a proposed default matching the 'field guide + documentary' visual identity (warm rust/amber accent, warm off-black background, warm off-white text) — not yet confirmed by Adrian. Change freely; nothing downstream depends on these specific hex values." +} diff --git a/brands/things-worth-doing/voice.json b/brands/things-worth-doing/voice.json new file mode 100644 index 0000000..6e28fc5 --- /dev/null +++ b/brands/things-worth-doing/voice.json @@ -0,0 +1,22 @@ +{ + "voiceId": "cjVigY5qzO86Huf0OWal", + "description": "Voice Brief: calm, confident, conversational, knowledgeable, slightly understated (never announcer-register).", + "settings": { + "stability": 0.68, + "similarityBoost": 0.85, + "style": 0.15, + "useSpeakerBoost": true + }, + "model": "eleven_multilingual_v2", + "qwen3": { + "speaker": "Ryan", + "language": "Auto", + "tone": "", + "instruct": "", + "clone": null + }, + "sixtydb": { + "voiceId": "YOUR_VOICE_ID_HERE", + "settings": { "stability": 0.85, "similarity": 0.9, "speed": 1.0, "enhance": true } + } +} diff --git a/templates/things-worth-doing-short/CLAUDE.md b/templates/things-worth-doing-short/CLAUDE.md new file mode 100644 index 0000000..28869ac --- /dev/null +++ b/templates/things-worth-doing-short/CLAUDE.md @@ -0,0 +1,74 @@ +# things-worth-doing-short — guidance for Claude + +Forked 2026-09-01 from `templates/concept-explainer-short` for the "Things Worth +Doing" men's content brand ([[Men's UGC Project]] in the Obsidian vault, and +[[Playbook - AI Agent Content Sprint (Things Worth Doing)]] for how a batch of +scripts/shot lists gets produced upstream of this template). Same Python/moviepy +pipeline, no Remotion, no npm — `gen_vo.py → gen_captions.py → build.py`, run +**from the project directory**. Differences from the base template are below; +everything not mentioned here works exactly like `concept-explainer-short`. + +## What's different from concept-explainer-short + +1. **One scene = one shot, not one narration beat.** The base template's own + guidance ("~15s pattern interrupt") assumes few, longer scenes. Things Worth + Doing's Production Standard requires visual movement every few seconds, so + TWD scenes run ~2-4s each — map each shot in the sprint playbook's Shot List + directly to one `scenes.json` scene, in order, one VO line per shot. A + 28-second video is normal at 7-9 scenes here, not 4. +2. **`visual` field gets a third real value: `"real"`.** `build.py` actually + decides treatment by file **extension**, not by the `visual` string — it's + documentation only. So a real filmed `.mp4` clip gets the exact same + boomerang-loop treatment an LTX clip would. Tag it `"real"` in scenes.json + purely so a human scanning the file can tell what still needs to be shot + vs. generated — `build.py` doesn't care. +3. **Voice is ElevenLabs by default**, via `config.json → voice.brand: + "things-worth-doing"` (patched into this fork's `gen_vo.py` — the base + template didn't forward `--brand` to `tools/voiceover.py` at all, only + scene text and qwen3-specific fields). Real voiceId is still a placeholder + in `brands/things-worth-doing/voice.json` — see Gotchas. +4. **Palette is the proposed "field guide + documentary" default** in + `config.json`/`brands/things-worth-doing/brand.json` — warm rust/olive + accents, warm off-black/off-white, deliberately not neon or AI-futuristic + (matches the brand's Visual Identity). Not yet confirmed by Adrian; safe to + change, nothing else depends on the specific hex values. +5. **Real footage is expected, not optional.** TWD's playbook prioritizes real + product/filmed footage over AI generation wherever the shot needs to show + the actual product being used (see the playbook's visual hierarchy). Don't + default to generating everything with LTX/Ideogram just because this + template can — check each scene's per-shot production package first. + +## Working on a project copy (same as base template) + +1. Plan in `scenes.json` first — one scene per shot from the video's + production package (VO Script + Shot List sections), not from scratch. +2. Hook discipline: scene 01 must earn the next few seconds — question or + tension immediately, no throat-clearing. +3. `build.py` works at every stage — placeholders before assets, estimates + before VO, silent before audio. Render early, render often; show the user + intermediate renders rather than describing them. + +## Review checklist before calling a video done + +- Pull frames with ffmpeg at several timestamps and *look* at them: caption + collisions, asset crops, placeholder cards left in. +- Check `gen_vo.py`'s per-scene wpm output; anything flagged FAST/SLOW that + `maxWpm` didn't catch needs a script edit or retake. +- Run this video's package through the sprint playbook's QC gates and + Red-Team pass again post-render, not just at pre-production — a render can + surface problems (bad continuity, a shot that doesn't land) invisible in + text form. + +## Gotchas (this fork, so far) + +- **`brands/things-worth-doing/voice.json`'s `voiceId` is still + `"YOUR_VOICE_ID_HERE"`** — `tools/voiceover.py` will error rather than + silently using a default. Replace it with a real ElevenLabs voice ID before + running `gen_vo.py` for real. +- Inherited from the base template: clone pacing follows the reference + recording, `voice.maxWpm` is a safety net not a fix; never burn whisper's + own transcription (captions force-align to script text on purpose); moviepy + 2.x uses `with_*` methods and PIL for text. +- Keep toolkit-level fixes (improving this *template*) in + `templates/things-worth-doing-short/`, not in a copied project under + `projects/` — same separation the base template's CLAUDE.md establishes. diff --git a/templates/things-worth-doing-short/README.md b/templates/things-worth-doing-short/README.md new file mode 100644 index 0000000..f9a5dc2 --- /dev/null +++ b/templates/things-worth-doing-short/README.md @@ -0,0 +1,126 @@ +# things-worth-doing-short + +Forked from `concept-explainer-short` for the "Things Worth Doing" brand — same +mechanics documented below, but **see `CLAUDE.md` in this folder for what's +actually different** (scene cadence, real-footage handling, ElevenLabs/brand +wiring, palette). Read that first. + +--- + +A 9:16 vertical **concept explainer** template — TikTok / Reels / YouTube Shorts +style — built entirely from AI-generated parts and composed with a single Python +build (no Remotion/Node). One concept, hook → explanation → payoff → CTA, with +big burned karaoke captions for sound-off viewing. + +Everything is driven by **`scenes.json`**: each scene is narration text plus a +visual asset. The pipeline turns that into per-scene cloned (or built-in) TTS, +word-perfect caption timing, and an audio-anchored composite where timing drift +is impossible. + +``` +scenes.json ──► gen_vo.py ──► gen_captions.py ──► build.py ──► out/short.mp4 + │ │ + ▼ ▼ + audio/scenes/*.mp3 captions/words_*.json + vo_durations.json +``` + +## Quick start + +```bash +cp -r templates/concept-explainer-short projects/my-short +cd projects/my-short + +# 0. Renders immediately — placeholder cards, estimated timing, silent +uv run build.py + +# 1. Write your scenes (see scenes.json for the example) +# 2. Generate visuals (from the TOOLKIT ROOT — see "Visuals" below) +# 3. Voiceover → captions → final render (from the project dir) +uv run gen_vo.py +uv run gen_captions.py # needs: uv sync --extra whisper +uv run build.py +``` + +## scenes.json + +```json +{ + "title": "Why Is the Sky Blue?", + "scenes": [ + { "id": "01", "slug": "hook", "visual": "ltx", + "asset": "clips/01_hook.mp4", + "text": "Look up on a clear day..." } + ] +} +``` + +- `text` — the narration, verbatim. Captions burn *this* text (whisper only + provides timing), so write it exactly as it should appear. +- `asset` — relative path; the extension decides the treatment: + image → slow Ken Burns zoom, video → boomerang loop cut to the scene, + missing → gradient placeholder (so you can render at any stage). +- `visual` — documentation of intent (`ideogram` / `ltx`); build.py goes by + the asset extension. + +**Pacing budget:** narration ÷ 2.4 ≈ seconds per scene. A 60s short is +~140 words total; the 4-scene example is ~45s. Keep the hook under 3 seconds +of setup. Platform limits: YouTube Shorts ≤ 3 min, Reels ≤ 3 min, +TikTok ≤ 10 min — gen_vo.py and build.py print a warning past 3 minutes. + +## Visuals + +Generate from the **toolkit root**, save into the project's `images/` and +`clips/`. The intended split (see CLAUDE.md "FLUX.2 vs Ideogram 4"): + +```bash +# Text-bearing cards (Ideogram 4, JSON captions; 9:16 = --resolution 1440x2560) +uv run tools/ideogram4.py --json caption.json --resolution 1440x2560 \ + --output projects/my-short/images/02_scattering.png + +# Motion b-roll (LTX-2; 576x1024 scales exactly to 1080x1920) +uv run tools/ltx2.py --width 576 --height 1024 --num-frames 161 \ + --prompt "..." --output projects/my-short/clips/01_hook.mp4 + +# Optional music bed (looped + ducked automatically if present) +uv run tools/music_gen.py --prompt "..." --duration 120 \ + --output projects/my-short/audio/music.mp3 +``` + +Alternate cards and motion so the viewer gets a pattern interrupt every +~15 seconds. Ideogram for anything with legible text; LTX for atmosphere. + +## Voice + +`config.json → voice` selects the narrator: + +```json +"voice": { + "provider": "qwen3", "cloud": "modal", "maxWpm": 165, + "refAudio": "ref/my-voice.m4a", + "refText": "Exact transcript of the reference recording.", + "speaker": "Ryan", "tone": "" +} +``` + +- **Clone**: set `refAudio` + `refText`. Use 12–25s of varied, full-sentence + speech *at narration pace* — the clone copies the reference's pace and + temperature cannot fix a rushed reference (see `/voice-clone`). +- **Built-in**: leave `refAudio` empty; `speaker` + `tone` apply. +- `maxWpm` is the pacing safety net: rushed takes are slowed in place with + pitch-preserving atempo. gen_vo.py prints per-scene wpm either way. +- `provider: "elevenlabs"` also works (uses your configured voice). + +## Captions + +Karaoke pills, 1–3 words at a time, timed from whisper word timestamps but +**force-aligned to your script text** so they're word-perfect. Tune position, +size, and chunking in `config.json → captions`; defaults clear platform UI and +the bottom rows of Ideogram list cards. Set `enabled: false` to skip. + +## Re-rendering + +Everything is idempotent and cached. Re-run `gen_vo.py --force` after script +edits (then `gen_captions.py` again), or just `build.py` after swapping an +asset or tweaking config. Timeline math always re-derives from +`vo_durations.json` — there is nothing to manually re-time. diff --git a/templates/things-worth-doing-short/build.py b/templates/things-worth-doing-short/build.py new file mode 100644 index 0000000..410c35d --- /dev/null +++ b/templates/things-worth-doing-short/build.py @@ -0,0 +1,262 @@ +#!/usr/bin/env python3 +"""Step 3 — compose the short. Audio-anchored moviepy build. + +Scene starts derive from actual VO durations (vo_durations.json), so timing +drift is impossible. Per scene, the visual is picked by asset extension: + + .png/.jpg/.jpeg/.webp → slow Ken Burns zoom (alternating in/out) + .mp4/.mov/.webm → boomerang loop (forward + reversed), cut to fit + missing → gradient placeholder card (render works + out-of-the-box before any assets exist) + +Captions (captions/words_*.json, from gen_captions.py) are burned as karaoke +pills sized/positioned via config.json. VO + ducked looped music are mixed if +present. Run from this project directory: + + uv run build.py +""" +from __future__ import annotations + +import hashlib +import json +import platform +import sys +from pathlib import Path + +try: + from PIL import Image, ImageDraw, ImageFont + from moviepy import ( + AudioFileClip, + ColorClip, + CompositeAudioClip, + CompositeVideoClip, + ImageClip, + VideoFileClip, + concatenate_videoclips, + vfx, + ) + from moviepy.audio.fx.AudioFadeOut import AudioFadeOut + from moviepy.audio.fx.AudioLoop import AudioLoop + from moviepy.audio.fx.MultiplyVolume import MultiplyVolume +except ImportError as e: + print(f"Missing dependency: {e}") + print(" uv sync (from toolkit root)") + sys.exit(1) + +HERE = Path(__file__).resolve().parent +TEXT_CACHE = HERE / ".text_cache" + +CONFIG = json.loads((HERE / "config.json").read_text()) +FMT = CONFIG["format"] +PALETTE = CONFIG["palette"] +CAPTIONS = CONFIG.get("captions", {}) +TIMING = CONFIG.get("timing", {}) + +W, H, FPS = FMT["width"], FMT["height"], FMT["fps"] +START_PAD = TIMING.get("startPad", 0.3) +LEAD = TIMING.get("lead", 0.4) # visual on screen before VO starts +TAIL = TIMING.get("tail", 0.8) # visual hold after VO ends +XFADE = TIMING.get("xfade", 0.45) # fade to black per scene edge + +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"} +VIDEO_EXTS = {".mp4", ".mov", ".webm"} + +_FONT_CANDIDATES = { + "Darwin": ["/System/Library/Fonts/Supplemental/Arial Bold.ttf"], + "Linux": ["/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf"], + "Windows": ["C:/Windows/Fonts/arialbd.ttf"], +} + + +def _load_font(size: int): + for path in _FONT_CANDIDATES.get(platform.system(), []): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + return ImageFont.load_default() + + +def _rgb(hex_color: str) -> tuple: + return tuple(int(hex_color.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4)) + + +def render_caption_png(txt: str) -> str: + """Caption text on a dark rounded pill, cached, width-capped to the safe + area. PIL, not TextClip — moviepy 2.x TextClip clips ascenders.""" + size = CAPTIONS.get("size", 80) + fill = CAPTIONS.get("fill", "#FFFFFF") + TEXT_CACHE.mkdir(parents=True, exist_ok=True) + key = hashlib.sha1(f"{txt}|{size}|{fill}".encode()).hexdigest()[:16] + path = TEXT_CACHE / f"{key}.png" + if path.exists(): + return str(path) + + font = _load_font(size) + sw = max(4, size // 14) + probe = ImageDraw.Draw(Image.new("RGBA", (1, 1))) + bbox = probe.textbbox((0, 0), txt, font=font, stroke_width=sw) + tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1] + pad = max(24, size // 4) + + img = Image.new("RGBA", (tw + pad * 2, th + pad * 2), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + draw.rounded_rectangle((0, 0, img.width - 1, img.height - 1), + radius=img.height // 4, fill=(*_rgb(PALETTE["ink"]), 185)) + draw.text((pad - bbox[0], pad - bbox[1]), txt, font=font, + fill=(*_rgb(fill), 255), stroke_width=sw, + stroke_fill=(*_rgb(PALETTE["ink"]), 235)) + + max_w = int(W * 0.91) + if img.width > max_w: + img = img.resize((max_w, max(1, int(img.height * max_w / img.width))), + Image.LANCZOS) + img.save(path) + return str(path) + + +def placeholder_png(scene: dict, idx: int) -> str: + """Gradient card with the scene slug — used when the asset is missing so + the project renders before any Ideogram/LTX generation has happened.""" + TEXT_CACHE.mkdir(parents=True, exist_ok=True) + accent = PALETTE["accent1"] if idx % 2 == 0 else PALETTE["accent2"] + key = hashlib.sha1(f"ph|{scene['slug']}|{accent}|{W}x{H}".encode()).hexdigest()[:16] + path = TEXT_CACHE / f"{key}.png" + if path.exists(): + return str(path) + + ink, acc = _rgb(PALETTE["ink"]), _rgb(accent) + img = Image.new("RGB", (W, H), ink) + draw = ImageDraw.Draw(img) + for y in range(H): # vertical gradient, accent rising from the bottom + t = max(0.0, y / H - 0.45) * 0.5 + draw.line([(0, y), (W, y)], + fill=tuple(int(i + (a - i) * t) for i, a in zip(ink, acc))) + font = _load_font(110) + label = scene["slug"].replace("_", " ").upper() + bbox = draw.textbbox((0, 0), label, font=font) + draw.text(((W - bbox[2] + bbox[0]) / 2, H * 0.42), label, + font=font, fill=_rgb(PALETTE["white"])) + small = _load_font(40) + hint = f"placeholder — add {scene.get('asset', 'an asset')}" + bbox = draw.textbbox((0, 0), hint, font=small) + draw.text(((W - bbox[2] + bbox[0]) / 2, H * 0.52), hint, + font=small, fill=_rgb(PALETTE["slate"])) + img.save(path) + return str(path) + + +def ken_burns(img_path: str, duration: float, zoom_in: bool) -> ImageClip: + """Slow zoom on a still; base scale covers the frame at any source size.""" + with Image.open(img_path) as im: + base = max(W / im.width, H / im.height) + amp = 0.07 + if zoom_in: + scale = lambda t: base * (1.0 + amp * (t / duration)) + else: + scale = lambda t: base * (1.0 + amp * (1 - t / duration)) + return (ImageClip(img_path).with_duration(duration) + .resized(scale).with_position(("center", "center"))) + + +def boomerang_fill(video_path: str, duration: float): + """Loop a short clip forward/backward until it covers `duration`.""" + fwd = VideoFileClip(video_path).without_audio() + fwd = fwd.resized(max(W / fwd.w, H / fwd.h)).with_position(("center", "center")) + rev = fwd.with_effects([vfx.TimeMirror()]) + segs, total = [], 0.0 + while total < duration + 0.5: + nxt = fwd if len(segs) % 2 == 0 else rev + segs.append(nxt) + total += nxt.duration + return concatenate_videoclips(segs).subclipped(0, duration) + + +def estimate_duration(text: str) -> float: + return max(2.0, len(text.split()) / 2.4) # ~145 wpm fallback estimate + + +def build() -> None: + scenes = json.loads((HERE / "scenes.json").read_text())["scenes"] + dur_path = HERE / "vo_durations.json" + durations = json.loads(dur_path.read_text()) if dur_path.exists() else {} + if not durations: + print("[no vo_durations.json — using word-count estimates, no audio. " + "Run gen_vo.py for the real thing]") + + clips: list = [ColorClip((W, H), color=_rgb(PALETTE["ink"]))] + audio: list = [] + cursor = START_PAD + zoom_in = True + + print("── audio-anchored timeline ──") + for idx, s in enumerate(scenes): + vo_dur = durations.get(s["id"]) or estimate_duration(s["text"]) + scene_start = cursor + audio_start = scene_start + LEAD + scene_dur = LEAD + vo_dur + TAIL + print(f" {s['id']} {s['slug']:16s} {scene_start:7.2f} → " + f"{scene_start + scene_dur:7.2f} (vo {vo_dur:.2f}s)") + + asset = HERE / s["asset"] if s.get("asset") else None + if asset and asset.exists() and asset.suffix.lower() in VIDEO_EXTS: + vis = boomerang_fill(str(asset), scene_dur) + elif asset and asset.exists() and asset.suffix.lower() in IMAGE_EXTS: + vis = ken_burns(str(asset), scene_dur, zoom_in) + zoom_in = not zoom_in + else: + if asset: + print(f" [missing {s['asset']} — placeholder card]") + vis = ken_burns(placeholder_png(s, idx), scene_dur, zoom_in) + zoom_in = not zoom_in + clips.append(vis.with_start(scene_start) + .with_effects([vfx.FadeIn(XFADE), vfx.FadeOut(XFADE)])) + + mp3 = HERE / "audio" / "scenes" / f"{s['id']}_{s['slug']}.mp3" + if mp3.exists() and s["id"] in durations: + audio.append(AudioFileClip(str(mp3)) + .with_effects([MultiplyVolume(1.1)]) + .with_start(audio_start)) + + words_file = HERE / "captions" / f"words_{s['id']}.json" + if CAPTIONS.get("enabled", True) and words_file.exists(): + for ch in json.loads(words_file.read_text()): + clips.append( + ImageClip(render_caption_png(ch["text"].upper())) + .with_duration(max(0.18, ch["end"] - ch["start"])) + .with_start(audio_start + ch["start"]) + .with_position(("center", CAPTIONS.get("y", 1640))) + ) + + cursor = scene_start + scene_dur + + total = cursor + 0.4 + clips[0] = clips[0].with_duration(total) + print(f" total: {total:.2f}s" + + (" (>3 min: fine for TikTok, too long for Shorts/Reels)" if total > 180 else "")) + + music_cfg = CONFIG.get("music", {}) + music_path = HERE / music_cfg.get("file", "audio/music.mp3") + if music_path.exists(): + audio.insert(0, AudioFileClip(str(music_path)).with_effects([ + AudioLoop(duration=total), + MultiplyVolume(music_cfg.get("volume", 0.16)), + AudioFadeOut(2.0), + ])) + + final = CompositeVideoClip(clips, size=(W, H)).with_duration(total) + if audio: + final = final.with_audio(CompositeAudioClip(audio)) + else: + print("[no audio found — rendering silent]") + + out = HERE / "out" / "short.mp4" + out.parent.mkdir(exist_ok=True) + final.write_videofile(str(out), fps=FPS, codec="libx264", + audio_codec="aac", preset="medium", threads=8) + print(f"wrote {out}") + + +if __name__ == "__main__": + build() diff --git a/templates/things-worth-doing-short/config.json b/templates/things-worth-doing-short/config.json new file mode 100644 index 0000000..1b5935b --- /dev/null +++ b/templates/things-worth-doing-short/config.json @@ -0,0 +1,43 @@ +{ + "format": { "width": 1080, "height": 1920, "fps": 30 }, + "palette": { + "ink": "#171410", + "accent1": "#B5651D", + "accent2": "#5C6B47", + "warn": "#B33A3A", + "white": "#F4EFE6", + "slate": "#8A8172" + }, + "_palette_note": "Matches brands/things-worth-doing/brand.json — proposed 'field guide + documentary' default (warm rust/olive, warm off-black/off-white), not yet confirmed by Adrian. Change both files together if it changes.", + "captions": { + "enabled": true, + "y": 1640, + "size": 80, + "maxWords": 3, + "maxChars": 22, + "fill": "#F4EFE6", + "whisperModel": "base" + }, + "timing": { + "startPad": 0.3, + "lead": 0.3, + "tail": 0.5, + "xfade": 0.25 + }, + "_timing_note": "lead/tail/xfade shortened vs. the concept-explainer-short default (0.4/0.8/0.45) — Things Worth Doing's Production Standard calls for visual movement every few seconds, not a ~15s pattern-interrupt cadence, so scenes here run shorter and cut tighter.", + "voice": { + "provider": "elevenlabs", + "brand": "things-worth-doing", + "maxWpm": 165, + "cloud": "modal", + "refAudio": "", + "refText": "", + "speaker": "Ryan", + "tone": "" + }, + "_voice_note": "provider is elevenlabs per Adrian's stated toolkit; brand points gen_vo.py at brands/things-worth-doing/voice.json for the actual voiceId (still a placeholder there — see Gotchas). qwen3/cloud/speaker/tone fields kept only as a fallback if provider is switched back.", + "music": { + "file": "audio/music.mp3", + "volume": 0.16 + } +} diff --git a/templates/things-worth-doing-short/gen_captions.py b/templates/things-worth-doing-short/gen_captions.py new file mode 100644 index 0000000..80dc3e6 --- /dev/null +++ b/templates/things-worth-doing-short/gen_captions.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Step 2 — word-level caption timing for burned karaoke captions. + +Whisper provides word *timestamps*; scenes.json provides ground-truth *text*. +Script words are aligned onto whisper's timeline (difflib word matching, +interpolating unmatched runs) so the burned captions are word-perfect even +when whisper mishears the TTS — never burn whisper's own transcription. + +Requires openai-whisper (not in the toolkit's base requirements — it pulls +in torch): uv sync --extra whisper + +Run from this project directory after gen_vo.py: + uv run gen_captions.py +Writes captions/words_{id}.json — chunks with start/end seconds relative to +each scene's audio file. Captions are optional: build.py renders without them. +""" +from __future__ import annotations + +import difflib +import json +import re +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent + +try: + import whisper +except ImportError: + sys.exit("openai-whisper is required for captions:\n" + " uv sync --extra whisper\n" + "(or set captions.enabled=false in config.json and skip this step)") + + +def norm(w: str) -> str: + return re.sub(r"[^a-z0-9']", "", w.lower()) + + +def align(script_words: list[str], heard: list[dict]) -> list[dict]: + """Map script words onto heard-word timings; interpolate unmatched runs.""" + sm = difflib.SequenceMatcher( + a=[norm(w) for w in script_words], + b=[norm(h["w"]) for h in heard], + autojunk=False, + ) + starts: list[float | None] = [None] * len(script_words) + ends: list[float | None] = [None] * len(script_words) + for a, b, n in sm.get_matching_blocks(): + for k in range(n): + starts[a + k] = heard[b + k]["s"] + ends[a + k] = heard[b + k]["e"] + total_end = heard[-1]["e"] if heard else 0.0 + i = 0 + while i < len(script_words): + if starts[i] is not None: + i += 1 + continue + j = i + while j < len(script_words) and starts[j] is None: + j += 1 + lo = ends[i - 1] if i > 0 else 0.0 + hi = starts[j] if j < len(script_words) else total_end + span = max(hi - lo, 0.12 * (j - i)) + for k in range(i, j): + starts[k] = lo + span * (k - i) / (j - i) + ends[k] = lo + span * (k - i + 1) / (j - i) + i = j + return [{"w": w, "s": starts[idx], "e": ends[idx]} + for idx, w in enumerate(script_words)] + + +def chunk(words: list[dict], max_words: int, max_chars: int) -> list[dict]: + chunks, cur = [], [] + for w in words: + cand = " ".join([c["w"] for c in cur] + [w["w"]]) + if cur and (len(cur) >= max_words or len(cand) > max_chars): + chunks.append({"text": " ".join(c["w"] for c in cur), + "start": cur[0]["s"], "end": cur[-1]["e"]}) + cur = [] + cur.append(w) + if cur: + chunks.append({"text": " ".join(c["w"] for c in cur), + "start": cur[0]["s"], "end": cur[-1]["e"]}) + # stretch each chunk to meet the next so captions never flicker off + for i in range(len(chunks) - 1): + chunks[i]["end"] = max(chunks[i]["end"], chunks[i + 1]["start"]) + return chunks + + +def main() -> None: + config = json.loads((HERE / "config.json").read_text()) + cap = config.get("captions", {}) + scenes = json.loads((HERE / "scenes.json").read_text())["scenes"] + out_dir = HERE / "captions" + out_dir.mkdir(exist_ok=True) + + model = whisper.load_model(cap.get("whisperModel", "base")) + for s in scenes: + mp3 = HERE / "audio" / "scenes" / f"{s['id']}_{s['slug']}.mp3" + if not mp3.exists(): + print(f"missing {mp3.name} — run gen_vo.py first; skipping") + continue + r = model.transcribe(str(mp3), word_timestamps=True, language="en") + heard = [{"w": w["word"].strip(), "s": w["start"], "e": w["end"]} + for seg in r["segments"] for w in seg["words"]] + words = align(s["text"].split(), heard) + chunks = chunk(words, cap.get("maxWords", 3), cap.get("maxChars", 22)) + out = out_dir / f"words_{s['id']}.json" + out.write_text(json.dumps(chunks, indent=1)) + print(f" {out.name}: {len(chunks)} chunks") + + print("Next: uv run build.py") + + +if __name__ == "__main__": + main() diff --git a/templates/things-worth-doing-short/gen_vo.py b/templates/things-worth-doing-short/gen_vo.py new file mode 100644 index 0000000..f957aca --- /dev/null +++ b/templates/things-worth-doing-short/gen_vo.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Step 1 — per-scene voiceover from scenes.json. + +Writes one .txt per scene into audio/scenes/, drives tools/voiceover.py over +the directory (clone or built-in speaker, pacing-clamped via --max-wpm), and +records the actual durations in vo_durations.json — the timeline anchor that +build.py reads. Run from this project directory: + + uv run gen_vo.py # generate all scenes + uv run gen_vo.py --force # regenerate even if MP3s exist +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +HERE = Path(__file__).resolve().parent + + +def find_toolkit_root() -> Path: + import os + env = os.environ.get("VIDEO_TOOLKIT_ROOT") + if env and (Path(env) / "tools" / "voiceover.py").exists(): + return Path(env) + p = HERE + for _ in range(5): + if (p / "tools" / "voiceover.py").exists(): + return p + p = p.parent + sys.exit("Could not find toolkit root (tools/voiceover.py) above this directory.\n" + "Projects normally live in /projects/; for copies elsewhere " + "set VIDEO_TOOLKIT_ROOT=/path/to/claude-code-video-toolkit") + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--force", action="store_true", help="Regenerate existing MP3s") + args = ap.parse_args() + + root = find_toolkit_root() + config = json.loads((HERE / "config.json").read_text()) + scenes = json.loads((HERE / "scenes.json").read_text())["scenes"] + voice = config.get("voice", {}) + + scene_dir = HERE / "audio" / "scenes" + scene_dir.mkdir(parents=True, exist_ok=True) + + pending = [] + for s in scenes: + txt = scene_dir / f"{s['id']}_{s['slug']}.txt" + mp3 = txt.with_suffix(".mp3") + txt.write_text(s["text"]) + if args.force or not mp3.exists(): + pending.append(s["id"]) + if not pending: + print("All scene MP3s exist — use --force to regenerate.") + return + if args.force: + for s in scenes: + (scene_dir / f"{s['id']}_{s['slug']}.mp3").unlink(missing_ok=True) + + cmd = [ + sys.executable, str(root / "tools" / "voiceover.py"), + "--provider", voice.get("provider", "qwen3"), + "--scene-dir", str(scene_dir), + "--json", + ] + if voice.get("brand"): + cmd += ["--brand", voice["brand"]] + if voice.get("maxWpm"): + cmd += ["--max-wpm", str(voice["maxWpm"])] + if voice.get("provider", "qwen3") == "qwen3": + cmd += ["--cloud", voice.get("cloud", "modal")] + ref_audio = voice.get("refAudio", "") + if ref_audio: + ref_path = Path(ref_audio) + if not ref_path.is_absolute(): + ref_path = HERE / ref_path + if not ref_path.exists(): + sys.exit(f"Clone reference audio not found: {ref_path}") + if not voice.get("refText"): + sys.exit("voice.refText is required with voice.refAudio (exact transcript).") + cmd += ["--ref-audio", str(ref_path), "--ref-text", voice["refText"]] + else: + cmd += ["--speaker", voice.get("speaker", "Ryan")] + if voice.get("tone"): + cmd += ["--tone", voice["tone"]] + + print(f"Generating {len(scenes)} scene VOs " + f"({voice.get('provider', 'qwen3')}, max {voice.get('maxWpm', '—')} wpm)...", + file=sys.stderr) + r = subprocess.run(cmd, cwd=str(root), capture_output=True, text=True, timeout=1800) + if r.returncode != 0: + sys.exit(f"voiceover.py failed:\n{r.stderr[-1000:]}") + + result = json.loads(r.stdout) + failed = [Path(i["output"]).stem for i in result["scenes"] if not i.get("success")] + if failed: + err = next(i.get("error", "?") for i in result["scenes"] if not i.get("success")) + sys.exit(f"{len(failed)} scene(s) failed ({', '.join(failed)}): {err}") + durations, problems = {}, [] + for item in result["scenes"]: + name = Path(item["output"]).stem # e.g. 01_hook + sid = name.split("_", 1)[0] + durations[sid] = item["duration_seconds"] + note = "" + if item.get("pace_adjusted"): + note = f" (clamped from {item['pace_adjusted']['original_wpm']:.0f} wpm)" + elif item.get("pacing") in ("fast", "slow"): + note = f" [{item['pacing'].upper()} {item['wpm']:.0f} wpm]" + problems.append(name) + print(f" {name}: {item['duration_seconds']}s, {item.get('wpm', '?')} wpm{note}") + + (HERE / "vo_durations.json").write_text(json.dumps(durations, indent=2)) + total = sum(durations.values()) + print(f"Total narration: {total:.1f}s → video ≈ {total + len(scenes) * 1.2:.0f}s") + if total + len(scenes) * 1.2 > 178: + print("Note: over ~3 min — fine for TikTok, too long for YouTube Shorts/Reels.") + if problems: + print(f"Pacing flags on: {', '.join(problems)} — consider voice.maxWpm in config.json") + print("Next: uv run gen_captions.py") + + +if __name__ == "__main__": + main() diff --git a/templates/things-worth-doing-short/scenes.json b/templates/things-worth-doing-short/scenes.json new file mode 100644 index 0000000..ba64e4f --- /dev/null +++ b/templates/things-worth-doing-short/scenes.json @@ -0,0 +1,33 @@ +{ + "title": "Why Is the Sky Blue?", + "scenes": [ + { + "id": "01", + "slug": "hook", + "visual": "ltx", + "asset": "clips/01_hook.mp4", + "text": "Look up on a clear day and you see blue in every direction. But sunlight is white. So where does the blue come from?" + }, + { + "id": "02", + "slug": "scattering", + "visual": "ideogram", + "asset": "images/02_scattering.png", + "text": "Sunlight is a mix of every color, and air molecules scatter short blue wavelengths far more than long red ones. The sky is blue light, bounced toward your eyes from everywhere at once." + }, + { + "id": "03", + "slug": "sunset", + "visual": "ltx", + "asset": "clips/03_sunset.mp4", + "text": "At sunset the light travels through far more air, the blue scatters away before it reaches you, and only the reds and oranges survive the trip." + }, + { + "id": "04", + "slug": "cta", + "visual": "ideogram", + "asset": "images/04_cta.png", + "text": "So the sky isn't blue. It's blue light, scattered. If that made the world a little clearer, follow for one concept every week." + } + ] +}