render-cosmic-mythology-voiceover
Assemble a cosmic-mythology-voiceover reel from a config — a warm spoken voiceover carries the whole narrative while N curated cosmic stills are weighted beat-synced across the delivered VO duration (cut_dur = VO_dur times weight over the weight sum, so emotional beats hold longe
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-cosmic-mythology-voiceover
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-cosmic-mythology-voiceover
Assemble a cosmic-mythology-voiceover reel from a config: a faceless, cinematic storytelling video where a warm, contemplative spoken voiceover carries the whole narrative (a "myth as teacher" reframe) over a slow, weighted Ken-Burns zoom across curated cosmic / mythology stills in ONE ethereal deep-indigo + gold look, with ONE on-screen hook line and burned captions. This capability is the FREE, deterministic assembly — the weighted beat-sync sequencing, the Ken-Burns render, the concat, the VO composite, the hook overlay, and the caption burn.
scripts/config.example.json is the worked example (WishAstro "Saturn isn't your villain", ~31s
1080×1920 9:16, 12 weighted Ken-Burns cuts); scripts/PIPELINE.md maps every config block to its
source step and scripts/README.md documents the free assembly.
Run
There is a single runnable script — scripts/render.py (config-driven, ffmpeg + Pillow only, NO
API keys and NO drawtext/libass required):
python3 scripts/render.py --config config.json --vo working/vo2/vo_atempo.mp3 \
--stills-dir working/stills --out working/final.mp4 \
[--words working/vo2/words.json] [--endcard working/endcard.png]
This is the FREE, deterministic assembly stage — it spends nothing. The paid inputs are
separate capabilities — the spoken VO (create-vo-elevenlabs, ElevenLabs eleven_v3 from a
tone-tagged script, atempo time-stretched so the delivered duration sets the timeline) and the 4–6
hero stills in one look pack (create-image-fal, Flux Pro 1.1, reused as repeats to reach the
~10–12 cuts). Given the VO + the stills + the per-cut weight array + the hook line, render.py
distributes the cuts across the VO duration by the weighted formula, Ken-Burns-renders each still,
concats, composites the VO, fades the hook line on over the open, burns the captions, and (if
--endcard is passed) appends a brand end card → the master + a poster. Re-cuts reuse the existing
VO / stills and cost $0. See scripts/README.md §0 for the full arg contract.
Contract (the free assembly)
- The spoken VO carries the narrative — no talking head, no sung song. The generated/supplied VO IS the audio bed (no music bed by default); do not add a presenter or a second bed.
- Plan the timeline AROUND the delivered VO duration. The VO is atempo time-stretched (clamp the factor ≤ ~1.25 so the voice never chipmunks); its delivered length sets the timeline — never trim the VO to a pre-planned grid.
- Weighted beat-sync, not a fixed grid. For each cut,
cut_dur = VO_dur × weight / Σweights— heavier weights hold longer on the emotional beats (the open, the reframe, the close); the setup cuts run shorter. Every cut stays proportional to the whole VO. - Ken-Burns per still. Render each still with scale 2×, center crop, and a
zoompanto the configuredzoom_end(~1.10); applyzoom_outon the flagged cuts;fade_inon the FIRST cut andfade_outon the LAST. Stills are reusable — the sequence repeats a few across the cuts. - ONE cosmic look, no in-world text. Every still reads in the single deep-indigo + gold + volumetric-light look; the reel's only text is the hook + the captions, added in post (the "no text, no words" descriptor keeps words off the stills).
- ONE hook line, alpha-faded on over the open. Burn the single reframe line over the OPEN only
(fade in ~0.5s, hold, fade out ~0.6s) — never a persistent caption, never in-world.
render.pydoes this with a PIL PNG + ffmpegfade=…:alpha=1(nodrawtextdependency, since stock ffmpeg often lacks it); an ffmpegdrawtextalpha window is an equivalent alternative where available. - Captions from Whisper — bottom, white. VEED/Whisper subtitle burn tracks the spoken VO in the
bottom third, white
#FFFFFF. If the host ffmpeg lacks libass (nosubtitles/assfilter), render the cues as timed PIL PNG overlays (ffmpegoverlay=…:enable='between(t,st,en)') at the same bottom placement — a free local Whisper + ffmpeg burn is the fallback to the VEED tier. - FFmpeg composite, deterministic, FREE. ffmpeg-concat the Ken-Burns clips, composite the VO
under the picture (libx264
crf 18+ aac 192k), burn the hook alpha-fade + the caption track → a 1080×1920 h264+aac master (~31s). No paid calls, no keys (the local caption fallback is free).
Files (goose-skills)
-
scripts
-
config.example.json 8.7 KB
{ "_comment": "WishAstro 'Saturn isn't your villain' cosmic-mythology-voiceover reel — THE WORKED EXAMPLE. Copy to config.json and edit. A ~31s faceless 9:16 reel where a warm contemplative spoken VO reframes Saturn / Shani as 'the strict teacher, not the punisher' over 12 weighted Ken-Burns cuts of cosmic stills, with the hook 'Stop calling Saturn your villain.' faded on over the open + Whisper captions. Pipeline: (1) lock the VO (ElevenLabs eleven_v3 tone-tagged, atempo-clamped 1.25 -> 31.4s — the delivered duration sets the timeline); (2) 6 Flux Pro 1.1 cosmic stills in ONE indigo+gold look (reused to 12 cuts); (3) FREE assembly — weighted beat-sync (cut_dur = VO_dur * weight / Sum(weights)), Ken-Burns per still, ffmpeg concat + VO composite (crf18) + hook alpha-fade overlay + VEED/Whisper caption burn. Master 1080x1920 h264+aac, ~31.4s (12 cuts). See scripts/PIPELINE.md for the source scripts.", "brand_name": "WishAstro", "campaign": "WishAstro IG pilot — mythology lane", "title": "Saturn isn't your villain", "width": 1080, "height": 1920, "fps": 30, "crf": 18, "duration_sec": 31.4, "aspect_ratio": "9:16", "vo": { "_comment": "Locked FIRST — the delivered, atempo-stretched VO duration sets the timeline. Rendered 39.24s at eleven_v3; a 23s target needed atempo 1.71, CLAMPED at 1.25 -> 31.4s final (manifest flagged 'atempo_clamped' + 'duration_overshoot' rather than chipmunking the voice). The first draft used eleven_multilingual_v2 + the Lauren voice (l4Coq6695JDX9xtLqXDE); v2 used eleven_v3 with the tone-tagged script.", "provider": "elevenlabs", "model": "eleven_v3", "model_fallback": "eleven_multilingual_v2", "voice_id": "jqcCZkN6Knx8BJ5TBdYR", "voice_settings": { "stability": 0.4, "similarity_boost": 0.75, "style": 0.05, "use_speaker_boost": true }, "script": "[soft, casual] Okay… can we talk about Saturn for a second? Everyone treats him like the villain of their birth chart — but that's so wrong. Saturn isn't here to punish you — he's here to teach you. In Vedic astrology, he's Shani… basically the strict professor of the zodiac. Your Saturn return, your Sade Sati? Those aren't curses… they're final exams. He strips away everything that was never really yours — so you're left with what actually is. Saturn doesn't break you. He builds you. So next time he tests you… pass.", "tone_tags": "[soft, casual] opener; warm, contemplative, unhurried delivery", "atempo_factor": 1.25, "atempo_clamp_max": 1.25, "target_duration_s": 23.0, "rendered_duration_s": 39.236, "final_duration_s": 31.399, "_note_atempo": "atempo_clamped: rendered 39.24s vs target 23.00s would require factor 1.706, clamped at 1.25. The reel runs 31.4s rather than chipmunk the voice — consider trimming the script if a tighter cut is required.", "outputs": { "raw": "working/vo2/saturn-vo2.mp3", "final": "working/vo2/saturn-vo2_atempo1.25.mp3", "manifest": "working/vo2/saturn-vo2.manifest.json" } }, "hook": { "_comment": "The ONE on-screen reframe line, faded on over the OPEN via ffmpeg drawtext alpha window (in ~0.5s at t=0.3, hold, out ~0.6s ending t=3.0) — never persistent, never a caption, never in-world text on a still.", "text": "Stop calling Saturn your villain.", "font": "Georgia", "font_fallback": ["/System/Library/Fonts/Supplemental/Georgia.ttf"], "font_color": "white", "font_size": 62, "placement": "centered upper third (y=h*0.13)", "shadow": "black@0.7 (2,2)", "fade_in_s": 0.5, "hold_until_s": 2.4, "fade_out_s": 0.6 }, "look_pack": { "_comment": "ONE cosmic look baked into EVERY image prompt so all stills read as one film. 'no text, no words' is load-bearing — the reel's only text is the hook + captions, added in post.", "name": "COSMIC_INDIGO_GOLD", "style_descriptor": "cinematic, ethereal, mystical, deep indigo and gold color palette, fine star particles, volumetric light, ultra-detailed, vertical 9:16 composition, no text, no words", "palette_anchors": [ "deep indigo starfield (primary)", "warm gold rings / particles / dawn light (accent)", "fine star particles + soft nebula", "volumetric god-ray lighting" ] }, "image_engine": { "provider": "fal", "model": "fal-ai/flux-pro/v1.1", "image_size": { "width": 1080, "height": 1920 }, "safety_tolerance": "5", "output_format": "png", "_note": "Generate at the OUTPUT resolution 1080x1920 (the old 'portrait_16_9' preset returned ~576x1024, soft after the 2x Ken-Burns). gen_images2.py generated 6 hero stills (img5..img10); gen_images.py generated the first 4 (img1..img4). 10 assets total; the sequence reuses a few to reach 12 cuts." }, "shots": [ { "id": "img1_saturn", "prompt": "Ringed Saturn glowing gold on a deep-indigo starfield, awe and gravity" }, { "id": "img2_silhouette", "prompt": "A lone silhouette on a cliff under a vast cosmic sky, Saturn on the horizon" }, { "id": "img3_rings", "prompt": "Abstract golden rings and particles swirling in deep space" }, { "id": "img4_dawn", "prompt": "Dawn light over a figure walking forward, Saturn fading behind them — 'he builds you'" }, { "id": "img5_shani", "prompt": "A dark imposing ringed planet like Shani looming as a stern wise presence in deep space, awe and gravity" }, { "id": "img6_exam", "prompt": "A lone figure standing before a vast glowing cosmic doorway of stars, a threshold or test, dramatic" }, { "id": "img7_strip", "prompt": "Golden particles and dust being swept away from a silhouetted figure by a cosmic wind, shedding and release" }, { "id": "img8_rings2", "prompt": "Dramatic side view of Saturn's golden rings cutting across a starfield, fine particles catching light" }, { "id": "img9_built", "prompt": "A strong solitary figure standing tall bathed in warm golden cosmic light, resilience and transformation" }, { "id": "img10_star", "prompt": "A single brilliant golden star blazing in a deep indigo cosmos surrounded by soft nebula, hopeful finality" } ], "sequence": { "_comment": "Weighted beat-sync (from build_video2.py's SEQ array): cut_dur = VO_dur * weight / Sum(weights). 12 cuts, ~10 unique stills (img1_saturn + img8_rings2 each reused twice). Heavier weights hold LONGER on the open (1.15) and the dawn 'he builds you' close (1.2); the setup cuts run shorter (0.8-0.95). Ken-Burns: scale 2x, center crop, zoompan to zoom_end 1.10; zoom_out on the flagged cuts; fade_in 0.4 on the FIRST cut, fade_out 0.6 on the LAST.", "zoom_end": 1.10, "fade_in_first_s": 0.4, "fade_out_last_s": 0.6, "cuts": [ { "still": "img1_saturn", "zoom_out": false, "weight": 1.15 }, { "still": "img5_shani", "zoom_out": false, "weight": 1.0 }, { "still": "img2_silhouette", "zoom_out": true, "weight": 0.95 }, { "still": "img6_exam", "zoom_out": false, "weight": 1.0 }, { "still": "img8_rings2", "zoom_out": false, "weight": 1.0 }, { "still": "img3_rings", "zoom_out": true, "weight": 0.9 }, { "still": "img10_star", "zoom_out": false, "weight": 0.85 }, { "still": "img8_rings2", "zoom_out": false, "weight": 0.8 }, { "still": "img7_strip", "zoom_out": true, "weight": 1.0 }, { "still": "img9_built", "zoom_out": false, "weight": 1.0 }, { "still": "img1_saturn", "zoom_out": false, "weight": 0.9 }, { "still": "img4_dawn", "zoom_out": false, "weight": 1.2 } ] }, "captions": { "_comment": "VEED/Whisper subtitle burn on working/draft2.mp4 -> finals/saturn-myth-v2.mp4. Tracks the spoken VO. Bottom third, white, mid shadow. The stills carry NO in-world text (the reel's only text is the hook + these captions). Local Whisper + ffmpeg subtitles burn is the free fallback if VEED is locked.", "engine": "veed/subtitles", "preset": "whisper", "position": "bottom", "font_color": "#FFFFFF", "shadow": "mid", "input_video": "working/draft2.mp4", "output_video": "finals/saturn-myth-v2.mp4" }, "audio_mix": { "_comment": "The spoken VO is the full audio bed — there is NO music bed by default. Add a ducked instrumental only if the brief calls for one; then mix it UNDER the VO.", "voiceover_only": true, "music_bed": false }, "post_production": { "voiceover": { "default": "on", "note": "the spoken eleven_v3 VO IS the spine; no music bed by default" }, "hook": { "default": "on", "note": "the ONE on-screen reframe line, alpha-faded on over the open, never persistent / never in-world" }, "captions": { "default": "on", "note": "VEED/Whisper subtitle burn tracking the VO, bottom, white #FFFFFF" }, "fades": { "default": "on", "note": "fade-in on the first Ken-Burns cut, fade-out on the last" } } } -
PIPELINE.md 5.5 KB
# Pipeline — cosmic-mythology-voiceover reel How `config.example.json` maps to the real production steps. The free assembly (steps 3+) now ships as a **runnable script — `scripts/render.py`** (config-driven; ffmpeg + Pillow only; see `README.md` §0). The worked example (WishAstro "Saturn isn't your villain") was originally produced by per-project drivers (`vo.py`, `gen_images2.py`, `build_video2.py` under `clients/wishastro/accounts/wishastro-ig/posts/saturn-myth/working/`); `render.py` is the generalized, config-driven successor to `build_video2.py`. The paid steps (VO, stills) remain the media capabilities. The steps run **in order** because each depends on the last: the VO sets the timeline (its delivered, atempo-stretched duration), the timeline + the weight array drive each cut's length, the one look pack drives the stills, the stills seed the Ken-Burns clips, and assembly concats them + composites the VO + fades the hook on + burns the captions. ## Field → source-script map | Config field | Phase | Source step / script (in the run) | Paid? | |---|---|---|---| | `vo.script`, `vo.tone_tags`, `vo.model`, `vo.voice_id`, `vo.voice_settings` | 1 VO | ElevenLabs `eleven_v3` (tone-tagged) → mp3 | **PAID** | | `vo.atempo_factor`, `vo.atempo_clamp_max`, `vo.target_duration_s` | 1 VO | atempo time-stretch to target, clamp ≤ 1.25 → `working/vo2/*_atempo1.25.mp3` | free | | `vo.outputs.manifest` (rendered / final duration) | 1 VO | `working/vo2/saturn-vo2.manifest.json` | free | | `look_pack.style_descriptor`, `look_pack.palette_anchors` | 2 Stills | threaded verbatim into every image prompt | (defines cost) | | `shots[].prompt`, `image_engine` | 2 Stills | `working/gen_images2.py` → `fal-ai/flux-pro/v1.1`, `portrait_16_9`, `safety_tolerance 5` → `assets/img*.png` | **PAID** | | `sequence.cuts[].still/zoom_out/weight`, `sequence.zoom_end` | 3 Assembly | `working/build_video2.py` weighted formula `cut_dur = VO_dur × weight / Σweights` | free | | `sequence.fade_in_first_s`, `.fade_out_last_s`, Ken-Burns clips | 3 Assembly | `working/build_video2.py` calls the ken-burns-clip atom `render.py` (scale 2×, zoompan) → `working/kb_NN.mp4` | free | | `hook.text`, `hook.fade_in_s`, `.hold_until_s`, `.fade_out_s` | 3 Assembly | `working/build_video2.py` ffmpeg `drawtext` alpha window | free | | assembly (`fps`, `crf`, dims) | 3 Assembly | `working/build_video2.py` ffmpeg concat → composite BG + VO (libx264 crf18, aac 192k) → `working/draft2.mp4` | free | | `captions` | 3 Assembly | VEED/Whisper subtitle burn on `working/draft2.mp4` → `finals/saturn-myth-v2.mp4` (free local Whisper + ffmpeg fallback) | ~$ (VEED) / free (local) | ## 1. VO → ElevenLabs `eleven_v3` (config: `vo`) [PAID] **Lock the VO FIRST — it sets the timeline.** Feed the tone-tagged script (`[soft, casual]` opener) to ElevenLabs `eleven_v3` (`eleven_multilingual_v2` is the plainer fallback) with a warm female voice → `working/vo2/vo.mp3`. Measure the rendered duration, then **atempo time-stretch to the target, CLAMPING the factor ≤ ~1.25** so the voice never chipmunks: the worked example rendered 39.24s, a 23s target needed factor 1.71, so it clamped at 1.25 → **31.4s final** and flagged the overshoot rather than pushing the factor. **The delivered VO duration sets the timeline** — distribute the cuts across it; never trim the VO to a pre-planned grid. ## 2. Stills → Flux Pro 1.1 (config: `shots`, `look_pack`, `image_engine`) [PAID] `working/gen_images2.py` builds each prompt as `look_pack.style_descriptor` + `shots[i].prompt` and calls `fal-ai/flux-pro/v1.1` at `image_size: portrait_16_9`, `safety_tolerance: 5`, `png`. **6 hero cosmic stills**; stills are **reusable** — the sequence repeats a few to reach the ~10–12 cuts. The "no text, no words" tail on the style descriptor is load-bearing — the reel's only text is the hook + captions, added in post. Review all before step 3. ## 3. Assembly (weighted sequence + Ken-Burns + concat + VO + hook + captions) (config: `sequence`, `hook`, `captions`) [FREE] `working/build_video2.py` owns the whole free assembly: - **Weighted beat-sync:** the `SEQ` array is `(image, zoom_out, weight)` per cut. It measures the VO duration `D`, sums the weights, and gives each cut `seg = D × weight / Σweights` — heavier weights **hold longer** on the emotional beats (open 1.15, dawn close 1.2), lighter setup cuts shorter (0.8–0.95). - **Ken-Burns render:** each cut renders through the **ken-burns-clip atom** (`render.py`: scale 2×, center crop, `zoompan` to `--zoom-end 1.10`, `--zoom-out` on the flagged cuts), with `--fade-in 0.4` on the **first** cut and `--fade-out 0.6` on the **last** → `working/kb_NN.mp4`. - **Concat:** ffmpeg-concat the N Ken-Burns clips (`-c copy`) → `working/bg2.mp4`. - **VO composite + hook:** one ffmpeg pass takes `bg2.mp4` + the atempo VO, applies a `drawtext` of the ONE hook line with an **alpha-fade window** (fade in ~0.5s at `t=0.3`, hold, fade out ~0.6s ending `t=3.0`) over the open, maps `0:v` + `1:a`, encodes libx264 `-crf 18` + aac 192k `-shortest` → `working/draft2.mp4`. - **Captions:** VEED/Whisper subtitle burn (`veed/subtitles`, `whisper` preset, bottom, white `#FFFFFF`) on `draft2.mp4` → `finals/saturn-myth-v2.mp4`. A local Whisper + ffmpeg `subtitles` burn (or timed PIL PNG overlays if libass is missing) is the **free** fallback to the VEED tier. Re-cuts (re-weighted windows, new hook timing, caption chunking, zoom params) reuse the existing VO + stills and cost **$0** — only steps 1–2 spend. -
README.md 4.5 KB
# render-cosmic-mythology-voiceover scripts — the FREE assembly `render-cosmic-mythology-voiceover` is the **deterministic, $0 assembly stage** of the cosmic-mythology-voiceover format. The paid stages (the spoken VO, the N cosmic hero stills) are separate capabilities — `create-vo-elevenlabs`, `create-image-fal`. This capability spends nothing (the caption burn has a free local Whisper + ffmpeg fallback): it takes the atempo VO + the stills + the per-cut weight array + the hook line and stitches the finished master. Re-cuts (re-weighted windows, new hook timing, caption chunking, zoom params) reuse the existing VO / stills and cost **$0**. `config.example.json` is the worked example (WishAstro "Saturn isn't your villain", ~31s 1080×1920). `PIPELINE.md` maps every config block to its source step. This README documents the FREE assembly pieces that `render-cosmic-mythology-voiceover` owns. ## 0. Run it — `render.py` (config-driven, portable) There IS a single runnable script: `scripts/render.py`. Given the bound recipe config + the atempo VO + the stills, it does the whole free assembly (sequence → Ken-Burns → concat → VO composite → hook → captions → optional end card) and reports duration/size/bitrate: ```bash python3 scripts/render.py \ --config config.json \ # the recipe.config bound for this brand --vo working/vo2/vo_atempo.mp3 \ # the atempo VO (delivered duration sets the timeline) --stills-dir working/stills \ # holds <still-id>.png for every sequence cut --out working/final.mp4 \ --words working/vo2/words.json \ # OPTIONAL word-timing JSON → captions (skipped if absent) --endcard working/endcard.png # OPTIONAL brand end card, appended ~2.6s ``` Deps: **ffmpeg + Pillow only** — NO API keys, and NO `drawtext`/`libass` required (the hook + captions burn as timed PIL PNG overlays; see §3–4). `--words` is any word-level timing list (`[{word|text,start,end}]`, groq/fal shapes both accepted) — the orchestrator makes it from the VO via the proxy Whisper. A re-cut (new weights/hook timing/caption chunking/zoom) reuses the same VO + stills for **$0**. ## 1. Weighted beat-sync sequencing The delivered VO duration sets the timeline. For each cut in `sequence.cuts[]`, the cut duration is `cut_dur = VO_dur × weight / Σweights` — so heavier weights **hold longer** on the emotional beats (the open, the reframe, the "he builds you" close) and the lighter setup cuts run shorter. Every cut stays proportional to the whole VO; never trim the VO to a pre-planned grid. Stills are reusable — the sequence repeats a few across the ~10–12 cuts. ## 2. Ken-Burns render per still Each cut renders through the ken-burns-clip atom (`render.py`): scale 2×, center crop, and a `zoompan` to the configured `zoom_end` (~1.10). Apply `zoom_out` on the flagged cuts. The FIRST cut gets a `fade_in` (~0.4s) up from black and the LAST cut a `fade_out` (~0.6s); the body cuts hard-join. ## 3. Concat + VO composite + hook overlay - **Concat:** ffmpeg-concat the N Ken-Burns clips (`-c copy`) → the background. - **VO composite:** one ffmpeg pass composites the atempo VO under the picture (libx264 `crf 18` + aac 192k). The spoken VO IS the bed — no separate VO and no music bed by default. - **Hook overlay:** the ONE reframe line, alpha-faded over the **open** only (fade in ~0.5s, hold, fade out ~0.6s) — never a persistent caption, never in-world text. `render.py` renders the line to a transparent PIL PNG and fades it with ffmpeg `fade=…:alpha=1`, so it needs NO `drawtext` filter (stock Homebrew ffmpeg often lacks it). If your ffmpeg *has* `drawtext`, that's an equivalent alternative — but the PIL path is the portable default. ## 4. Caption burn VEED/Whisper subtitle burn tracks the spoken VO in the **bottom** third, white `#FFFFFF`, on the draft. If the host ffmpeg lacks libass (no `subtitles`/`ass` filter), render the cues as timed PIL PNG overlays composited with ffmpeg `overlay=…:enable='between(t,st,en)'` instead — same bottom placement, no libass dependency. A local Whisper + ffmpeg burn is the **free** fallback to the VEED tier. The stills carry no in-world text — the reel's only text is the hook + these captions. ## 5. FFmpeg composite FFmpeg stitches the master: weighted-sequence the cuts, Ken-Burns-render each still, concat, burn the VO composite + the hook alpha-fade + the caption track → a 1080×1920 h264 + aac master (~31s). Deterministic, no paid calls, no keys (the local caption fallback is free). -
render.py 11.8 KB
#!/usr/bin/env python3 """render-cosmic-mythology-voiceover — the FREE, deterministic assembly, as a RUNNABLE script. Given the atempo VO, the stills, and the bound recipe config, this produces the 1080x1920 master: weighted beat-sync sequencing -> Ken-Burns per still -> concat -> VO composite -> hook overlay -> caption burn (-> optional end card). No paid calls, no API keys. $0. A re-cut reuses the same VO + stills. render.py --config config.json --vo working/vo2/vo_atempo.mp3 --stills-dir working/stills \ --out working/final.mp4 [--words working/vo2/words.json] [--endcard working/endcard.png] TEXT WITHOUT drawtext/libass: stock Homebrew ffmpeg frequently lacks BOTH the `drawtext` and the `subtitles`/libass filters, so this renderer burns the hook + captions as timed PIL PNG overlays (ffmpeg `overlay=...:enable='between(t,st,en)'` for captions, `fade=...:alpha=1` for the hook) — the recipe's documented fallback. It needs only Pillow + a stock ffmpeg (no text filters required). CONFIG (the bound recipe.config): reads fps, crf, width, height, sequence{cuts[],zoom_end, fade_in_first_s,fade_out_last_s}, hook{text,font_fallback,font_size,fade_in_s,hold_until_s, fade_out_s,placement,font_color}, captions{position,font_color}. Each sequence cut is {still,zoom_out,weight}; stills-dir holds <still>.png (or .jpg). words.json is optional word-level timing ([{word|text,start,end}, ...], groq/fal shapes both accepted); without it captions are skipped. """ import argparse import json import os import subprocess import textwrap from PIL import Image, ImageDraw, ImageFont # ---- font resolution (portable across macOS + Linux) -------------------------------------------- _HOOK_FONTS = [ "/System/Library/Fonts/Supplemental/Georgia.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf", ] _CAP_FONTS = [ "/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Helvetica.ttc", ] def _first_font(paths, size): for p in paths: if os.path.exists(p): return ImageFont.truetype(p, size) return ImageFont.load_default() def run(cmd): r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: raise RuntimeError("ffmpeg/ffprobe FAILED: " + " ".join(cmd[:6]) + "\n" + r.stderr[-1800:]) return r def dur(path): return float(run(["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", path]).stdout.strip()) def still_path(stills_dir, sid): for ext in (".png", ".jpg", ".jpeg"): p = os.path.join(stills_dir, sid + ext) if os.path.exists(p): return p raise FileNotFoundError(f"still '{sid}' not found in {stills_dir}") def ken_burns(img, out, N, zoom_out, zoom_end, fps, W, H, crf, fade_in=0.0, fade_out=0.0): seg = N / fps zamt = zoom_end - 1.0 z = f"{zoom_end:.4f}-{zamt:.4f}*on/{N}" if zoom_out else f"1.0000+{zamt:.4f}*on/{N}" vf = (f"scale={W*2}:{H*2}:force_original_aspect_ratio=increase,crop={W*2}:{H*2}," f"zoompan=z='{z}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s={W}x{H}:fps={fps}") if fade_in: vf += f",fade=t=in:st=0:d={fade_in}" if fade_out: vf += f",fade=t=out:st={max(0, seg - fade_out):.3f}:d={fade_out}" vf += ",format=yuv420p" run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-framerate", str(fps), "-t", f"{seg:.4f}", "-i", img, "-vf", vf, "-frames:v", str(N), "-c:v", "libx264", "-crf", str(crf), "-preset", "medium", "-pix_fmt", "yuv420p", out]) def render_hook_png(cfg_hook, W, H, out): text = cfg_hook.get("text", "").strip() if not text: return None size = int(cfg_hook.get("font_size", 62)) + 12 # a touch larger reads better at 1080w font = _first_font(list(cfg_hook.get("font_fallback", [])) + _HOOK_FONTS, size) im = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(im) # wrap to ~24 chars so a long hook stacks to 2 lines lines = textwrap.fill(text, width=24).split("\n") line_h = int(size * 1.24) y0 = int(H * 0.13) for i, line in enumerate(lines): bb = d.textbbox((0, 0), line, font=font) x = (W - (bb[2] - bb[0])) // 2 - bb[0] y = y0 + i * line_h d.text((x + 3, y + 3), line, font=font, fill=(0, 0, 0, 180)) # soft shadow d.text((x, y), line, font=font, fill=(255, 255, 255, 255)) im.save(out) return out def build_caption_cues(words, video_dur, chunk=3): cues = [] i = 0 while i < len(words): grp = words[i:i + chunk] txt = " ".join((w.get("word") or w.get("text") or "").strip() for w in grp).strip() st = float(grp[0].get("start", 0) or 0) en = min(float(grp[-1].get("end", st + 0.4) or st + 0.4), video_dur) if en <= st: en = st + 0.4 if txt: cues.append((st, en, txt)) i += chunk return cues def render_caption_png(text, cfg_caps, W, H, font, out): im = Image.new("RGBA", (W, H), (0, 0, 0, 0)) d = ImageDraw.Draw(im) lines = textwrap.fill(text.upper(), width=22).split("\n") lh = int(font.size * 1.22) yb = int(H * (0.80 if cfg_caps.get("position", "bottom") == "bottom" else 0.5)) fill = cfg_caps.get("font_color", "#FFFFFF") for i, line in enumerate(lines): bb = d.textbbox((0, 0), line, font=font) x = (W - (bb[2] - bb[0])) // 2 - bb[0] y = yb + i * lh for dx in (-3, 0, 3): # black outline for legibility for dy in (-3, 0, 3): if dx or dy: d.text((x + dx, y + dy), line, font=font, fill=(0, 0, 0, 220)) d.text((x, y), line, font=font, fill=fill) im.save(out) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--config", required=True) ap.add_argument("--vo", required=True) ap.add_argument("--stills-dir", required=True) ap.add_argument("--out", required=True) ap.add_argument("--words", help="optional word-timing JSON for captions") ap.add_argument("--endcard", help="optional end-card PNG to append (~2.6s)") ap.add_argument("--work", default=None, help="scratch dir (default: <out dir>/_render)") a = ap.parse_args() cfg = json.load(open(a.config)) W = int(cfg.get("width", 1080)) H = int(cfg.get("height", 1920)) fps = int(cfg.get("fps", 30)) crf = int(cfg.get("crf", 18)) seq = cfg["sequence"] cuts = seq["cuts"] zoom_end = float(seq.get("zoom_end", 1.10)) fade_in_first = float(seq.get("fade_in_first_s", 0.4)) fade_out_last = float(seq.get("fade_out_last_s", 0.6)) work = a.work or os.path.join(os.path.dirname(os.path.abspath(a.out)) or ".", "_render") kb_dir = os.path.join(work, "kb") ovl_dir = os.path.join(work, "ovl") os.makedirs(kb_dir, exist_ok=True) os.makedirs(ovl_dir, exist_ok=True) vo_dur = dur(a.vo) sumw = sum(float(c.get("weight", 1.0)) for c in cuts) frames = [max(6, round(vo_dur * float(c.get("weight", 1.0)) / sumw * fps)) for c in cuts] total_frames = sum(frames) print(f"[render] vo={vo_dur:.3f}s cuts={len(cuts)} total={total_frames/fps:.3f}s {W}x{H}@{fps}") # 1) Ken-Burns per cut for i, (c, N) in enumerate(zip(cuts, frames)): img = still_path(a.stills_dir, c["still"]) ken_burns(img, os.path.join(kb_dir, f"kb_{i:02d}.mp4"), N, bool(c.get("zoom_out")), zoom_end, fps, W, H, crf, fade_in=fade_in_first if i == 0 else 0.0, fade_out=fade_out_last if i == len(cuts) - 1 else 0.0) # 2) concat concat_txt = os.path.join(kb_dir, "concat.txt") with open(concat_txt, "w") as f: for i in range(len(cuts)): f.write(f"file 'kb_{i:02d}.mp4'\n") bg = os.path.join(work, "bg.mp4") run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", concat_txt, "-c", "copy", bg]) bg_dur = dur(bg) # 3) overlays: hook (alpha-faded) + captions (timed), then VO under the picture. # ii = the ffmpeg input INDEX of the last-added input (bg=0, vo=1); a `-loop 1 -t X -i p` # image input is 6 tokens, not 2, so track the index explicitly (never derive it from len()). inputs = ["-i", bg, "-i", a.vo] ii = 1 fc, prev = [], "0:v" hook = cfg.get("hook", {}) hook_png = render_hook_png(hook, W, H, os.path.join(ovl_dir, "hook.png")) if hook_png: fi = hook.get("fade_in_s", 0.5) hold = hook.get("hold_until_s", 2.4) fo = hook.get("fade_out_s", 0.6) inputs += ["-loop", "1", "-t", f"{bg_dur:.3f}", "-i", hook_png] ii += 1 fc.append(f"[{ii}:v]format=rgba,fade=t=in:st=0.3:d={fi}:alpha=1," f"fade=t=out:st={hold}:d={fo}:alpha=1[hk]") fc.append(f"[{prev}][hk]overlay=0:0[bh]") prev = "bh" cues = [] if a.words and os.path.exists(a.words): wj = json.load(open(a.words)) words = wj.get("words", wj) if isinstance(wj, dict) else wj cues = build_caption_cues(words, bg_dur) else: print("[render] no --words: captions skipped") cap_font = _first_font(_CAP_FONTS, 60) for ci, (st, en, txt) in enumerate(cues): p = render_caption_png(txt, cfg.get("captions", {}), W, H, cap_font, os.path.join(ovl_dir, f"cap_{ci:03d}.png")) inputs += ["-loop", "1", "-t", f"{bg_dur:.3f}", "-i", p] ii += 1 nxt = f"c{ci}" fc.append(f"[{prev}][{ii}:v]overlay=0:0:enable='between(t,{st:.3f},{en:.3f})'[{nxt}]") prev = nxt reel = os.path.join(work, "reel.mp4") if a.endcard else a.out cmd = ["ffmpeg", "-y", "-loglevel", "error"] + inputs if fc: cmd += ["-filter_complex", ";".join(fc), "-map", f"[{prev}]"] else: cmd += ["-map", "0:v"] cmd += ["-map", "1:a", "-c:v", "libx264", "-crf", str(max(crf, 20)), "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", "-movflags", "+faststart", reel] run(cmd) # 4) optional end card appended (fades up from black, silent tail) if a.endcard: ec_dur, N = 2.6, round(2.6 * fps) ec_mp4 = os.path.join(work, "endcard.mp4") vf = (f"scale={W*2}:{H*2}:force_original_aspect_ratio=increase,crop={W*2}:{H*2}," f"zoompan=z='1.00+0.04*on/{N}':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=1:s={W}x{H}:fps={fps}," f"fade=t=in:st=0:d=0.5,format=yuv420p") run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-framerate", str(fps), "-t", f"{ec_dur}", "-i", a.endcard, "-f", "lavfi", "-t", f"{ec_dur}", "-i", "anullsrc=r=44100:cl=mono", "-vf", vf, "-frames:v", str(N), "-c:v", "libx264", "-crf", str(max(crf, 20)), "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-shortest", ec_mp4]) run(["ffmpeg", "-y", "-loglevel", "error", "-i", reel, "-i", ec_mp4, "-filter_complex", "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]", "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-crf", str(max(crf, 20)), "-preset", "medium", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart", a.out]) # poster + size report poster = os.path.splitext(a.out)[0] + "-thumb.jpg" run(["ffmpeg", "-y", "-loglevel", "error", "-ss", "1.2", "-i", a.out, "-frames:v", "1", "-q:v", "3", poster]) out_dur = dur(a.out) size = os.path.getsize(a.out) print(f"[render] OUT {a.out} {out_dur:.2f}s {size} bytes ({size/out_dur/1e6*8:.2f} Mbps) | poster {poster}") if size / out_dur > 1_000_000: # ~>1MB/s → re-encode advised (see the master skill's QC gate) print("[render] WARNING: >~1MB/s — re-encode (-crf 23 / -maxrate 4M) before publishing") if __name__ == "__main__": main()
-
-
tests
-
smoke-test.md 1.7 KB
# Smoke Test Given the spoken VO (atempo mp3) and N curated stills in ONE coherent look (the WishAstro demo is deep-indigo + gold cosmic; the look is brand-swappable), the per-cut weight array, and the ONE hook line, `scripts/render.py` assembles the master: distribute the cuts across the VO duration by the weighted formula (`cut_dur = VO_dur × weight / Σweights`), Ken-Burns-render each still (fade-in first / fade-out last), ffmpeg-concat, composite the VO under the picture, fade the hook line on over the open, burn captions bottom, and (optionally) append a brand end card → 1080×1920 h264+aac. Run: ```bash python3 scripts/render.py --config config.json --vo <vo_atempo.mp3> \ --stills-dir <stills/> --out final.mp4 [--words words.json] [--endcard endcard.png] ``` Pass when `render.py` runs to a valid MP4 and: - the cuts follow the weight ratios (emotional beats hold longer, setup cuts shorter); Ken-Burns zoom per still with fade-in on the first cut and fade-out on the last; - the spoken VO carries end to end (no gaps) — the VO is the entire narrative, no talking head and no sung song; - the ONE look holds across every still (no drift, no wrong palette, no in-world text — the reel's only text is the hook + captions, then any end card); - the ONE hook line fades on/off over the open (not persistent, not in-world); captions are burned bottom, white, tracking the VO; - it runs on a **stock ffmpeg with NO `drawtext`/`libass`** (hook + captions are PIL PNG overlays) and needs only ffmpeg + Pillow; - **no paid call is made** — the VO and the stills come from the paid capabilities (create-vo-elevenlabs / create-image-fal); this assembly is $0 and a re-cut reuses the existing assets.
-
-
SKILL.md 5.3 KB
--- name: render-cosmic-mythology-voiceover description: Assemble a cosmic-mythology-voiceover reel from a config — a warm spoken voiceover carries the whole narrative while N curated cosmic stills are weighted beat-synced across the delivered VO duration (cut_dur = VO_dur times weight over the weight sum, so emotional beats hold longer), Ken-Burns-zoomed per still (scale 2x, center crop, zoompan, fade-in first and fade-out last), ffmpeg-concatenated, the VO composited under the picture (libx264 crf18 plus aac), the ONE on-screen hook line faded on over the open with a drawtext alpha window, and Whisper/VEED captions burned along the bottom — never in-world text on a still. This is the FREE deterministic assembly stage (weighted sequence plus Ken-Burns plus concat plus VO composite plus hook overlay plus caption burn); the VO and the stills come from create-vo-elevenlabs and create-image-fal. Use for the cosmic-mythology-voiceover format. status: active --- # render-cosmic-mythology-voiceover Assemble a **cosmic-mythology-voiceover** reel from a config: a faceless, cinematic storytelling video where a warm, contemplative spoken **voiceover** carries the whole narrative (a "myth as teacher" reframe) over a slow, weighted **Ken-Burns zoom** across curated cosmic / mythology stills in ONE ethereal deep-indigo + gold look, with ONE on-screen hook line and burned captions. This capability is the **FREE, deterministic assembly** — the weighted beat-sync sequencing, the Ken-Burns render, the concat, the VO composite, the hook overlay, and the caption burn. `scripts/config.example.json` is the worked example (WishAstro "Saturn isn't your villain", ~31s 1080×1920 9:16, 12 weighted Ken-Burns cuts); `scripts/PIPELINE.md` maps every config block to its source step and `scripts/README.md` documents the free assembly. ## Run There is a single runnable script — `scripts/render.py` (config-driven, ffmpeg + Pillow only, NO API keys and NO `drawtext`/`libass` required): ```bash python3 scripts/render.py --config config.json --vo working/vo2/vo_atempo.mp3 \ --stills-dir working/stills --out working/final.mp4 \ [--words working/vo2/words.json] [--endcard working/endcard.png] ``` This is the **FREE, deterministic** assembly stage — it spends nothing. The paid inputs are separate capabilities — the spoken VO (`create-vo-elevenlabs`, ElevenLabs `eleven_v3` from a tone-tagged script, atempo time-stretched so the delivered duration sets the timeline) and the 4–6 hero stills in one look pack (`create-image-fal`, Flux Pro 1.1, reused as repeats to reach the ~10–12 cuts). Given the VO + the stills + the per-cut weight array + the hook line, `render.py` distributes the cuts across the VO duration by the weighted formula, Ken-Burns-renders each still, concats, composites the VO, fades the hook line on over the open, burns the captions, and (if `--endcard` is passed) appends a brand end card → the master + a poster. Re-cuts reuse the existing VO / stills and cost **$0**. See `scripts/README.md` §0 for the full arg contract. ## Contract (the free assembly) - **The spoken VO carries the narrative — no talking head, no sung song.** The generated/supplied VO IS the audio bed (no music bed by default); do not add a presenter or a second bed. - **Plan the timeline AROUND the delivered VO duration.** The VO is atempo time-stretched (clamp the factor ≤ ~1.25 so the voice never chipmunks); its delivered length sets the timeline — never trim the VO to a pre-planned grid. - **Weighted beat-sync, not a fixed grid.** For each cut, `cut_dur = VO_dur × weight / Σweights` — heavier weights hold longer on the emotional beats (the open, the reframe, the close); the setup cuts run shorter. Every cut stays proportional to the whole VO. - **Ken-Burns per still.** Render each still with scale 2×, center crop, and a `zoompan` to the configured `zoom_end` (~1.10); apply `zoom_out` on the flagged cuts; `fade_in` on the FIRST cut and `fade_out` on the LAST. Stills are reusable — the sequence repeats a few across the cuts. - **ONE cosmic look, no in-world text.** Every still reads in the single deep-indigo + gold + volumetric-light look; the reel's only text is the hook + the captions, added in post (the "no text, no words" descriptor keeps words off the stills). - **ONE hook line, alpha-faded on over the open.** Burn the single reframe line over the OPEN only (fade in ~0.5s, hold, fade out ~0.6s) — never a persistent caption, never in-world. `render.py` does this with a PIL PNG + ffmpeg `fade=…:alpha=1` (no `drawtext` dependency, since stock ffmpeg often lacks it); an ffmpeg `drawtext` alpha window is an equivalent alternative where available. - **Captions from Whisper — bottom, white.** VEED/Whisper subtitle burn tracks the spoken VO in the bottom third, white `#FFFFFF`. If the host ffmpeg lacks libass (no `subtitles`/`ass` filter), render the cues as timed PIL PNG overlays (ffmpeg `overlay=…:enable='between(t,st,en)'`) at the same bottom placement — a free local Whisper + ffmpeg burn is the fallback to the VEED tier. - **FFmpeg composite, deterministic, FREE.** ffmpeg-concat the Ken-Burns clips, composite the VO under the picture (libx264 `crf 18` + aac 192k), burn the hook alpha-fade + the caption track → a 1080×1920 h264+aac master (~31s). No paid calls, no keys (the local caption fallback is free). -
skill.meta.json 343 B
{ "slug": "render-cosmic-mythology-voiceover", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-cosmic-mythology-voiceover", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.