song-to-script
Turn a song into <project>/script.md — a sequence of ## Scene N
blocks whose duration: values sum to the track length and whose cut
points land on real section boundaries. Consumed by
/video:from-song, then handed
to scene-expander and
video-director. Never invents timing —
every boundary comes from the audio probe, and the probe's method
tells this skill how musical (or not) those boundaries actually are.
When to use
- A music-video run needs scenes cut to the song (
/video:from-song).
- An existing script must be re-timed to a track after the edit
drifted from the beat (the named second consumer — re-time without a
full re-author).
Do NOT use when:
- The operator already supplies a
## Scene N script with duration:
values — feed it straight to scene-expander.
- There is no audio — use the operator brief with
scene-expander
directly.
Inputs
- Audio analysis — adapter-first, probe as the floor:
- Analysis adapter (when an
audio-analysis provider is
configured — see
audio-adapter-contract.md):
{bpm, beats, downbeats, sections:[{start,end,label,energy?}]} —
real musical structure. Beats/downbeats become the candidate cut
grid; section labels are musical (verse, chorus, …).
- Audio probe — JSON from
scripts/ai-video/lib/probe-audio.sh:
{duration, method, warning?, sections:[{start,end,energy,label}]}.
method: silence — boundaries are real quiet gaps; trust them as cuts.
method: rms — boundaries are energy-delta inflections; usable but
coarse.
method: interval — the track is structurally flat (brick-walled
/ sustained); sections are fixed-interval, NOT musical. When method
is interval (or warning is set), the emitted script header states
that timing is interval-based and the operator should pass
--scene-durations for musical sync. Never present interval cuts as
beat-synced.
- Model capabilities — the chosen video model's renderable envelope
from the multiplexer manifest:
scripts/ai-video/adapters/<provider>.sh capability --model <id> →
{min_duration, max_duration, audio_sync, aspect, verified}. Scene
durations MUST land inside [min_duration, max_duration]; a
verified: false manifest entry is surfaced in the report, never
trusted silently.
- Mode —
brief (operator text is the creative source) or auto
(infer mood + action from energy).
- Brief (brief mode only) — free text: story, settings, look.
- Character lock (optional) —
<project>/character.json if a human
subject was locked. Absent is normal — abstract / landscape /
visualiser videos have no locked subject; see Step 2.
Procedure
Step 1: Map sections → scenes (capability-clamped, beat-aligned)
One ## Scene N per analysis section. duration: = end - start
(rounded to 0.5 s). Then clamp the plan to the chosen model's
renderable envelope — read min_duration / max_duration from the
model-capabilities manifest (<provider>.sh capability --model <id>),
falling back to the provider tuning for single-model adapters:
- Section shorter than
min_duration → merge into its neighbour.
With beat data, merge toward the neighbour that keeps the joined cut
on a downbeat (else any beat); without beat data, merge into the
shorter neighbour.
- Section longer than
max_duration → split into sub-scenes. With
beat data, place every split point on the nearest downbeat (else
beat) to the equal-division point — never mid-beat; without beat
data, split equally.
- No valid plan exists (e.g. the whole song is shorter than
min_duration, or a section cannot be split onto any beat inside the
envelope) → halt and surface the conflict with the model id and
the violated bound — an unbuildable plan never reaches the renderer.
Every emitted scene satisfies
min_duration ≤ duration ≤ max_duration. When the manifest entry is
verified: false, say so in the report — the envelope is
documented-best-effort, not a smoke-traced fact.
Step 2: Assign mood + action
First decide the subject mode:
- Character mode —
character.json exists: every scene's action:
names the locked subject, never a fresh description.
- Style mode — no
character.json: scenes describe setting, palette,
and motion continuity (the recurring look), not a person. This is the
valid abstract / landscape / visualiser path — do not invent a human
subject to fill the slot.
Then pick the prompt source per segment — the modality switch:
- Lyric segment (the vocal map places ≥1 transcribed line inside
it) → the scene prompt derives from the lyric line itself: its
imagery, subjects, and verbs seed
mood: + action: (in character
mode, acted by the locked subject; in style mode, rendered as
setting / weather / palette — never an invented human). The line
lands in dialogue: per Step 3.
- Instrumental segment (no vocal-map line) → the scene prompt
derives from the audio features: section
label + energy via
the intent table below. Never recycle a lyric from another segment
into an instrumental one.
Then assign per scene:
Brief mode — distribute the brief's beats across scenes in order;
the modality switch still applies (lyric segments quote the brief's
matching beat through the lyric's lens), and energy modulates pacing.
Do not add story the brief did not state.
Auto mode — derive mood per section from energy and label
(probe labels and musical labels from the analysis adapter both map):
| label / energy |
default scene intent |
| intro / low |
establishing wide, slow camera, calm subject/scene |
| verse / mid |
narrative motion, medium framing, follow the subject |
| build / rising |
approach, tightening framing |
| chorus · drop / peak |
dynamic motion, weather/FX, fast push |
| bridge · breakdown / dip |
close-up / detail, quiet, single light source |
| outro / fade |
pull-back, resolve, hold |
Energy → cut frequency + motion intensity. Section energy (0..1,
relative to the track mean) drives both how often the edit cuts and how
hard the camera moves — chorus = faster cuts / more motion:
| energy vs. track mean |
cut length target |
camera: motion intensity |
| ≥ mean + 0.10 (chorus / drop) |
short — split the section toward min_duration, one scene per 1–2 downbeat bars |
fast push / whip / handheld shake |
| within ±0.10 of mean (verse / build) |
medium — one scene per section or per 4-bar phrase |
steady dolly, slow tighten |
| ≤ mean − 0.10 (breakdown / outro) |
long — merge toward max_duration, hold shots |
locked-off or slow drift |
High-energy splitting and low-energy merging both stay inside the
Step 1 capability envelope and land on downbeats — the energy table
chooses where inside the envelope a scene length falls, never
outside it.
Step 3: Vocal map — transcribe, never guess (vocal tracks)
LYRIC TIMING AND SINGER COME FROM THE TRANSCRIBED AUDIO, NEVER FROM A
BRIEF / STORY SKELETON OR A GUESSED STRETCH. NEVER PUT ONE SINGER'S
LINE ON ANOTHER SINGER'S SCENE.
When the track has vocals and the run intends lip-sync, build a
vocal map from the real audio before assigning any dialogue::
- Transcribe the audio to timestamped lines. Adapter-first: a
configured
lyrics provider (e.g.
audio-adapters/whisperx.sh)
returns word-level timestamps plus per-line diarization labels
(SPEAKER_00, …, or "?" when ambiguous):echo '{"audio_path":"<vocal-stem-or-song>"}' \
| scripts/ai-video/audio-adapters/whisperx.sh analyze
No lyrics provider configured → OpenAI /v1/audio/transcriptions
(response_format=verbose_json → segments[].{start,end,text}) or
local whisper as before (no speaker labels — every line starts as
"?"). Either way the transcript is the only source of lyric timing.
- Label the singer per line — map diarization labels (or unlabeled
lines) to cast names via the operator's who-sings reference (a
roster, a brief that names who sings which line, or a character
cast): one diarization label ↦ one cast name, consistently. If a
line's singer is genuinely ambiguous (label
"?", mixed-speaker
line, or no roster match), keep singer: "?" and surface it —
never guess a singer to fill the slot.
- Emit
<project>/vocal-map.json:
[{start, end, text, singer}], timing verbatim from the transcript.
- Validate — run the ground-truth enforcer before handing the map
to the sign-off gate:
scripts/ai-video/lib/validate-vocal-map.sh <project>/vocal-map.json \
<project>/transcript.json --roster "<cast names>"
It rejects re-timed lines, lyrics not in the transcript, and missing
singers (exit 7, specific line named). A red validator is a halt —
fix the map, never bypass.
- Place lines into the matching scene's
dialogue: block using the
transcript timing, tagged with the singer (singer: "<line>"). A
scene's lip-sync subject MUST be the line's labelled singer; a
"?" line gets NO lip-sync scene until the operator resolves it.
No vocals / no transcript / no lip-sync intent → leave dialogue: empty;
the scene is performance / B-roll. Never fabricate lyrics, never
re-time a line off the brief, and in style mode dialogue: stays empty
(lip-sync needs a character subject). The /video:from-song sign-off
gate (its Step 6) shows this map for approval before any render.
Step 4: Emit + reconcile
Write <project>/script.md (and <project>/vocal-map.json when the
track has vocals). Report the delta, the section→scene map, the probe
method (so the operator sees whether cuts are silence-derived,
energy-derived, or interval-fallback), and whether lyric timing is
transcript-derived (it must be — never brief-derived). If the sum
cannot be reconciled (e.g. provider max-duration forces more time than
the song has), halt and surface the conflict — do not pad silently.
Step 5: Validate before handoff
Concrete checks (all must pass before the script is handed to
scene-expander):
- Assert
Σ(duration) == probe.duration within ±1.0 s; report the
exact delta. A larger delta → halt, do not pad.
- Verify every scene boundary equals a probe section boundary (or a
--scene-durations value) — no invented cut points.
- Confirm no scene
duration: exceeds the model's max_duration
or falls below min_duration (model-capabilities manifest, or the
provider tuning for single-model adapters).
- Verify every lyric-segment scene derives its prompt from its own
vocal-map line and every instrumental scene from audio features — no
cross-segment lyric recycling (modality switch).
- Ensure every
## Scene N carries all five keys (duration ·
mood · action · camera · dialogue), and that dialogue: is
empty in style mode.
Output format
script.md opens with the derivation header —
# <project> — derived from <song-file> (<mode> mode · cuts: <method>)
— so the probe method stays visible downstream.
- One
## Scene N block per cut carrying exactly the keys
duration · mood · action · camera · dialogue —
scene-expander consumes this verbatim; keep the keys exact.
dialogue: stays empty unless operator-supplied lyrics cover the
section — detected vocal energy alone never fills it.
# <project> — derived from <song-file> (<mode> mode · cuts: <method>)
## Scene 1
duration: 6.0
mood: establishing, cold, pre-storm
action: <subject from character.json, OR style description in style mode>
camera: slow push-in
dialogue:
## Scene 2
duration: 4.5
mood: build, rising tension
action: close on <subject / detail>, wind picking up
camera: handheld tighten
dialogue:
- "<subject>: \"<lyric line for this section, if any>\""
Gotcha
method: interval is the brick-walled-master signal, not a bug.
A compressed modern master has near-constant RMS and no silence, so the
probe degrades to fixed intervals. That is the honest floor — surface
it and point the operator at --scene-durations; never dress interval
cuts up as beat-synced.
- A vocal section without supplied lyrics is B-roll, not lip-sync.
Detected vocal energy alone does not authorise
dialogue: — only
operator-supplied lyrics do.
- Style mode is the default for a no-character run, not an error
path. Landscape / abstract / visualiser videos never get a fabricated
human subject.
Do NOT
- Do NOT invent timing. Every cut maps to a probe boundary or a
--scene-durations value — never to taste.
- Do NOT present
interval-fallback cuts as beat-synced. Always
surface the probe method.
- Do NOT emit a clip outside the provider's min/max duration —
split/merge in Step 1 instead.
- Do NOT fabricate lyrics or story beyond the brief / detected vocals.
- Do NOT invent a human subject in style mode; defer identity to
character.json only when a lock exists.
- Do NOT pad a unreconcilable timing sum — halt and surface it.
See also
1---2name: song-to-script3description: Turn an audio track into a timed `## Scene N` script: song sections → per-scene durations, auto mode adds mood + lip-sync lines. Triggers 'music video', 'from the song', 'cut to the beat'.4---56# song-to-script78> Turn a song into `<project>/script.md` — a sequence of `## Scene N`9> blocks whose `duration:` values sum to the track length and whose cut10> points land on real section boundaries. Consumed by11> [`/video:from-song`](../../commands/video/from-song.md), then handed12> to [`scene-expander`](../scene-expander/SKILL.md) and13> [`video-director`](../video-director/SKILL.md). Never invents timing —14> every boundary comes from the audio probe, and the probe's `method`15> tells this skill how musical (or not) those boundaries actually are.1617## When to use1819- A music-video run needs scenes cut to the song (`/video:from-song`).20- An existing script must be **re-timed** to a track after the edit21 drifted from the beat (the named second consumer — re-time without a22 full re-author).2324Do NOT use when:2526- The operator already supplies a `## Scene N` script with `duration:`27 values — feed it straight to `scene-expander`.28- There is no audio — use the operator brief with `scene-expander`29 directly.3031## Inputs3233- **Audio analysis** — adapter-first, probe as the floor:34 - **Analysis adapter** (when an `audio-analysis` provider is35 configured — see36 [`audio-adapter-contract.md`](../../scripts/ai-video/lib/audio-adapter-contract.md)):37 `{bpm, beats, downbeats, sections:[{start,end,label,energy?}]}` —38 real musical structure. Beats/downbeats become the candidate cut39 grid; section labels are musical (`verse`, `chorus`, …).40 - **Audio probe** — JSON from41 [`scripts/ai-video/lib/probe-audio.sh`](../../scripts/ai-video/lib/probe-audio.sh):42 `{duration, method, warning?, sections:[{start,end,energy,label}]}`.43 - `method: silence` — boundaries are real quiet gaps; trust them as cuts.44 - `method: rms` — boundaries are energy-delta inflections; usable but45 coarse.46 - `method: interval` — **the track is structurally flat** (brick-walled47 / sustained); sections are fixed-interval, NOT musical. When `method`48 is `interval` (or `warning` is set), the emitted script header states49 that timing is interval-based and the operator should pass50 `--scene-durations` for musical sync. Never present interval cuts as51 beat-synced.52- **Model capabilities** — the chosen video model's renderable envelope53 from the multiplexer manifest:54 `scripts/ai-video/adapters/<provider>.sh capability --model <id>` →55 `{min_duration, max_duration, audio_sync, aspect, verified}`. Scene56 durations MUST land inside `[min_duration, max_duration]`; a57 `verified: false` manifest entry is surfaced in the report, never58 trusted silently.59- **Mode** — `brief` (operator text is the creative source) or `auto`60 (infer mood + action from energy).61- **Brief** (brief mode only) — free text: story, settings, look.62- **Character lock** (optional) — `<project>/character.json` if a human63 subject was locked. **Absent is normal** — abstract / landscape /64 visualiser videos have no locked subject; see Step 2.6566## Procedure6768### Step 1: Map sections → scenes (capability-clamped, beat-aligned)6970One `## Scene N` per analysis section. `duration:` = `end - start`71(rounded to 0.5 s). Then **clamp the plan to the chosen model's72renderable envelope** — read `min_duration` / `max_duration` from the73model-capabilities manifest (`<provider>.sh capability --model <id>`),74falling back to the provider tuning for single-model adapters:7576- **Section shorter than `min_duration`** → merge into its neighbour.77 With beat data, merge toward the neighbour that keeps the joined cut78 on a **downbeat** (else any beat); without beat data, merge into the79 shorter neighbour.80- **Section longer than `max_duration`** → split into sub-scenes. With81 beat data, place every split point on the **nearest downbeat** (else82 beat) to the equal-division point — never mid-beat; without beat83 data, split equally.84- **No valid plan exists** (e.g. the whole song is shorter than85 `min_duration`, or a section cannot be split onto any beat inside the86 envelope) → **halt and surface the conflict** with the model id and87 the violated bound — an unbuildable plan never reaches the renderer.8889Every emitted scene satisfies90`min_duration ≤ duration ≤ max_duration`. When the manifest entry is91`verified: false`, say so in the report — the envelope is92documented-best-effort, not a smoke-traced fact.9394### Step 2: Assign mood + action9596First decide the **subject mode**:9798- **Character mode** — `character.json` exists: every scene's `action:`99 names the locked subject, never a fresh description.100- **Style mode** — no `character.json`: scenes describe setting, palette,101 and motion continuity (the recurring *look*), not a person. This is the102 valid abstract / landscape / visualiser path — do not invent a human103 subject to fill the slot.104105Then pick the **prompt source per segment — the modality switch**:106107- **Lyric segment** (the vocal map places ≥1 transcribed line inside108 it) → the scene prompt derives from the **lyric line itself**: its109 imagery, subjects, and verbs seed `mood:` + `action:` (in character110 mode, acted by the locked subject; in style mode, rendered as111 setting / weather / palette — never an invented human). The line112 lands in `dialogue:` per Step 3.113- **Instrumental segment** (no vocal-map line) → the scene prompt114 derives from the **audio features**: section `label` + `energy` via115 the intent table below. Never recycle a lyric from another segment116 into an instrumental one.117118Then assign per scene:119120- **Brief mode** — distribute the brief's beats across scenes in order;121 the modality switch still applies (lyric segments quote the brief's122 matching beat through the lyric's lens), and energy modulates pacing.123 Do not add story the brief did not state.124- **Auto mode** — derive mood per section from `energy` and `label`125 (probe labels and musical labels from the analysis adapter both map):126127 | label / energy | default scene intent |128 |---|---|129 | intro / low | establishing wide, slow camera, calm subject/scene |130 | verse / mid | narrative motion, medium framing, follow the subject |131 | build / rising | approach, tightening framing |132 | chorus · drop / peak | dynamic motion, weather/FX, fast push |133 | bridge · breakdown / dip | close-up / detail, quiet, single light source |134 | outro / fade | pull-back, resolve, hold |135136**Energy → cut frequency + motion intensity.** Section energy (0..1,137relative to the track mean) drives both how often the edit cuts and how138hard the camera moves — chorus = faster cuts / more motion:139140| energy vs. track mean | cut length target | `camera:` motion intensity |141|---|---|---|142| ≥ mean + 0.10 (chorus / drop) | short — split the section toward `min_duration`, one scene per 1–2 downbeat bars | fast push / whip / handheld shake |143| within ±0.10 of mean (verse / build) | medium — one scene per section or per 4-bar phrase | steady dolly, slow tighten |144| ≤ mean − 0.10 (breakdown / outro) | long — merge toward `max_duration`, hold shots | locked-off or slow drift |145146High-energy splitting and low-energy merging both stay inside the147Step 1 capability envelope and land on downbeats — the energy table148chooses *where inside the envelope* a scene length falls, never149outside it.150151### Step 3: Vocal map — transcribe, never guess (vocal tracks)152153```154LYRIC TIMING AND SINGER COME FROM THE TRANSCRIBED AUDIO, NEVER FROM A155BRIEF / STORY SKELETON OR A GUESSED STRETCH. NEVER PUT ONE SINGER'S156LINE ON ANOTHER SINGER'S SCENE.157```158159When the track has vocals and the run intends lip-sync, build a160**vocal map** from the real audio before assigning any `dialogue:`:1611621. **Transcribe** the audio to timestamped lines. Adapter-first: a163 configured `lyrics` provider (e.g.164 [`audio-adapters/whisperx.sh`](../../scripts/ai-video/audio-adapters/whisperx.sh))165 returns word-level timestamps **plus per-line diarization labels**166 (`SPEAKER_00`, …, or `"?"` when ambiguous):167 ```bash168 echo '{"audio_path":"<vocal-stem-or-song>"}' \169 | scripts/ai-video/audio-adapters/whisperx.sh analyze170 ```171 No lyrics provider configured → OpenAI `/v1/audio/transcriptions`172 (`response_format=verbose_json` → `segments[].{start,end,text}`) or173 local whisper as before (no speaker labels — every line starts as174 `"?"`). Either way the transcript is the only source of lyric timing.1752. **Label the singer** per line — map diarization labels (or unlabeled176 lines) to cast names via the operator's who-sings reference (a177 roster, a brief that names who sings which line, or a character178 cast): one diarization label ↦ one cast name, consistently. If a179 line's singer is genuinely ambiguous (label `"?"`, mixed-speaker180 line, or no roster match), keep `singer: "?"` and surface it —181 never guess a singer to fill the slot.1823. **Emit** `<project>/vocal-map.json`:183 `[{start, end, text, singer}]`, timing verbatim from the transcript.1844. **Validate** — run the ground-truth enforcer before handing the map185 to the sign-off gate:186 ```bash187 scripts/ai-video/lib/validate-vocal-map.sh <project>/vocal-map.json \188 <project>/transcript.json --roster "<cast names>"189 ```190 It rejects re-timed lines, lyrics not in the transcript, and missing191 singers (exit 7, specific line named). A red validator is a halt —192 fix the map, never bypass.1935. **Place lines into the matching scene's** `dialogue:` block using the194 transcript timing, tagged with the singer (`singer: "<line>"`). A195 scene's lip-sync subject MUST be the line's labelled singer; a196 `"?"` line gets NO lip-sync scene until the operator resolves it.197198No vocals / no transcript / no lip-sync intent → leave `dialogue:` empty;199the scene is performance / B-roll. **Never fabricate lyrics**, **never200re-time a line off the brief**, and in style mode `dialogue:` stays empty201(lip-sync needs a character subject). The `/video:from-song` sign-off202gate (its Step 6) shows this map for approval before any render.203204### Step 4: Emit + reconcile205206Write `<project>/script.md` (and `<project>/vocal-map.json` when the207track has vocals). Report the delta, the section→scene map, **the probe208`method`** (so the operator sees whether cuts are silence-derived,209energy-derived, or interval-fallback), **and whether lyric timing is210transcript-derived** (it must be — never brief-derived). If the sum211cannot be reconciled (e.g. provider max-duration forces more time than212the song has), **halt and surface the conflict** — do not pad silently.213214### Step 5: Validate before handoff215216Concrete checks (all must pass before the script is handed to217`scene-expander`):218219- **Assert** `Σ(duration) == probe.duration` within ±1.0 s; report the220 exact delta. A larger delta → halt, do not pad.221- **Verify** every scene boundary equals a probe section boundary (or a222 `--scene-durations` value) — no invented cut points.223- **Confirm** no scene `duration:` exceeds the model's `max_duration`224 or falls below `min_duration` (model-capabilities manifest, or the225 provider tuning for single-model adapters).226- **Verify** every lyric-segment scene derives its prompt from its own227 vocal-map line and every instrumental scene from audio features — no228 cross-segment lyric recycling (modality switch).229- **Ensure** every `## Scene N` carries all five keys (`duration` ·230 `mood` · `action` · `camera` · `dialogue`), and that `dialogue:` is231 empty in style mode.232233## Output format2342351. `script.md` opens with the derivation header —236 `# <project> — derived from <song-file> (<mode> mode · cuts: <method>)`237 — so the probe `method` stays visible downstream.2382. One `## Scene N` block per cut carrying exactly the keys239 `duration` · `mood` · `action` · `camera` · `dialogue` —240 `scene-expander` consumes this verbatim; keep the keys exact.2413. `dialogue:` stays empty unless operator-supplied lyrics cover the242 section — detected vocal energy alone never fills it.243244```markdown245# <project> — derived from <song-file> (<mode> mode · cuts: <method>)246247## Scene 1248duration: 6.0249mood: establishing, cold, pre-storm250action: <subject from character.json, OR style description in style mode>251camera: slow push-in252dialogue:253254## Scene 2255duration: 4.5256mood: build, rising tension257action: close on <subject / detail>, wind picking up258camera: handheld tighten259dialogue:260 - "<subject>: \"<lyric line for this section, if any>\""261```262263## Gotcha264265- **`method: interval` is the brick-walled-master signal, not a bug.**266 A compressed modern master has near-constant RMS and no silence, so the267 probe degrades to fixed intervals. That is the honest floor — surface268 it and point the operator at `--scene-durations`; never dress interval269 cuts up as beat-synced.270- **A vocal section without supplied lyrics is B-roll, not lip-sync.**271 Detected vocal energy alone does not authorise `dialogue:` — only272 operator-supplied lyrics do.273- **Style mode is the default for a no-character run**, not an error274 path. Landscape / abstract / visualiser videos never get a fabricated275 human subject.276277## Do NOT278279- **Do NOT invent timing.** Every cut maps to a probe boundary or a280 `--scene-durations` value — never to taste.281- **Do NOT present `interval`-fallback cuts as beat-synced.** Always282 surface the probe `method`.283- **Do NOT emit a clip outside the provider's min/max duration** —284 split/merge in Step 1 instead.285- **Do NOT fabricate lyrics or story** beyond the brief / detected vocals.286- **Do NOT invent a human subject** in style mode; defer identity to287 `character.json` only when a lock exists.288- **Do NOT pad a unreconcilable timing sum** — halt and surface it.289290## See also291292- [`/video:from-song`](../../commands/video/from-song.md) — the command293 that drives this skill294- [`scene-expander`](../scene-expander/SKILL.md) — consumes the emitted295 script296- [`character-consistency`](../character-consistency/SKILL.md) — supplies297 the locked subject referenced in `action:` (character mode only)