Avatar Reel Composer
Orchestrates the sibling skills into one finished reel from a script + an
existing avatar, replicating the structure of the avatar's analyzed reels.
An optional finishing pass (finish_reel.py) adds burned-in word-timed
subtitles + a music bed under the voice — flat by default, or a structured
volume envelope (entrance / lift / settle / duck / resolve, anchored to scene
boundaries) that makes the soundtrack do editing work. SFX stingers and dissolves
are still deferred (see Next phases).
When to use
- The user has an avatar folder (e.g.
lolo/) that already contains videos/,
a trained voice in voices/, camera-angle images in angles/, and ideally a
talking_profile.json (all produced by the upstream skills below).
- They give you a script (what the avatar should say) and want a reel — 9:16
vertical (TikTok/Reels) or 16:9 landscape (YouTube), set via the storyboard's
format — where the avatar's voice narrates continuously while the video cuts
between talking-head shots and complementary B-roll — exactly like the
original reels analyzed by video-scene-analysis. For 16:9, use the avatar's
_169.png angle crops (see avatar-camera-angles --crop169).
If the avatar does NOT exist yet, create it first from its public Instagram
profile with create_avatar.py (see Stage 0: create an avatar from a public
Instagram URL).
Output location. create_avatar.py creates a bare avatar name under
./avatares/<name>/ (so avatars never clutter the project root); pass an
explicit path to override, or set AVATARES_ROOT. Storyboards reference the
avatar via its avatar_dir path (e.g. avatares/lolo), resolved against
--base-dir.
Prerequisites (existing avatar)
| Asset |
Produced by |
Used for |
videos/, <name>.analysis.json |
video-scene-analysis |
structural template (pacing, camera, zoom, emotion) |
voices/*.json (trained voice) |
voice-clone |
the cloned narration voice |
angles/**/<angle>_916.png (or _169.png for 16:9) |
avatar-camera-angles |
talking-head scene framings |
talking_profile.json |
video-scene-analysis |
reusable lip-sync prompt/personality |
A shared replicate_api_token (inherited from the sibling skills) is required.
Install deps once: pip3 install -r requirements.txt.
Pipeline
script ─► narrate.py ─► narration.mp3 (cloned voice, ONE TTS call per sentence,
│ joined with a small silence gap)
└► faster-whisper ─► narration.align.json (word timings)
storyboard.json (you write it, guided by <avatar>.analysis.json)
│
▼
compose_reel.py
1. align scene.text → [start,end] in the narration (snap cuts to silence)
2. slice narration.mp3 → scenes/chunk_<id>.mp3
3. talking_head → avatar-talking-video --audio chunk (lip-synced)
broll → broll-generator --duration ceil(chunk) (silent)
4. normalize each clip: TRIM to exact chunk dur (never freeze-pad) + scale/crop to the format size (1080x1920 reel / 1920x1080 landscape) + Ken Burns/zoom
5. concat with HARD CUTS (Σ durations == narration length)
6. mux narration.mp3 back on as the single master track → final.mp4
(optional finishing pass) finish_reel.py
7. captions: group words → self-contained PHRASE UNITS that REPLACE each other
(no stale already-spoken text stacked under a new line). A unit shows a
regular SETUP line + a BOLD-ITALIC PAYOFF line (the breath-ending / key
words); emphasis falls only on a breath group's completion, mid-breath
continuation units stay plain. Serif, white + soft shadow, lowercase
("subtitle") casing with intentional ALL-CAPS preserved and no trailing dot —
matching the analyzed reels. Rendered as transparent PNGs (Pillow), burned
in via video-compose's overlay_titles
8. music: bg-music-hq instrumental bed under the voice. Default = a FIXED low
volume (no ducking). Optional STRUCTURED envelope (--music-structure auto,
a storyboard finish.music_plan, or --music-from-cutsheet) makes the bed do
editing work: a hard-cut entrance, a lift/settle at an emotional shift, a
duck under a key line, a resolve into the close — anchored to scene
boundaries. TAILOR the prompt to the reel's tone
9. re-mux → final.mp4 (video copy + voice + music, flat or enveloped)
(optional polish pass) polish_reel.py — applied OVER the finished video
10. keep the pre-fx version as final-without-sfx.mp4
11. golden-flash transitions at B-roll cuts (the originals' warm amber wash,
rising+decaying over ~0.36s on the incoming scene — duration-preserving,
so nothing desyncs) + short-soft SFX (airy whoosh leading each B-roll cut
by ~0.35s, soft low boom under emphasis scenes), sparse (~1 per 15s) and
very quiet (~18% of voice) like the analyzed reels → final.mp4
Sync key: every chunk is cut from the same narration and the scenes are
assembled with hard cuts (no xfade that would shorten the timeline), so re-laying
the full narration on top lands perfectly in sync.
Output per reel: <avatar>/reels/<NNN>_<slug>/ containing storyboard.json,
narration.mp3, narration.align.json, scenes/ (chunks + normalized clips),
video_track.mp4, final.mp4, reel_manifest.json. After the finishing pass,
also captions/ (caption PNGs), video_sub.mp4 (captioned, silent) and
music.mp3 (the bed).
Why PNG captions, not ASS/SRT? This machine's ffmpeg is built without libass
(no subtitles/ass filter), so we render captions ourselves with Pillow and
composite them with the overlay filter — which also gives full styling control.
How to run
Write a storyboard.json (see examples/storyboard.example.json and the
schema below). Derive it from the avatar's *.analysis.json so the new reel
feels like the originals.
Compose:
# run from the folder your storyboard's relative paths are based on (repo root)
python3 ~/.cursor/skills/avatar-reel-composer/scripts/compose_reel.py storyboard.json --language es
Useful flags:
--dry-run — narrate + align + compute boundaries + slice the audio, then
stop. Always do this first to verify scene timing cheaply (one TTS call,
no video generation).
--regen — regenerate scene clips even if cached ones exist.
--force-narrate — rebuild narration.mp3 (reuses unchanged per-sentence
takes from narration_parts/; change a voice param to actually re-TTS).
--reroll N [M …] — force a fresh take of the given 1-based sentence
index(es) when one segment is mispronounced; the rest are reused, and only
the talking-heads whose audio changed are regenerated.
--out-dir DIR — write the reel to a specific folder (otherwise
<avatar>/reels/<NNN>_<slug>).
--base-dir DIR — base for resolving relative paths (default: CWD).
--whisper-model {tiny,base,small,medium} — alignment model (default small).
Finish (optional but recommended): add the serif phrase-unit captions + a
fixed-volume music bed. Either let compose_reel.py do it in one shot with
--finish (or a storyboard finish block), or run it standalone on any reel:
python3 ~/.cursor/skills/avatar-reel-composer/scripts/finish_reel.py <reel_dir> \
--style-from <avatar>/subtitle_style.json \
--music-prompt "…tailored to the reel's emotional tone…"
Useful flags: --no-music, --no-subtitles, --music-mood <preset>
(default ambient), --music-prompt "…" (tailor to the tone),
--music-volume 0.12 (FIXED bed level), --music-vocals {wordless,none}
(default wordless soft oohs/aahs; none = instrumental), --regen-music, --max-words 6
(max words per phrase unit), --no-emphasis (disable the bold-italic payoff),
--casing {subtitle,natural,lower,upper}, --fontsize, --y-frac, --regular-font,
--emph-font, --style-from <profile.json> (seed caption position/size/casing
from the analyzed reels — see Matching the analyzed caption style).
It's idempotent (reuses music.mp3 unless --regen-music) and re-runnable,
so you can iterate on caption style / music without regenerating any video.
Profile the originals' transitions (once per avatar): measure how the
avatar's ORIGINAL reels visually dress their cuts — flash or not, where
(B-roll entry/exit/talking-head cuts), how long, how strong, what hue
(golden vs white vs dip-to-black):
python3 ~/.cursor/skills/avatar-reel-composer/scripts/profile_transitions.py \
<video1>.analysis.json <video2>.analysis.json … # -> <avatar>/transition_style.json
It samples low-res frames around every scene boundary of each
*.analysis.json (video-scene-analysis output), measures brightness/warmth
deviation vs the surrounding baseline, and aggregates per boundary type
(e.g. the reference avatar flashes 100% of B-roll ENTRIES, 0% of exits) into
transition_style.json: style, flash_at, flash_dur, flash_gain.
Polish (optional): scene-cut transition effects + short-soft SFX,
applied OVER final.mp4 (the pre-fx version is kept as
final-without-sfx.mp4). Either via the storyboard finish.fx block (runs
automatically after the finish pass) or standalone:
python3 ~/.cursor/skills/avatar-reel-composer/scripts/polish_reel.py <reel_dir> \
--guide <avatar>/videos/<original>_voice/voice.json
The transition look comes from the avatar's own measured
transition_style.json (auto-discovered at <avatar>/transition_style.json,
or --style-from path); explicit flags win, built-in defaults (= the
reference avatar's measurements) are the last resort. Useful flags:
--transition-style {golden_flash,white_flash,dip_black,punch,none},
--no-transitions, --no-sfx, --sfx-volume 0.18, --flash-dur,
--flash-gain, --density N (seconds per SFX event), --guide voice.json
(a voice-isolate output of an ORIGINAL reel: its measured sfx_intervals
set the density), --regen-sfx. It's idempotent: re-running re-polishes
from the clean copy (effects never stack), and a fresh finish pass resets
the clean source. SFX assets are cached avatar-wide in
<avatar>/reels/_sfx_cache/.
narrate.py can also be run standalone (it's what stage 1 calls). Likewise
finish_reel.py and polish_reel.py run standalone on any reel folder
produced by compose_reel.py.
Storyboard schema (you write this)
Top-level:
| Field |
Meaning |
avatar_dir |
path to the avatar folder (abs or relative to --base-dir) |
slug |
short id for filenames / the reel folder |
reference_analysis |
the *.analysis.json you based pacing on (recorded in manifest) |
format |
reel (1080x1920), post (1080x1080) or landscape (1920x1080) |
resolution |
720p or 1080p — generation resolution for both models |
fps |
final reel fps (default 30) |
voice |
{name, voice_id, emotion, speed, language_boost, volume, pitch, sentence_gap, sentences_per_call} (all optional; voice auto-resolves). language_boost defaults to None (no boost — keeps the cloned voice's own accent; boosting e.g. Spanish can drag a neutral/Chilean clone toward another regional accent like Argentinian voseo). sentence_gap = silence in seconds joining sentence takes (default 0.12); sentences_per_call = sentences per TTS call (default 1 = one per sentence) |
script |
the FULL verbatim narration (optional — defaults to the scenes' text joined). MAY contain MiniMax expressive interjections — (sighs), (exhales), (laughs softly), … — and manual pauses <#0.5#>; they're spoken by the TTS and ignored by alignment/captions (whisper doesn't transcribe them). --list-interjections in voice-clone lists the recognized set |
location |
OPTIONAL reel-default look for the avatar (a "location" = wardrobe + environment + light, created by the avatar-location skill). Talking-head angles then resolve from <avatar>/locations/<location>/angles/ instead of the top-level angles/. Omit or set "default" for the avatar's base look (today's behavior). Per-scene location overrides this. |
scenes[] |
ordered scenes (below) |
finish |
optional finishing-pass config (see below); runs automatically when present |
Optional finish block (also overridable by compose_reel.py --finish flags):
| Field |
Meaning |
enabled |
true to auto-run the finishing pass after assembly |
subtitles |
burn in word-timed captions (default true) |
music |
add a fixed-volume music bed (default true) |
music_mood |
bg-music-hq mood preset (default ambient; e.g. cinematic, inspiring, dramatic, lofi) |
music_prompt |
tailor this to the reel's emotional tone (read from the script + B-roll); light, instrumental, no drums |
music_volume |
BASE bed level under the voice, 0–1 (default 0.12). With music_structure/music_plan this is the level the envelope moves around (still no sidechain ducking) |
music_vocals |
wordless (default — soft, non-distracting oohs/aahs) or none (instrumental). Stage directions are NEVER sung either way |
music_structure |
flat (default — constant bed) or auto (a tasteful volume envelope from the scene structure: duck under the hook, lift after it, resolve on the close) |
music_plan |
explicit soundtrack moves — {"moves":[{"type":…,"at":…,"amount":…}]} — the precise envelope (overrides music_structure). See Structured music below |
music_from_cutsheet |
path to a rule-of-six-edit *.cutsheet.json; its per-cut sound notes are mapped (best-effort) to music moves at their scene boundaries |
max_words |
max words per caption phrase unit (default 6) |
emphasis |
highlight each breath group's payoff in bold-italic (default true) |
casing |
subtitle (default — lowercase like the analyzed reels: no sentence-initial capitals and no trailing dot, but intentional ALL-CAPS words like REPE/NO stay shouted and accents are kept), natural (preserve ASR/script casing), lower, or upper |
caption_reveal |
word (default — karaoke reveal: each word appears as it's spoken, building the phrase in place; already-spoken words stay lit, the phrase clears on the next unit) or phrase (the whole phrase unit pops in at once). See the caption-word-reveal skill |
style_from |
path to a subtitle_style.json (from avatar-frames) to seed caption position/size/casing |
regular_font / emph_font |
override the serif / bold-italic caption fonts (TTF) |
fontsize / y_frac |
caption size (px) / vertical center as fraction of height (defaults: profile, else ~7.2% of the SHORTER side / 0.66 for 9:16, 0.85 lower-third for 16:9) |
fx |
OPTIONAL polish-pass block (see below) — runs after the finish pass, keeps final-without-sfx.mp4 |
finish.fx block (stage 4, polish_reel.py):
| Field |
Meaning |
enabled |
true to auto-run the polish pass after finishing |
transition_style |
OMIT to use the avatar's measured transition_style.json (recommended). Override: golden_flash (warm amber wash over the incoming scene), white_flash, dip_black, punch (small zoom pulse), none (bare hard cuts) |
style_from |
explicit path to a transition_style.json (default: auto-discover <avatar>/transition_style.json written by profile_transitions.py) |
sfx |
overlay short-soft SFX (default true): airy whoosh leading each B-roll cut by ~0.35s + soft low boom under emphasis: true scene starts |
sfx_volume |
SFX level under the voice (default 0.18 ≈ the originals' non-speech/speech RMS ratio) |
flash_dur / flash_gain |
flash length (s) / strength — OMIT to use the measured profile (fallback 0.36 / 1.0) |
density |
seconds per SFX event (default: from guide, else 15 as measured) |
guide |
path to a voice-isolate voice.json of an ORIGINAL reel — its measured sfx_intervals set the density |
regen_sfx |
regenerate the cached SFX assets (<avatar>/reels/_sfx_cache/) |
Hard rule: the concatenation of every scene.text (joined with single
spaces) must equal script verbatim. The script is narrated as one take, then
cut per scene; if the texts don't tile the script the alignment falls back to a
rough proportional split.
Each scene:
| Field |
Applies to |
Meaning |
id |
all |
unique id (e.g. s1); used for filenames |
type |
all |
talking_head, broll or guest |
text |
all |
the contiguous slice of the script spoken during this scene |
motion |
all |
Ken Burns/zoom: zoom_center, push_in, push_out, drift_{left,right,up,down}, none |
emphasis |
all |
true bumps motion intensity (subtle→medium); a marked zoom-in for key lines |
image |
talking_head |
path to a *_916.png (9:16) or *_169.png (16:9) camera-angle image (preferred); an explicit path always wins over angle/location |
angle |
talking_head |
alternative to image: a move name (e.g. push_in) globbed under the active location's angles/, preferring the crop that matches the reel format (*_169.png for landscape, else *_916.png, then any *.png). Falls back to the default look (with a warning) if the location lacks that angle |
location |
talking_head |
OPTIONAL per-scene look override (a name from the avatar-location skill); overrides the reel-level location. "default"/unset = the avatar's base look |
video_prompt / negative_prompt |
talking_head |
optional p-video-avatar overrides; omit to use talking_profile.json |
broll_description |
broll |
the scene to generate (people/objects/environment, NO main presenter) |
broll_camera |
broll |
handheld, push_in, pull_out, pan_left, pan_right, orbit, static |
broll_action |
broll |
explicit continuous human performance (gestures, talking/not), avoids the "mannequin" look |
broll_source |
broll |
generate (default — synthesize with broll-generator) or existing (use a real found-footage clip instead, e.g. from the broll-finder skill) |
broll_clip |
broll |
path to a pre-made B-roll clip (abs / relative to --base-dir / the avatar folder); required when broll_source: existing. Silent clips shorter than the slot are looped to cover it, then trimmed |
broll_clip |
guest |
path to the pre-made guest clip (with its OWN voice) built by assemble_narration.py; the scene is used as-is and the cut is pinned to its exact end (no pad, no loop) |
Guest / cameo scenes — a DIFFERENT avatar inside the reel
A guest scene drops a clip of another avatar speaking in their own voice
into the host avatar's reel — e.g. a photorealistic human presenter opens the
hook ("…I'm not real") before a surprise cut to the host. It is the right tool
whenever a beat needs a different face/voice than the host. (It is not B-roll:
B-roll is silent and gets the host's voice-over laid on top; a guest clip keeps
its own audio and must NOT be narrated over.)
Because compose_reel.py muxes one master narration over the whole timeline,
the guest's voice has to be woven INTO that master track. assemble_narration.py
does this: it stitches a single narration.mp3 + narration.align.json from an
ordered list of segments (guest clips + the host narration), so everything
downstream (boundaries, captions, music, flash) just works.
# 1) Build the master narration from heterogeneous segments (gap 0 → host picks up
# the instant the guest stops). See the plan schema in assemble_narration.py.
python3 .cursor/skills/avatar-reel-composer/scripts/assemble_narration.py plan.json --base-dir .
# → writes narration.mp3 + narration.align.json into out_dir, prints guest scene stubs.
# 2) Put the printed stub(s) into storyboard.json as `type: "guest"` scenes, set the
# storyboard `script` = the full combined text, then compose normally (it REUSES
# the pre-built narration when you point --out-dir at that folder):
python3 .cursor/skills/avatar-reel-composer/scripts/compose_reel.py storyboard.json --base-dir . --out-dir <reel_dir> --finish
Segment kinds in the plan: guest (generates a lip-synced clip of avatar_dir
via avatar-talking-video and uses its audio — matched to the clip's exact video
length with inaudible trailing silence), audio (an existing file, e.g. a host
audio-theater dialogue.wav), tts (host MiniMax voice via narrate.py).
No freeze pad — the next clip starts immediately. A guest scene is never
looped or frozen: the composer pins its boundary to the clip's real duration, so
the host cuts in the instant the guest stops talking. The guest segment in the
master narration is matched to that same duration, so there is zero downstream
drift. A guest cut is also treated as an INSERT boundary (like B-roll), so the
polish pass's golden-flash + whoosh naturally land on the reveal.
Any position — hook, middle or end. Guests are not limited to the opening
scene. When assemble_narration.py ran, it recorded each clip's exact
[start, end] in the master narration (assemble_narration.out.json); the
composer reads those and pins BOTH the in- and out-point of every guest scene, so
a mid-reel guest (host → guest → host) is just as frame-exact as a leading
one. To place a guest in the middle, supply the host narration as TWO segments
(before / after the guest) in the plan, with the guest segment between them, and
keep the storyboard scene order matching the plan order.
Locations — one avatar, multiple looks
A location is a look for the avatar — wardrobe + environment + light bundled
together — built by the sibling avatar-location skill (create_location.py).
It keeps the avatar's identity (face, gestures, voice, talking_profile) and
only changes how it's dressed/roomed, with its own identity-anchored hero + camera
angles under <avatar>/locations/<loc>/angles/. The avatar's default look is just
the top-level scene.json + angles/ (unchanged).
- Set a reel-wide look with the top-level
location: "<loc>", and/or per-scene
location overrides — so a reel can cut between looks while the same person keeps
talking (e.g. open in studio_night, then back to default).
- Only talking-head angle resolution is affected; the voice, narration, guest
and B-roll scenes are untouched. An explicit scene
image path still wins.
- A
--dry-run prints the resolved angle (and @ <loc>) per talking-head scene, so
you can confirm the avatar + look before any paid generation. If a location is
missing an angle, the scene falls back to the default look with a warning.
# 1) Create the look (review checkpoint, then 1 hero + ~5 angles via gpt-image-2)
python3 .cursor/skills/avatar-location/scripts/create_location.py nora studio_night \
--setting studio --brief "evening studio, moody teal key light, black turtleneck"
# ...refine nora/locations/studio_night/scene.json, then re-run to generate.
python3 .cursor/skills/avatar-location/scripts/list_locations.py nora
# 2) Reference it in the storyboard: top-level "location": "studio_night" and/or
# per-scene "location"; then compose normally.
Deriving the storyboard from <avatar>.analysis.json
Read the avatar's analysis and mirror its rhythm so the new reel matches.
Pacing rules (do this first — it's what makes a reel engaging)
Short-form reels keep attention by cutting often and never lingering.
Reproduce the analyzed reel's rhythm, not just its talking-head:B-roll ratio:
- Compute the target:
target_scene_len ≈ median scene duration of the
reference analysis (typically ~4–6s); num_scenes ≈ narration_seconds / target_scene_len. A 30s reel usually wants ~6–8 scenes, not 3–4.
- Open with a short hook: the first scene should be ~2–3s (match the
reference's scene #0). Never open with a 5s+ talking-head — that's the #1 way
to lose the viewer early.
- No shot lingers: keep every scene ≤ ~6s (a slightly longer ~7–8s shot
is OK only for an emotional B-roll outro). If a sentence is long, split it
across 2+ scenes at commas/colons/semicolons and change the framing/zoom on
each — the scene texts still must tile the script verbatim.
- Vary consecutive talking-heads: alternate angle + zoom (
eye_level
zoom_center → push_in zoom-in → pull_out zoom-out …) so back-to-back
presenter shots read as distinct cuts, exactly like the original's repeated
hard_cut + zoom_in/zoom_out.
- Place B-roll deliberately: put one insert right after the hook and one for
the outro at minimum; sprinkle more to break up long talking-head stretches.
compose_reel.py prints a pacing report (vs the reference median) before
generating and warns on a long hook / over-long shots — adjust the storyboard
until the warnings are gone.
Field-by-field mapping
scene_type → scene type: main_character_solo → talking_head;
supplementary_material → broll.
zoom_from_previous.type → motion. You can either set motion directly,
or copy the analysis value verbatim into the scene's zoom_from_previous
and the pipeline maps it for you: zoom_in → push_in, zoom_out →
push_out, hard_cut → none (clean static reframe), none → zoom_center
(subtle) for talking-head / none for B-roll. An explicit motion always
wins. B-roll keeps none regardless (its camera move is baked in at
generation, so don't double it).
Camera-angle sequence (base the new reel on the analyzed one)
The talking-head shots are NOT free camera moves — they reuse a few
pre-rendered angle crops from <avatar>/angles/*_916.png (or *_169.png for
a 16:9 reel) plus digital zoom. So replicate the reference's tendencies, not a
1:1 angle-per-scene copy (the new reel has fewer scenes):
compose_reel.py prints a camera fingerprint of the reference
(talking-head angle + framing distribution, and the zoom-transition mix). Match it.
- Base shot: use the dominant angle/framing for most talking-heads (for the
analyzed
lolo reel that's eye_level / medium_close_up → the
lolo_eye_level_916.png crop).
- Emphasis: for the lines the original tightens on (its
close_up /
zoom_in scenes), use a closer crop (*_push_in_916.png) with
emphasis: true.
- Variety: alternate in the other available crops (
pull_out,
negative_space_left) so consecutive presenter shots read as real cuts,
mirroring the reference's repeated hard_cut + zoom_in/zoom_out.
- Zoom mix: keep roughly the reference's ratio (here ≈ half
hard_cut,
balanced zoom_in/zoom_out) — copy the per-scene values into
zoom_from_previous to reproduce it faithfully.
- Widen the palette: if the reference uses angles you don't have a crop for
(e.g.
high_angle), generate them first with the avatar-camera-angles
skill, then reference the new *_916.png in the storyboard.
camera.angle / camera.framing → pick the matching talking-head
image from angles/ (e.g. eye_level, a closer push-in for emphasis).
summary.emotion → informs the voice.emotion and the mood of B-roll
descriptions/actions.
summary.focus / camera.description of B-roll scenes → inspiration for
your broll_description (reinforce the spoken idea visually, WITHOUT the
presenter). Always give people a broll_action so they aren't mannequins.
- emphasis: set
emphasis: true on talking-head lines the original drives
home with a tighter zoom.
Motion mapping (replicates the analyzed pattern)
push_in / push_out / zoom_center / drift_* / none come from
video-compose's MOTION_DEFS and are applied with apply_camera_motion.
- talking-head default (no
motion): zoom_center subtle; emphasis → medium.
- B-roll default:
none (its camera move is baked in at generation time).
Reuse of video-compose
apply_camera_motion — Ken Burns/zoom on each scene clip (this skill applies
it to video clips, which video-compose's own render_final does not).
FORMAT_PRESETS (reel=1080x1920), ffprobe_video, run_ffmpeg.
- We do not use its
mix_music or the xfade-with-silent-audio path: with a
voice-over we keep the narration as the single master track and cut hard.
Notes / defaults
- Hard cuts only (v1): xfade would shorten the timeline and desync the
narration. Motion lives inside each scene (Ken Burns/zoom) — which is exactly
the analyzed reels' pattern (mostly
hard_cut + zoom_in/out). Short
dissolves are a future improvement (need to compensate the audio overlap).
- Idempotency:
narration.mp3 + narration.align.json are reused if present
(skip with nothing, or re-make with --force-narrate). Scene clips are cached
in <avatar>/generated-videos/ (talking-heads, keyed by scene + an audio
fingerprint) and <avatar>/broll/ (keyed by scene); regenerate with --regen.
So you can iterate on assembly without paying for generation twice.
- Music cache (don't regenerate a good bed): the per-reel
music.mp3 is reused
unless --regen-music. On top of that, the RAW generated track is cached
avatar-wide in <avatar>/reels/_bgm_cache/<key>.mp3, keyed by
prompt + mood + vocals. So a second attempt — or a new reel version
(-v2, -v3) — with the same musical intent reuses the already-good track
(just re-fit/looped to that reel's length) instead of paying MiniMax again.
Change the prompt/mood/vocals to get (and cache) a fresh bed; --regen-music
forces a new generation and refreshes the cache entry.
- No freeze-pad; B-roll covers its slot: clips are only ever TRIMMED to the
scene duration, never frozen on a held last frame. Talking-heads run the
chunk's exact (audio-driven) length; B-roll is silent (nothing to sync to),
is generated at
ceil(target) ≥ slot, and a cached B-roll that no longer
covers its slot (e.g. after re-narration lengthened the scene) is regenerated
rather than stretched/frozen.
- Per-sentence narration + cache:
narrate.py synthesizes one MiniMax call
per sentence (decimal/abbreviation-aware split) and joins the takes with a
small sentence_gap of silence. This avoids the audio-quality degradation
speech-2.8-hd shows on long single takes (its own docs recommend short
sentences), and the sentence-boundary gaps give the caption engine clean pauses
to clear on. Each take is cached in <reel>/narration_parts/ keyed by its
text + voice params, so re-narrating only regenerates what changed. If one
segment is mispronounced, re-roll just that sentence with
compose_reel.py <storyboard> --reroll N (1-based index, from the list
narrate.py prints) — the rest are reused, and only the talking-heads whose
audio actually changed are regenerated (their cache is audio-fingerprinted).
Group sentences with voice.sentences_per_call to trade a little prosody
continuity for fewer API calls.
- No language boost by default: narration uses
language_boost="None" so the
cloned voice keeps its own accent. Boosting a language nudges pronunciation
toward a "standard"/regional accent that can fight the clone (e.g. a neutral or
Chilean voice drifting into Argentinian voseo). Set voice.language_boost to
a locale only if you specifically need that pronunciation help.
- Expressive delivery: keep the narration from sounding flat by setting a
fitting
voice.emotion and, sparingly, dropping MiniMax interjections
((sighs), (exhales), (laughs softly), …) or manual pauses <#0.5#> right
into script. They render in the voice but are invisible to alignment/captions.
- Captions: ASR timing + ASR styling, script spelling (forced alignment):
faster-whisper gives the timing; its word strings are phonetic, so it
mis-hears acronyms (
REPE → rape). align() aligns each ASR word to the
script and fixes only what should be fixed, while keeping normal subtitle
conventions:
- same word (ignoring case AND accents) ⇒ keep the ASR word verbatim — its
casing, accents and punctuation.
Sólo/sólo/solo are "the same"; we never
impose the script's sentence-capitalization or punctuation, and never strip
whisper's accents (cómo, relación stay accented).
- intentional ALL-CAPS in the script (≥2 letters) ⇒ forced uppercase in the
caption, since caps denote intent (acronyms/emphasis:
NO, REPE).
- genuinely different letters ⇒ adopt the script spelling, rendered in the
ASR word's own case style (so
rape → REPE, but no mid-sentence capitals).
Originals are kept under asr_word in narration.align.json for debugging.
- Polish pass is duration-preserving and applied OVER the final video: the
fx layer (stage 4) never re-cuts segments. Real crossfades are forbidden —
they overlap clips and shorten the timeline, desyncing the continuous
narration + captions. The golden flash is a per-cut color envelope (stepped
eq slices under timeline enable; eq does NOT re-evaluate t
expressions per frame) and SFX are an additive audio overlay — neither adds
or removes a single frame. The pre-fx video is always kept as
final-without-sfx.mp4; re-polishing starts from that clean copy (effects
never stack), and a fresh finish pass clears the fx marker so the new
final.mp4 becomes the clean source.
- FX fingerprint is MEASURED per avatar, not assumed:
profile_transitions.py studies the avatar's own originals (frames sampled
around every analyzed scene boundary) and writes transition_style.json —
flash presence per boundary type, hue (golden/white), duration, strength.
polish_reel.py auto-discovers it, so a new avatar whose originals use a
white flash, a dip-to-black, or no transition at all gets THEIR look, not the
reference's. E.g. the reference avatar measures: golden flash on 100% of
B-roll entries, 0% of exits, 0.40s, gain ~1.0. SFX are sparse (1 per
15s), short (0.3–0.7s), very soft (~15–20% of voice RMS), placed either
leading a B-roll cut by ~0.35s (whoosh) or under an emphasized phrase (soft
low boom). --guide (a voice-isolate voice.json of an original)
recomputes the density from its measured sfx_intervals.
- Module naming: the shared module is
_arc_common.py (not _common.py) on
purpose, so importing video-compose's _video_pipeline (which does
from _common import …) resolves to its _common, not ours.
Stage 0: create an avatar from a public Instagram URL (create_avatar.py)
If the avatar doesn't exist yet, build it with the orchestrator before composing
reels. create_avatar.py runs the whole setup chain with idempotent resume,
skipping any stage whose outputs already exist and stopping with instructions at
the two steps that need you (the agent): the browser scrape and the vision
enrichment. Re-run after each to continue.
download instagram-videos -> <avatar>/videos/*.mp4
analyze video-scene-analysis -> <avatar>/videos/*.analysis.json (+ *_frames/)
[CHECKPOINT] AGENT vision enrichment of the analyses (avatar_profile.video_prompt)
frames avatar-frames -> <avatar>/frames/ + subtitle_style.json
voice voice-isolate + voice-clone -> <avatar>/voices/
transitions profile_transitions.py -> <avatar>/transition_style.json
profile export_talking_profile.py -> <avatar>/talking_profile.json
report -> <avatar>/avatar.json + readiness table
Agent workflow:
- Scrape the profile with the browser MCP per the
instagram-videos SKILL
(confirm the profile is public, scroll Picnob, collect every .post_box) and
save the array to posts-raw/meta/picnob_<handle>.json.
- Run the orchestrator (from the repo root, so
<avatar>/... paths resolve):python3 .cursor/skills/avatar-reel-composer/scripts/create_avatar.py <avatar> \
--handle <handle_or_url> [--language es] [--voice-video <name>]
It downloads + analyzes, then stops at the enrichment checkpoint.
- Enrich each
*.analysis.json per the video-scene-analysis SKILL — view
the <stem>_frames/scene_XX.jpg and fill camera/framing, focus/emotion,
mannerisms and the reusable avatar_profile.video_prompt/negative_prompt.
- Re-run the same command. It resumes: frames, voice, transitions, profile,
then writes
avatar.json and prints a readiness table. --status shows that
table any time without running anything (zero API spend); --force-stage NAME
re-runs one completed stage.
Camera-angle stills (angles/*_916.png, via avatar-camera-angles) are the one
piece this orchestrator does not generate — add them if you want fixed
talking-head framings; otherwise the talking-head scenes use the reference
frames. Then write the storyboard and run compose_reel.py as above.
Finishing pass (implemented — finish_reel.py)
- Burned-in subtitles — matched to the analyzed reels. Word timings from
narration.align.json are segmented into self-contained phrase units (breath
groups, split at pauses/punctuation; a group longer than max_words (default 6)
is subdivided recursively & balanced so EVERY unit is short — no oversized
leftover chunk — and breaks land at proclitic-safe points so a line never ends on
que/en/a/el…). Each unit REPLACES the previous one — captions are
never rolled/accumulated, so the viewer never reads stale already-spoken text
stacked under a new line (the avatar-frames profile confirms the originals'
progression: "replace", word-overlap ≈ 0.1).
- Meaningful emphasis. A unit renders as a regular setup line + a
bold-italic PAYOFF line — the breath-ending / key words that complete the
thought (e.g.
de no poder → soltar.). Emphasis falls only on a breath
group's completion; mid-breath continuation units stay plain (so the bold
is reserved for what matters, like the originals). The setup/payoff split is
balanced (comma-preferred, proclitic-safe). --no-emphasis / emphasis:false
disables it.
- Always ~2 lines (like the originals). Lines use a balanced wrap (a small
DP that minimizes the widest line) so a caption never strands an orphan
single-word line.
- Split, don't shrink. If a caption wouldn't fit at the nominal font, it's
split into sequential full-size captions (each shown for its own words) —
necesitas reprogramar → tus patrones / subconscientes. — rather than crammed
into a tiny block. Lower cognitive load, consistent big text. Splits keep both
sides ≥2 words (no lone-word flash). The font only auto-fits as a last resort
(gentle ~80% floor) for a residual 2-long-word overflow.
- Tracks speech / clears at pauses. Each caption is time-bounded to its spoken
words; when a long pause follows (sentence boundary, > ~0.4s from the
word-level alignment) it clears shortly after the last word instead of lingering
on screen with not-yet-spoken text.
- Style: an elegant serif (Georgia), lowercase "subtitle" casing
(no sentence-initial capitals, no trailing dot; intentional ALL-CAPS like
REPE/NO kept, accents kept), white with a
soft drop shadow (thin subtle outline, no heavy block outline). Rendered as
transparent PNGs (Pillow) and composited with video-compose's
overlay_titles; windows are contiguous (no flicker).
- Music bed (fixed or structured). Reuses
bg-music-hq's prompt/stru
…(truncated)
1---2name: avatar-reel-composer3description: Turn a script + an existing avatar into a finished reel — 9:16 vertical (TikTok/Reels) or 16:9 landscape (YouTube), set via the storyboard `format`. Narrates the script once in the avatar's cloned voice, cuts it per scene by word-level alignment, generates lip-synced talking-head scenes (avatar-talking-video) and silent B-roll scenes (broll-generator) as voice-over, applies Ken Burns / zoom motion, and assembles with hard cuts under one master narration. Can weave in a GUEST/cameo scene (a clip of a DIFFERENT avatar speaking in its own voice) that keeps its own audio. Supports per-reel and per-scene LOCATIONS (alternate avatar looks built by avatar-location). Can also onboard a brand-new avatar from a public Instagram profile via create_avatar.py (download, analysis, frames, voice cloning, style profiling). Use when the user wants to produce a full reel/short from a script for an avatar that already exists (videos/, voices/, angles/ and/or talking_profile.json), or to onboard a new avatar from an Instagram UR4---56# Avatar Reel Composer78Orchestrates the sibling skills into one finished reel from a **script** + an9**existing avatar**, replicating the structure of the avatar's analyzed reels.10An optional **finishing pass** (`finish_reel.py`) adds burned-in word-timed11subtitles + a music bed under the voice — flat by default, or a **structured12volume envelope** (entrance / lift / settle / duck / resolve, anchored to scene13boundaries) that makes the soundtrack do editing work. SFX stingers and dissolves14are still deferred (see *Next phases*).1516## When to use17- The user has an avatar folder (e.g. `lolo/`) that already contains `videos/`,18 a trained voice in `voices/`, camera-angle images in `angles/`, and ideally a19 `talking_profile.json` (all produced by the upstream skills below).20- They give you a **script** (what the avatar should say) and want a reel — 9:1621 vertical (TikTok/Reels) or 16:9 landscape (YouTube), set via the storyboard's22 `format` — where the avatar's voice narrates continuously while the video cuts23 between talking-head shots and complementary B-roll — exactly like the24 original reels analyzed by `video-scene-analysis`. For 16:9, use the avatar's25 `_169.png` angle crops (see `avatar-camera-angles --crop169`).2627If the avatar does NOT exist yet, create it first from its public Instagram28profile with `create_avatar.py` (see *Stage 0: create an avatar from a public29Instagram URL*).3031> **Output location.** `create_avatar.py` creates a bare avatar name under32> `./avatares/<name>/` (so avatars never clutter the project root); pass an33> explicit path to override, or set `AVATARES_ROOT`. Storyboards reference the34> avatar via its `avatar_dir` path (e.g. `avatares/lolo`), resolved against35> `--base-dir`.3637## Prerequisites (existing avatar)38| Asset | Produced by | Used for |39|---|---|---|40| `videos/`, `<name>.analysis.json` | `video-scene-analysis` | structural template (pacing, camera, zoom, emotion) |41| `voices/*.json` (trained voice) | `voice-clone` | the cloned narration voice |42| `angles/**/<angle>_916.png` (or `_169.png` for 16:9) | `avatar-camera-angles` | talking-head scene framings |43| `talking_profile.json` | `video-scene-analysis` | reusable lip-sync prompt/personality |4445A shared `replicate_api_token` (inherited from the sibling skills) is required.46Install deps once: `pip3 install -r requirements.txt`.4748## Pipeline49```50script ─► narrate.py ─► narration.mp3 (cloned voice, ONE TTS call per sentence,51 │ joined with a small silence gap)52 └► faster-whisper ─► narration.align.json (word timings)53storyboard.json (you write it, guided by <avatar>.analysis.json)54 │55 ▼56 compose_reel.py57 1. align scene.text → [start,end] in the narration (snap cuts to silence)58 2. slice narration.mp3 → scenes/chunk_<id>.mp359 3. talking_head → avatar-talking-video --audio chunk (lip-synced)60 broll → broll-generator --duration ceil(chunk) (silent)61 4. normalize each clip: TRIM to exact chunk dur (never freeze-pad) + scale/crop to the format size (1080x1920 reel / 1920x1080 landscape) + Ken Burns/zoom62 5. concat with HARD CUTS (Σ durations == narration length)63 6. mux narration.mp3 back on as the single master track → final.mp46465(optional finishing pass) finish_reel.py66 7. captions: group words → self-contained PHRASE UNITS that REPLACE each other67 (no stale already-spoken text stacked under a new line). A unit shows a68 regular SETUP line + a BOLD-ITALIC PAYOFF line (the breath-ending / key69 words); emphasis falls only on a breath group's completion, mid-breath70 continuation units stay plain. Serif, white + soft shadow, lowercase71 ("subtitle") casing with intentional ALL-CAPS preserved and no trailing dot —72 matching the analyzed reels. Rendered as transparent PNGs (Pillow), burned73 in via video-compose's overlay_titles74 8. music: bg-music-hq instrumental bed under the voice. Default = a FIXED low75 volume (no ducking). Optional STRUCTURED envelope (--music-structure auto,76 a storyboard finish.music_plan, or --music-from-cutsheet) makes the bed do77 editing work: a hard-cut entrance, a lift/settle at an emotional shift, a78 duck under a key line, a resolve into the close — anchored to scene79 boundaries. TAILOR the prompt to the reel's tone80 9. re-mux → final.mp4 (video copy + voice + music, flat or enveloped)8182(optional polish pass) polish_reel.py — applied OVER the finished video83 10. keep the pre-fx version as final-without-sfx.mp484 11. golden-flash transitions at B-roll cuts (the originals' warm amber wash,85 rising+decaying over ~0.36s on the incoming scene — duration-preserving,86 so nothing desyncs) + short-soft SFX (airy whoosh leading each B-roll cut87 by ~0.35s, soft low boom under emphasis scenes), sparse (~1 per 15s) and88 very quiet (~18% of voice) like the analyzed reels → final.mp489```90**Sync key:** every chunk is cut from the *same* narration and the scenes are91assembled with hard cuts (no xfade that would shorten the timeline), so re-laying92the full narration on top lands perfectly in sync.9394Output per reel: `<avatar>/reels/<NNN>_<slug>/` containing `storyboard.json`,95`narration.mp3`, `narration.align.json`, `scenes/` (chunks + normalized clips),96`video_track.mp4`, `final.mp4`, `reel_manifest.json`. After the finishing pass,97also `captions/` (caption PNGs), `video_sub.mp4` (captioned, silent) and98`music.mp3` (the bed).99100**Why PNG captions, not ASS/SRT?** This machine's ffmpeg is built without libass101(no `subtitles`/`ass` filter), so we render captions ourselves with Pillow and102composite them with the `overlay` filter — which also gives full styling control.103104## How to run1051061. **Write a `storyboard.json`** (see `examples/storyboard.example.json` and the107 schema below). Derive it from the avatar's `*.analysis.json` so the new reel108 *feels* like the originals.1092. **Compose:**110 ```bash111 # run from the folder your storyboard's relative paths are based on (repo root)112 python3 ~/.cursor/skills/avatar-reel-composer/scripts/compose_reel.py storyboard.json --language es113 ```114 Useful flags:115 - `--dry-run` — narrate + align + compute boundaries + slice the audio, then116 stop. **Always do this first** to verify scene timing cheaply (one TTS call,117 no video generation).118 - `--regen` — regenerate scene clips even if cached ones exist.119 - `--force-narrate` — rebuild `narration.mp3` (reuses unchanged per-sentence120 takes from `narration_parts/`; change a `voice` param to actually re-TTS).121 - `--reroll N [M …]` — force a fresh take of the given 1-based sentence122 index(es) when one segment is mispronounced; the rest are reused, and only123 the talking-heads whose audio changed are regenerated.124 - `--out-dir DIR` — write the reel to a specific folder (otherwise125 `<avatar>/reels/<NNN>_<slug>`).126 - `--base-dir DIR` — base for resolving relative paths (default: CWD).127 - `--whisper-model {tiny,base,small,medium}` — alignment model (default `small`).1283. **Finish (optional but recommended):** add the serif phrase-unit captions + a129 fixed-volume music bed. Either let `compose_reel.py` do it in one shot with130 `--finish` (or a storyboard `finish` block), or run it standalone on any reel:131 ```bash132 python3 ~/.cursor/skills/avatar-reel-composer/scripts/finish_reel.py <reel_dir> \133 --style-from <avatar>/subtitle_style.json \134 --music-prompt "…tailored to the reel's emotional tone…"135 ```136 Useful flags: `--no-music`, `--no-subtitles`, `--music-mood <preset>`137 (default `ambient`), `--music-prompt "…"` (**tailor to the tone**),138 `--music-volume 0.12` (FIXED bed level), `--music-vocals {wordless,none}`139 (default `wordless` soft oohs/aahs; `none` = instrumental), `--regen-music`, `--max-words 6`140 (max words per phrase unit), `--no-emphasis` (disable the bold-italic payoff),141 `--casing {subtitle,natural,lower,upper}`, `--fontsize`, `--y-frac`, `--regular-font`,142 `--emph-font`, `--style-from <profile.json>` (seed caption position/size/casing143 from the analyzed reels — see *Matching the analyzed caption style*).144 It's **idempotent** (reuses `music.mp3` unless `--regen-music`) and re-runnable,145 so you can iterate on caption style / music without regenerating any video.1461474. **Profile the originals' transitions (once per avatar):** measure how the148 avatar's ORIGINAL reels visually dress their cuts — flash or not, where149 (B-roll entry/exit/talking-head cuts), how long, how strong, what hue150 (golden vs white vs dip-to-black):151 ```bash152 python3 ~/.cursor/skills/avatar-reel-composer/scripts/profile_transitions.py \153 <video1>.analysis.json <video2>.analysis.json … # -> <avatar>/transition_style.json154 ```155 It samples low-res frames around every scene boundary of each156 `*.analysis.json` (video-scene-analysis output), measures brightness/warmth157 deviation vs the surrounding baseline, and aggregates **per boundary type**158 (e.g. the reference avatar flashes 100% of B-roll ENTRIES, 0% of exits) into159 `transition_style.json`: `style`, `flash_at`, `flash_dur`, `flash_gain`.1605. **Polish (optional):** scene-cut transition effects + short-soft SFX,161 applied OVER `final.mp4` (the pre-fx version is kept as162 `final-without-sfx.mp4`). Either via the storyboard `finish.fx` block (runs163 automatically after the finish pass) or standalone:164 ```bash165 python3 ~/.cursor/skills/avatar-reel-composer/scripts/polish_reel.py <reel_dir> \166 --guide <avatar>/videos/<original>_voice/voice.json167 ```168 The transition look comes from the avatar's own measured169 `transition_style.json` (auto-discovered at `<avatar>/transition_style.json`,170 or `--style-from path`); explicit flags win, built-in defaults (= the171 reference avatar's measurements) are the last resort. Useful flags:172 `--transition-style {golden_flash,white_flash,dip_black,punch,none}`,173 `--no-transitions`, `--no-sfx`, `--sfx-volume 0.18`, `--flash-dur`,174 `--flash-gain`, `--density N` (seconds per SFX event), `--guide voice.json`175 (a voice-isolate output of an ORIGINAL reel: its measured `sfx_intervals`176 set the density), `--regen-sfx`. It's idempotent: re-running re-polishes177 from the clean copy (effects never stack), and a fresh finish pass resets178 the clean source. SFX assets are cached avatar-wide in179 `<avatar>/reels/_sfx_cache/`.180181`narrate.py` can also be run standalone (it's what stage 1 calls). Likewise182`finish_reel.py` and `polish_reel.py` run standalone on any reel folder183produced by `compose_reel.py`.184185## Storyboard schema (you write this)186Top-level:187| Field | Meaning |188|---|---|189| `avatar_dir` | path to the avatar folder (abs or relative to `--base-dir`) |190| `slug` | short id for filenames / the reel folder |191| `reference_analysis` | the `*.analysis.json` you based pacing on (recorded in manifest) |192| `format` | `reel` (1080x1920), `post` (1080x1080) or `landscape` (1920x1080) |193| `resolution` | `720p` or `1080p` — generation resolution for both models |194| `fps` | final reel fps (default 30) |195| `voice` | `{name, voice_id, emotion, speed, language_boost, volume, pitch, sentence_gap, sentences_per_call}` (all optional; voice auto-resolves). `language_boost` defaults to **`None`** (no boost — keeps the cloned voice's own accent; boosting e.g. `Spanish` can drag a neutral/Chilean clone toward another regional accent like Argentinian voseo). `sentence_gap` = silence in seconds joining sentence takes (default `0.12`); `sentences_per_call` = sentences per TTS call (default `1` = one per sentence) |196| `script` | the FULL verbatim narration (optional — defaults to the scenes' text joined). MAY contain MiniMax expressive **interjections** — `(sighs)`, `(exhales)`, `(laughs softly)`, … — and manual pauses `<#0.5#>`; they're spoken by the TTS and ignored by alignment/captions (whisper doesn't transcribe them). `--list-interjections` in voice-clone lists the recognized set |197| `location` | OPTIONAL reel-default **look** for the avatar (a "location" = wardrobe + environment + light, created by the `avatar-location` skill). Talking-head angles then resolve from `<avatar>/locations/<location>/angles/` instead of the top-level `angles/`. Omit or set `"default"` for the avatar's base look (today's behavior). Per-scene `location` overrides this. |198| `scenes[]` | ordered scenes (below) |199| `finish` | optional finishing-pass config (see below); runs automatically when present |200201Optional `finish` block (also overridable by `compose_reel.py --finish` flags):202| Field | Meaning |203|---|---|204| `enabled` | `true` to auto-run the finishing pass after assembly |205| `subtitles` | burn in word-timed captions (default `true`) |206| `music` | add a fixed-volume music bed (default `true`) |207| `music_mood` | `bg-music-hq` mood preset (default `ambient`; e.g. `cinematic`, `inspiring`, `dramatic`, `lofi`) |208| `music_prompt` | **tailor this** to the reel's emotional tone (read from the script + B-roll); light, instrumental, no drums |209| `music_volume` | BASE bed level under the voice, 0–1 (default `0.12`). With `music_structure`/`music_plan` this is the level the envelope moves around (still no sidechain ducking) |210| `music_vocals` | `wordless` (default — soft, non-distracting oohs/aahs) or `none` (instrumental). Stage directions are NEVER sung either way |211| `music_structure` | `flat` (default — constant bed) or `auto` (a tasteful volume envelope from the scene structure: duck under the hook, lift after it, resolve on the close) |212| `music_plan` | explicit soundtrack moves — `{"moves":[{"type":…,"at":…,"amount":…}]}` — the precise envelope (overrides `music_structure`). See *Structured music* below |213| `music_from_cutsheet` | path to a `rule-of-six-edit` `*.cutsheet.json`; its per-cut `sound` notes are mapped (best-effort) to music moves at their scene boundaries |214| `max_words` | max words per caption phrase unit (default `6`) |215| `emphasis` | highlight each breath group's payoff in bold-italic (default `true`) |216| `casing` | `subtitle` (default — lowercase like the analyzed reels: no sentence-initial capitals and no trailing dot, but intentional ALL-CAPS words like `REPE`/`NO` stay shouted and accents are kept), `natural` (preserve ASR/script casing), `lower`, or `upper` |217| `caption_reveal` | `word` (default — **karaoke reveal**: each word appears as it's spoken, building the phrase in place; already-spoken words stay lit, the phrase clears on the next unit) or `phrase` (the whole phrase unit pops in at once). See the [`caption-word-reveal`](../caption-word-reveal/SKILL.md) skill |218| `style_from` | path to a `subtitle_style.json` (from avatar-frames) to seed caption position/size/casing |219| `regular_font` / `emph_font` | override the serif / bold-italic caption fonts (TTF) |220| `fontsize` / `y_frac` | caption size (px) / vertical center as fraction of height (defaults: profile, else ~7.2% of the SHORTER side / `0.66` for 9:16, `0.85` lower-third for 16:9) |221| `fx` | OPTIONAL polish-pass block (see below) — runs after the finish pass, keeps `final-without-sfx.mp4` |222223`finish.fx` block (stage 4, `polish_reel.py`):224| Field | Meaning |225|---|---|226| `enabled` | `true` to auto-run the polish pass after finishing |227| `transition_style` | OMIT to use the avatar's measured `transition_style.json` (recommended). Override: `golden_flash` (warm amber wash over the incoming scene), `white_flash`, `dip_black`, `punch` (small zoom pulse), `none` (bare hard cuts) |228| `style_from` | explicit path to a `transition_style.json` (default: auto-discover `<avatar>/transition_style.json` written by `profile_transitions.py`) |229| `sfx` | overlay short-soft SFX (default `true`): airy whoosh leading each B-roll cut by ~0.35s + soft low boom under `emphasis: true` scene starts |230| `sfx_volume` | SFX level under the voice (default `0.18` ≈ the originals' non-speech/speech RMS ratio) |231| `flash_dur` / `flash_gain` | flash length (s) / strength — OMIT to use the measured profile (fallback `0.36` / `1.0`) |232| `density` | seconds per SFX event (default: from `guide`, else `15` as measured) |233| `guide` | path to a voice-isolate `voice.json` of an ORIGINAL reel — its measured `sfx_intervals` set the density |234| `regen_sfx` | regenerate the cached SFX assets (`<avatar>/reels/_sfx_cache/`) |235236**Hard rule:** the concatenation of every `scene.text` (joined with single237spaces) must equal `script` verbatim. The script is narrated as one take, then238cut per scene; if the texts don't tile the script the alignment falls back to a239rough proportional split.240241Each scene:242| Field | Applies to | Meaning |243|---|---|---|244| `id` | all | unique id (e.g. `s1`); used for filenames |245| `type` | all | `talking_head`, `broll` or `guest` |246| `text` | all | the contiguous slice of the script spoken during this scene |247| `motion` | all | Ken Burns/zoom: `zoom_center`, `push_in`, `push_out`, `drift_{left,right,up,down}`, `none` |248| `emphasis` | all | `true` bumps motion intensity (subtle→medium); a marked zoom-in for key lines |249| `image` | talking_head | path to a `*_916.png` (9:16) or `*_169.png` (16:9) camera-angle image (preferred); an explicit path always wins over `angle`/`location` |250| `angle` | talking_head | alternative to `image`: a move name (e.g. `push_in`) globbed under the active location's `angles/`, preferring the crop that matches the reel `format` (`*_169.png` for landscape, else `*_916.png`, then any `*.png`). Falls back to the default look (with a warning) if the location lacks that angle |251| `location` | talking_head | OPTIONAL per-scene **look** override (a name from the `avatar-location` skill); overrides the reel-level `location`. `"default"`/unset = the avatar's base look |252| `video_prompt` / `negative_prompt` | talking_head | optional p-video-avatar overrides; omit to use `talking_profile.json` |253| `broll_description` | broll | the scene to generate (people/objects/environment, NO main presenter) |254| `broll_camera` | broll | `handheld`, `push_in`, `pull_out`, `pan_left`, `pan_right`, `orbit`, `static` |255| `broll_action` | broll | explicit continuous human performance (gestures, talking/not), avoids the "mannequin" look |256| `broll_source` | broll | `generate` (default — synthesize with broll-generator) or `existing` (use a real found-footage clip instead, e.g. from the `broll-finder` skill) |257| `broll_clip` | broll | path to a pre-made B-roll clip (abs / relative to `--base-dir` / the avatar folder); required when `broll_source: existing`. Silent clips shorter than the slot are looped to cover it, then trimmed |258| `broll_clip` | guest | path to the pre-made guest clip (with its OWN voice) built by `assemble_narration.py`; the scene is used as-is and the cut is pinned to its exact end (no pad, no loop) |259260## Guest / cameo scenes — a DIFFERENT avatar inside the reel261A `guest` scene drops a clip of **another avatar speaking in their own voice**262into the host avatar's reel — e.g. a photorealistic human presenter opens the263hook ("…I'm not real") before a surprise cut to the host. It is the right tool264whenever a beat needs a different face/voice than the host. (It is **not** B-roll:265B-roll is silent and gets the host's voice-over laid on top; a guest clip *keeps266its own audio* and must NOT be narrated over.)267268Because `compose_reel.py` muxes **one** master narration over the whole timeline,269the guest's voice has to be woven INTO that master track. `assemble_narration.py`270does this: it stitches a single `narration.mp3` + `narration.align.json` from an271ordered list of segments (guest clips + the host narration), so everything272downstream (boundaries, captions, music, flash) just works.273274```bash275# 1) Build the master narration from heterogeneous segments (gap 0 → host picks up276# the instant the guest stops). See the plan schema in assemble_narration.py.277python3 .cursor/skills/avatar-reel-composer/scripts/assemble_narration.py plan.json --base-dir .278# → writes narration.mp3 + narration.align.json into out_dir, prints guest scene stubs.279# 2) Put the printed stub(s) into storyboard.json as `type: "guest"` scenes, set the280# storyboard `script` = the full combined text, then compose normally (it REUSES281# the pre-built narration when you point --out-dir at that folder):282python3 .cursor/skills/avatar-reel-composer/scripts/compose_reel.py storyboard.json --base-dir . --out-dir <reel_dir> --finish283```284Segment kinds in the plan: `guest` (generates a lip-synced clip of `avatar_dir`285via avatar-talking-video and uses its audio — matched to the clip's exact video286length with inaudible trailing silence), `audio` (an existing file, e.g. a host287`audio-theater` `dialogue.wav`), `tts` (host MiniMax voice via `narrate.py`).288289**No freeze pad — the next clip starts immediately.** A guest scene is never290looped or frozen: the composer pins its boundary to the clip's real duration, so291the host cuts in the instant the guest stops talking. The guest segment in the292master narration is matched to that same duration, so there is zero downstream293drift. A guest cut is also treated as an INSERT boundary (like B-roll), so the294polish pass's golden-flash + whoosh naturally land on the reveal.295296**Any position — hook, middle or end.** Guests are not limited to the opening297scene. When `assemble_narration.py` ran, it recorded each clip's exact298`[start, end]` in the master narration (`assemble_narration.out.json`); the299composer reads those and pins BOTH the in- and out-point of every guest scene, so300a mid-reel guest (host → **guest** → host) is just as frame-exact as a leading301one. To place a guest in the middle, supply the host narration as TWO segments302(before / after the guest) in the plan, with the guest segment between them, and303keep the storyboard scene order matching the plan order.304305## Locations — one avatar, multiple looks306A **location** is a *look* for the avatar — wardrobe + environment + light bundled307together — built by the sibling **`avatar-location`** skill (`create_location.py`).308It keeps the avatar's identity (face, gestures, **voice**, `talking_profile`) and309only changes how it's dressed/roomed, with its own identity-anchored hero + camera310angles under `<avatar>/locations/<loc>/angles/`. The avatar's *default* look is just311the top-level `scene.json` + `angles/` (unchanged).312313- Set a reel-wide look with the top-level `location: "<loc>"`, and/or per-scene314 `location` overrides — so a reel can cut between looks while the same person keeps315 talking (e.g. open in `studio_night`, then back to `default`).316- Only **talking-head** angle resolution is affected; the voice, narration, guest317 and B-roll scenes are untouched. An explicit scene `image` path still wins.318- A `--dry-run` prints the resolved angle (and `@ <loc>`) per talking-head scene, so319 you can confirm the avatar + look **before** any paid generation. If a location is320 missing an angle, the scene falls back to the default look with a warning.321322```bash323# 1) Create the look (review checkpoint, then 1 hero + ~5 angles via gpt-image-2)324python3 .cursor/skills/avatar-location/scripts/create_location.py nora studio_night \325 --setting studio --brief "evening studio, moody teal key light, black turtleneck"326# ...refine nora/locations/studio_night/scene.json, then re-run to generate.327python3 .cursor/skills/avatar-location/scripts/list_locations.py nora328# 2) Reference it in the storyboard: top-level "location": "studio_night" and/or329# per-scene "location"; then compose normally.330```331332## Deriving the storyboard from `<avatar>.analysis.json`333Read the avatar's analysis and mirror its rhythm so the new reel matches.334335### Pacing rules (do this first — it's what makes a reel engaging)336Short-form reels keep attention by **cutting often** and **never lingering**.337Reproduce the analyzed reel's rhythm, not just its talking-head:B-roll ratio:338- **Compute the target:** `target_scene_len ≈ median scene duration` of the339 reference analysis (typically **~4–6s**); `num_scenes ≈ narration_seconds /340 target_scene_len`. A 30s reel usually wants **~6–8 scenes**, not 3–4.341- **Open with a short hook:** the first scene should be **~2–3s** (match the342 reference's scene #0). Never open with a 5s+ talking-head — that's the #1 way343 to lose the viewer early.344- **No shot lingers:** keep every scene **≤ ~6s** (a slightly longer ~7–8s shot345 is OK only for an emotional B-roll outro). If a sentence is long, **split it346 across 2+ scenes** at commas/colons/semicolons and change the framing/zoom on347 each — the scene texts still must tile the script verbatim.348- **Vary consecutive talking-heads:** alternate angle + zoom (`eye_level`349 zoom_center → `push_in` zoom-in → `pull_out` zoom-out …) so back-to-back350 presenter shots read as distinct cuts, exactly like the original's repeated351 `hard_cut` + `zoom_in/zoom_out`.352- **Place B-roll deliberately:** put one insert right after the hook and one for353 the outro at minimum; sprinkle more to break up long talking-head stretches.354- `compose_reel.py` prints a **pacing report** (vs the reference median) before355 generating and warns on a long hook / over-long shots — adjust the storyboard356 until the warnings are gone.357358### Field-by-field mapping359- **`scene_type`** → scene `type`: `main_character_solo` → `talking_head`;360 `supplementary_material` → `broll`.361- **`zoom_from_previous.type`** → motion. You can either set `motion` directly,362 or **copy the analysis value verbatim** into the scene's `zoom_from_previous`363 and the pipeline maps it for you: `zoom_in` → `push_in`, `zoom_out` →364 `push_out`, `hard_cut` → `none` (clean static reframe), `none` → `zoom_center`365 (subtle) for talking-head / `none` for B-roll. An explicit `motion` always366 wins. B-roll keeps `none` regardless (its camera move is baked in at367 generation, so don't double it).368369### Camera-angle sequence (base the new reel on the analyzed one)370The talking-head shots are NOT free camera moves — they reuse a few371**pre-rendered angle crops** from `<avatar>/angles/*_916.png` (or `*_169.png` for372a 16:9 reel) plus digital zoom. So replicate the reference's *tendencies*, not a3731:1 angle-per-scene copy (the new reel has fewer scenes):374- `compose_reel.py` prints a **camera fingerprint** of the reference375 (talking-head angle + framing distribution, and the zoom-transition mix). Match it.376- **Base shot:** use the dominant angle/framing for most talking-heads (for the377 analyzed `lolo` reel that's `eye_level` / `medium_close_up` → the378 `lolo_eye_level_916.png` crop).379- **Emphasis:** for the lines the original tightens on (its `close_up` /380 `zoom_in` scenes), use a closer crop (`*_push_in_916.png`) with381 `emphasis: true`.382- **Variety:** alternate in the other available crops (`pull_out`,383 `negative_space_left`) so consecutive presenter shots read as real cuts,384 mirroring the reference's repeated `hard_cut` + `zoom_in/zoom_out`.385- **Zoom mix:** keep roughly the reference's ratio (here ≈ half `hard_cut`,386 balanced `zoom_in`/`zoom_out`) — copy the per-scene values into387 `zoom_from_previous` to reproduce it faithfully.388- **Widen the palette:** if the reference uses angles you don't have a crop for389 (e.g. `high_angle`), generate them first with the `avatar-camera-angles`390 skill, then reference the new `*_916.png` in the storyboard.391- **`camera.angle` / `camera.framing`** → pick the matching talking-head392 `image` from `angles/` (e.g. `eye_level`, a closer push-in for emphasis).393- **`summary.emotion`** → informs the `voice.emotion` and the mood of B-roll394 descriptions/actions.395- **`summary.focus` / `camera.description`** of B-roll scenes → inspiration for396 your `broll_description` (reinforce the spoken idea visually, WITHOUT the397 presenter). Always give people a `broll_action` so they aren't mannequins.398- **emphasis:** set `emphasis: true` on talking-head lines the original drives399 home with a tighter zoom.400401## Motion mapping (replicates the analyzed pattern)402- `push_in` / `push_out` / `zoom_center` / `drift_*` / `none` come from403 `video-compose`'s `MOTION_DEFS` and are applied with `apply_camera_motion`.404- talking-head default (no `motion`): `zoom_center` subtle; `emphasis` → medium.405- B-roll default: `none` (its camera move is baked in at generation time).406407## Reuse of video-compose408- `apply_camera_motion` — Ken Burns/zoom on each scene clip (this skill applies409 it to *video* clips, which `video-compose`'s own `render_final` does not).410- `FORMAT_PRESETS` (reel=1080x1920), `ffprobe_video`, `run_ffmpeg`.411- We do **not** use its `mix_music` or the xfade-with-silent-audio path: with a412 voice-over we keep the narration as the single master track and cut hard.413414## Notes / defaults415- **Hard cuts only (v1):** xfade would shorten the timeline and desync the416 narration. Motion lives *inside* each scene (Ken Burns/zoom) — which is exactly417 the analyzed reels' pattern (mostly `hard_cut` + `zoom_in/out`). Short418 dissolves are a future improvement (need to compensate the audio overlap).419- **Idempotency:** `narration.mp3` + `narration.align.json` are reused if present420 (skip with nothing, or re-make with `--force-narrate`). Scene clips are cached421 in `<avatar>/generated-videos/` (talking-heads, keyed by scene + an audio422 fingerprint) and `<avatar>/broll/` (keyed by scene); regenerate with `--regen`.423 So you can iterate on assembly without paying for generation twice.424- **Music cache (don't regenerate a good bed):** the per-reel `music.mp3` is reused425 unless `--regen-music`. On top of that, the RAW generated track is cached426 **avatar-wide** in `<avatar>/reels/_bgm_cache/<key>.mp3`, keyed by427 `prompt + mood + vocals`. So a second attempt — or a new reel **version**428 (`-v2`, `-v3`) — with the same musical intent reuses the already-good track429 (just re-fit/looped to that reel's length) instead of paying MiniMax again.430 Change the prompt/mood/vocals to get (and cache) a fresh bed; `--regen-music`431 forces a new generation and refreshes the cache entry.432- **No freeze-pad; B-roll covers its slot:** clips are only ever TRIMMED to the433 scene duration, never frozen on a held last frame. Talking-heads run the434 chunk's exact (audio-driven) length; **B-roll is silent (nothing to sync to)**,435 is generated at `ceil(target)` ≥ slot, and a cached B-roll that no longer436 covers its slot (e.g. after re-narration lengthened the scene) is regenerated437 rather than stretched/frozen.438- **Per-sentence narration + cache:** `narrate.py` synthesizes **one MiniMax call439 per sentence** (decimal/abbreviation-aware split) and joins the takes with a440 small `sentence_gap` of silence. This avoids the audio-quality degradation441 speech-2.8-hd shows on long single takes (its own docs recommend short442 sentences), and the sentence-boundary gaps give the caption engine clean pauses443 to clear on. Each take is **cached in `<reel>/narration_parts/`** keyed by its444 text + voice params, so re-narrating only regenerates what changed. If one445 segment is mispronounced, **re-roll just that sentence** with446 `compose_reel.py <storyboard> --reroll N` (1-based index, from the list447 `narrate.py` prints) — the rest are reused, and only the talking-heads whose448 audio actually changed are regenerated (their cache is audio-fingerprinted).449 Group sentences with `voice.sentences_per_call` to trade a little prosody450 continuity for fewer API calls.451- **No language boost by default:** narration uses `language_boost="None"` so the452 **cloned voice keeps its own accent**. Boosting a language nudges pronunciation453 toward a "standard"/regional accent that can fight the clone (e.g. a neutral or454 Chilean voice drifting into Argentinian *voseo*). Set `voice.language_boost` to455 a locale only if you specifically need that pronunciation help.456- **Expressive delivery:** keep the narration from sounding flat by setting a457 fitting `voice.emotion` and, sparingly, dropping MiniMax interjections458 (`(sighs)`, `(exhales)`, `(laughs softly)`, …) or manual pauses `<#0.5#>` right459 into `script`. They render in the voice but are invisible to alignment/captions.460- **Captions: ASR timing + ASR styling, script spelling (forced alignment):**461 faster-whisper gives the *timing*; its word strings are phonetic, so it462 mis-hears acronyms (`REPE` → `rape`). `align()` aligns each ASR word to the463 `script` and fixes only what should be fixed, while keeping normal subtitle464 conventions:465 - **same word (ignoring case AND accents) ⇒ keep the ASR word verbatim** — its466 casing, accents and punctuation. `Sólo`/`sólo`/`solo` are "the same"; we never467 impose the script's sentence-capitalization or punctuation, and never strip468 whisper's accents (`cómo`, `relación` stay accented).469 - **intentional ALL-CAPS in the script (≥2 letters) ⇒ forced uppercase** in the470 caption, since caps denote intent (acronyms/emphasis: `NO`, `REPE`).471 - **genuinely different letters ⇒ adopt the script spelling**, rendered in the472 ASR word's own case style (so `rape` → `REPE`, but no mid-sentence capitals).473 Originals are kept under `asr_word` in `narration.align.json` for debugging.474- **Polish pass is duration-preserving and applied OVER the final video:** the475 fx layer (stage 4) never re-cuts segments. Real crossfades are forbidden —476 they overlap clips and shorten the timeline, desyncing the continuous477 narration + captions. The golden flash is a per-cut color envelope (stepped478 `eq` slices under timeline `enable`; `eq` does NOT re-evaluate `t`479 expressions per frame) and SFX are an additive audio overlay — neither adds480 or removes a single frame. The pre-fx video is always kept as481 `final-without-sfx.mp4`; re-polishing starts from that clean copy (effects482 never stack), and a fresh finish pass clears the fx marker so the new483 `final.mp4` becomes the clean source.484- **FX fingerprint is MEASURED per avatar, not assumed:**485 `profile_transitions.py` studies the avatar's own originals (frames sampled486 around every analyzed scene boundary) and writes `transition_style.json` —487 flash presence per boundary type, hue (golden/white), duration, strength.488 `polish_reel.py` auto-discovers it, so a new avatar whose originals use a489 white flash, a dip-to-black, or no transition at all gets THEIR look, not the490 reference's. E.g. the reference avatar measures: golden flash on **100% of491 B-roll entries, 0% of exits**, ~0.40s, gain ~1.0. SFX are sparse (~1 per492 15s), short (0.3–0.7s), very soft (~15–20% of voice RMS), placed either493 leading a B-roll cut by ~0.35s (whoosh) or under an emphasized phrase (soft494 low boom). `--guide` (a voice-isolate `voice.json` of an original)495 recomputes the density from its measured `sfx_intervals`.496- **Module naming:** the shared module is `_arc_common.py` (not `_common.py`) on497 purpose, so importing `video-compose`'s `_video_pipeline` (which does498 `from _common import …`) resolves to *its* `_common`, not ours.499500## Stage 0: create an avatar from a public Instagram URL (`create_avatar.py`)501If the avatar doesn't exist yet, build it with the orchestrator before composing502reels. `create_avatar.py` runs the whole setup chain with **idempotent resume**,503skipping any stage whose outputs already exist and stopping with instructions at504the two steps that need you (the agent): the browser scrape and the vision505enrichment. Re-run after each to continue.506507```508download instagram-videos -> <avatar>/videos/*.mp4509analyze video-scene-analysis -> <avatar>/videos/*.analysis.json (+ *_frames/)510[CHECKPOINT] AGENT vision enrichment of the analyses (avatar_profile.video_prompt)511frames avatar-frames -> <avatar>/frames/ + subtitle_style.json512voice voice-isolate + voice-clone -> <avatar>/voices/513transitions profile_transitions.py -> <avatar>/transition_style.json514profile export_talking_profile.py -> <avatar>/talking_profile.json515report -> <avatar>/avatar.json + readiness table516```517518Agent workflow:5191. **Scrape the profile** with the browser MCP per the `instagram-videos` SKILL520 (confirm the profile is public, scroll Picnob, collect every `.post_box`) and521 save the array to `posts-raw/meta/picnob_<handle>.json`.5222. **Run the orchestrator** (from the repo root, so `<avatar>/...` paths resolve):523 ```bash524 python3 .cursor/skills/avatar-reel-composer/scripts/create_avatar.py <avatar> \525 --handle <handle_or_url> [--language es] [--voice-video <name>]526 ```527 It downloads + analyzes, then **stops at the enrichment checkpoint**.5283. **Enrich each `*.analysis.json`** per the `video-scene-analysis` SKILL — view529 the `<stem>_frames/scene_XX.jpg` and fill camera/framing, focus/emotion,530 mannerisms and the reusable `avatar_profile.video_prompt`/`negative_prompt`.5314. **Re-run the same command.** It resumes: frames, voice, transitions, profile,532 then writes `avatar.json` and prints a readiness table. `--status` shows that533 table any time without running anything (zero API spend); `--force-stage NAME`534 re-runs one completed stage.535536Camera-angle stills (`angles/*_916.png`, via `avatar-camera-angles`) are the one537piece this orchestrator does not generate — add them if you want fixed538talking-head framings; otherwise the talking-head scenes use the reference539frames. Then write the storyboard and run `compose_reel.py` as above.540541## Finishing pass (implemented — `finish_reel.py`)542- **Burned-in subtitles — matched to the analyzed reels.** Word timings from543 `narration.align.json` are segmented into **self-contained phrase units** (breath544 groups, split at pauses/punctuation; a group longer than `max_words` (default 6)545 is subdivided **recursively & balanced** so EVERY unit is short — no oversized546 leftover chunk — and breaks land at proclitic-safe points so a line never ends on547 `que`/`en`/`a`/`el`…). Each unit **REPLACES** the previous one — captions are548 never rolled/accumulated, so the viewer never reads stale already-spoken text549 stacked under a new line (the `avatar-frames` profile confirms the originals'550 `progression: "replace"`, word-overlap ≈ 0.1).551 - **Meaningful emphasis.** A unit renders as a regular **setup** line + a552 **bold-italic PAYOFF** line — the breath-ending / key words that *complete* the553 thought (e.g. `de no poder` → ***soltar.***). Emphasis falls only on a breath554 group's **completion**; mid-breath continuation units stay plain (so the bold555 is reserved for what matters, like the originals). The setup/payoff split is556 balanced (comma-preferred, proclitic-safe). `--no-emphasis` / `emphasis:false`557 disables it.558 - **Always ~2 lines (like the originals).** Lines use a **balanced wrap** (a small559 DP that minimizes the widest line) so a caption never strands an orphan560 single-word line.561 - **Split, don't shrink.** If a caption wouldn't fit at the nominal font, it's562 split into **sequential full-size captions** (each shown for its own words) —563 `necesitas reprogramar` → `tus patrones / subconscientes.` — rather than crammed564 into a tiny block. Lower cognitive load, consistent big text. Splits keep both565 sides ≥2 words (no lone-word flash). The font only auto-fits as a last resort566 (gentle ~80% floor) for a residual 2-long-word overflow.567 - **Tracks speech / clears at pauses.** Each caption is time-bounded to its spoken568 words; when a long pause follows (sentence boundary, > ~0.4s from the569 word-level alignment) it clears shortly after the last word instead of lingering570 on screen with not-yet-spoken text.571 - **Style:** an elegant **serif** (Georgia), **lowercase "subtitle" casing**572 (no sentence-initial capitals, no trailing dot; intentional ALL-CAPS like573 `REPE`/`NO` kept, accents kept), **white with a574 soft drop shadow** (thin subtle outline, no heavy block outline). Rendered as575 transparent PNGs (Pillow) and composited with `video-compose`'s576 `overlay_titles`; windows are contiguous (no flicker).577- **Music bed (fixed or structured).** Reuses `bg-music-hq`'s prompt/stru578579…(truncated)