add-voiceover-to-video
Take a voice_id (from ElevenLabs), a script (string), and a video file (mp4/mov). Produce a final mp4 with the voiceover muxed in. Upload to VidJutsu CDN and return a stable URL.
When to use
- User has a silent or b-roll-style video and wants to add narration.
- Following
clone-voice-from-video — the natural next step.
- Re-voicing a video with a script tweak (regenerate VO, re-mux) without re-rendering the video.
When NOT to use
- User wants to replace an existing spoken track with a different voice → use
swap-voice (ElevenLabs STS) instead.
- User has no script yet → write the script first; this skill assumes the script is locked.
Cost
- ElevenLabs TTS (multilingual v2):
$0.30 per 1K characters at the Creator tier ($0.18 Pro). A 50-word script ≈ 280 chars ≈ $0.05–0.08.
- VidJutsu upload: free on the included plan.
- Total: < $0.10 per run.
Pipeline
Validate ELEVENLABS_API_KEY and VIDJUTSU_API_KEY. Stop with a signup link if either is missing.
Probe the video with ffprobe to get its duration. The VO must fit inside this window.
Generate the VO via ElevenLabs TTS:
curl -s -X POST "https://api.elevenlabs.io/v1/text-to-speech/<VOICE_ID>?output_format=mp3_44100_128" \
-H "xi-api-key: $ELEVENLABS_API_KEY" \
-H "Content-Type: application/json" \
--data-raw '{
"text": "<script>",
"model_id": "eleven_multilingual_v2",
"voice_settings": {
"stability": 0.55,
"similarity_boost": 0.85,
"style": 0.3,
"use_speaker_boost": true,
"speed": 0.95
}
}' \
--output <out>/voiceover.mp3
Sanity-check the VO duration with ffprobe. If vo_duration + lead_in > video_duration, the script is too long — surface to the user with the delta and the suggestion to trim N words. Do not silently truncate.
Mux with ffmpeg. Add a small lead-in (default 0.5s) so the VO doesn't start on frame 1, and apad so the audio track stretches to match the (likely longer) video. -shortest ensures the output ends at video end:
ffmpeg -y -i <video> -i <out>/voiceover.mp3 \
-filter_complex "[1:a]adelay=500|500,apad[a]" \
-map 0:v:0 -map "[a]" \
-c:v copy -c:a aac -b:a 192k -shortest \
<out>/video-with-vo.mp4
Verify dead space. If the gap between vo_end + lead_in and video_duration is > 2s, surface to the user with a suggestion to add a closing line of ~N words. Do not silently leave a long silence — viewers register it as an unfinished ad.
Upload to VidJutsu CDN for a stable shareable URL:
vidjutsu upload <out>/video-with-vo.mp4
# → returns { url, assetId }
Persist a manifest at <out>/manifest.json:
{
"voice_id": "...",
"script": "...",
"vo_duration_seconds": <number>,
"video_duration_seconds": <number>,
"lead_in_seconds": 0.5,
"tail_silence_seconds": <number>,
"video_with_vo_path": "<abs path>",
"vidjutsu_url": "https://cdn.vidjutsu.ai/...mp4",
"vidjutsu_asset_id": "asset_..."
}
Defaults
| Param |
Default |
Notes |
model_id |
eleven_multilingual_v2 |
Best balance of quality and prosody for cloned voices. |
stability |
0.55 |
Slight expressiveness without drift. Lower = more emotional, higher = more monotone. |
similarity_boost |
0.85 |
Hold close to the cloned voice's character. |
style |
0.3 |
Light style transfer. Push to 0.5+ only if the source has strong vocal personality. |
speed |
0.95 |
Slightly slower than 1.0 reads more naturally for ad delivery. |
lead_in_seconds |
0.5 |
Avoids frame-1 audio cut-in. |
output_format |
mp3_44100_128 |
Plenty of headroom for AAC re-encode at mux. |
Hard-won learnings
ElevenLabs duration is non-deterministic on the same script. Ask for a 50-word script with speed: 0.95 and you may get 11.4s on one call and 14.5s on the next. Always re-probe after generation; don't assume word-count predicts seconds.
Em-dashes (—) and ellipses (...) can blow up duration. A script that rendered at 11s without dashes can render at 24s with dashes (the model sometimes treats them as long pauses). If the duration jumps wildly between attempts, normalize punctuation (commas + periods only) and re-run.
Apostrophe escaping in shell-passed JSON. you'll shelled through bash with -d "...you'll..." will sometimes emit a stray backslash that ElevenLabs reads literally. Use --data-raw '{"text":"..."}' with single-quote outside / single-quote-escape-inside, or write the JSON to a file and --data-binary @file.json.
-shortest cuts video to VO duration if you swap the args. The pattern ffmpeg -i video.mp4 -i vo.mp3 ... -shortest plus apad on the audio side will preserve full video length only if the apad'd audio is longer than the video. Always apad the audio.
Dead space at the end reads as unfinished. A 15s ad with a VO that ends at 11s shows the viewer 4 seconds of held-product silence. Either extend the script with a closer that maps to whatever's on screen during that window (use VidJutsu /v1/watch to identify the visual beat), or trim the video.
Upload to VidJutsu CDN, not the bare Higgsfield/Wavespeed CDN. Source CDNs rotate or expire (Higgsfield CloudFront has a 7-day TTL). VidJutsu URLs persist. For anything the user will share or re-publish, always upload.
Reference run
EP-01 Bloom Greens (Strawberry Kiwi UGC, 15s):
- Voice:
lFGbjNWc9mbcFqOEMwq3 (cloned via clone-voice-from-video)
- Script: 54 words, ending with "Tap the orange cart, I linked the one I use."
- VO duration: 14.5s, lead-in 0.5s, video 15s → 0s tail silence
- Output:
https://cdn.vidjutsu.ai/uploads/mc_8ae830aed7914759ac75c693/8891660c-7e6d-4d03-a5c0-9240a8692dc5.mp4
This run produced the canonical "no dead space, native CTA" output. Use as the validation template for future runs.
1---2name: add-voiceover-to-video3description: Generate an ElevenLabs voiceover from a script and a `voice_id`, then mux it onto a (typically silent) video. Handles lead-in padding, end-pad to video length, and uploads the final mp4 to VidJutsu CDN for a stable URL. Use when the user has a voice ID + script + video and wants the spoken track laid down. Pairs with `clone-voice-from-video`.4---56# add-voiceover-to-video78Take a `voice_id` (from ElevenLabs), a script (string), and a video file (mp4/mov). Produce a final mp4 with the voiceover muxed in. Upload to VidJutsu CDN and return a stable URL.910## When to use1112- User has a silent or b-roll-style video and wants to add narration.13- Following `clone-voice-from-video` — the natural next step.14- Re-voicing a video with a script tweak (regenerate VO, re-mux) without re-rendering the video.1516## When NOT to use1718- User wants to *replace* an existing spoken track with a different voice → use `swap-voice` (ElevenLabs STS) instead.19- User has no script yet → write the script first; this skill assumes the script is locked.2021## Cost2223- ElevenLabs TTS (multilingual v2): ~$0.30 per 1K characters at the Creator tier (~$0.18 Pro). A 50-word script ≈ 280 chars ≈ $0.05–0.08.24- VidJutsu upload: free on the included plan.25- Total: < $0.10 per run.2627## Pipeline28291. **Validate** `ELEVENLABS_API_KEY` and `VIDJUTSU_API_KEY`. Stop with a signup link if either is missing.30312. **Probe the video** with `ffprobe` to get its duration. The VO must fit inside this window.32333. **Generate the VO** via ElevenLabs TTS:3435 ```sh36 curl -s -X POST "https://api.elevenlabs.io/v1/text-to-speech/<VOICE_ID>?output_format=mp3_44100_128" \37 -H "xi-api-key: $ELEVENLABS_API_KEY" \38 -H "Content-Type: application/json" \39 --data-raw '{40 "text": "<script>",41 "model_id": "eleven_multilingual_v2",42 "voice_settings": {43 "stability": 0.55,44 "similarity_boost": 0.85,45 "style": 0.3,46 "use_speaker_boost": true,47 "speed": 0.9548 }49 }' \50 --output <out>/voiceover.mp351 ```52534. **Sanity-check** the VO duration with `ffprobe`. If `vo_duration + lead_in > video_duration`, the script is too long — surface to the user with the delta and the suggestion to trim N words. Do not silently truncate.54555. **Mux** with ffmpeg. Add a small lead-in (default 0.5s) so the VO doesn't start on frame 1, and `apad` so the audio track stretches to match the (likely longer) video. `-shortest` ensures the output ends at video end:5657 ```sh58 ffmpeg -y -i <video> -i <out>/voiceover.mp3 \59 -filter_complex "[1:a]adelay=500|500,apad[a]" \60 -map 0:v:0 -map "[a]" \61 -c:v copy -c:a aac -b:a 192k -shortest \62 <out>/video-with-vo.mp463 ```64656. **Verify dead space.** If the gap between `vo_end + lead_in` and `video_duration` is > 2s, surface to the user with a suggestion to add a closing line of ~N words. Do not silently leave a long silence — viewers register it as an unfinished ad.66677. **Upload** to VidJutsu CDN for a stable shareable URL:6869 ```sh70 vidjutsu upload <out>/video-with-vo.mp471 # → returns { url, assetId }72 ```73748. **Persist** a manifest at `<out>/manifest.json`:7576 ```json77 {78 "voice_id": "...",79 "script": "...",80 "vo_duration_seconds": <number>,81 "video_duration_seconds": <number>,82 "lead_in_seconds": 0.5,83 "tail_silence_seconds": <number>,84 "video_with_vo_path": "<abs path>",85 "vidjutsu_url": "https://cdn.vidjutsu.ai/...mp4",86 "vidjutsu_asset_id": "asset_..."87 }88 ```8990## Defaults9192| Param | Default | Notes |93|---|---|---|94| `model_id` | `eleven_multilingual_v2` | Best balance of quality and prosody for cloned voices. |95| `stability` | `0.55` | Slight expressiveness without drift. Lower = more emotional, higher = more monotone. |96| `similarity_boost` | `0.85` | Hold close to the cloned voice's character. |97| `style` | `0.3` | Light style transfer. Push to 0.5+ only if the source has strong vocal personality. |98| `speed` | `0.95` | Slightly slower than 1.0 reads more naturally for ad delivery. |99| `lead_in_seconds` | `0.5` | Avoids frame-1 audio cut-in. |100| `output_format` | `mp3_44100_128` | Plenty of headroom for AAC re-encode at mux. |101102## Hard-won learnings1031041. **ElevenLabs duration is non-deterministic on the same script.** Ask for a 50-word script with `speed: 0.95` and you may get 11.4s on one call and 14.5s on the next. Always re-probe after generation; don't assume word-count predicts seconds.1051062. **Em-dashes (`—`) and ellipses (`...`) can blow up duration.** A script that rendered at 11s without dashes can render at 24s with dashes (the model sometimes treats them as long pauses). If the duration jumps wildly between attempts, normalize punctuation (commas + periods only) and re-run.1071083. **Apostrophe escaping in shell-passed JSON.** `you'll` shelled through bash with `-d "...you'll..."` will sometimes emit a stray backslash that ElevenLabs reads literally. Use `--data-raw '{"text":"..."}'` with single-quote outside / single-quote-escape-inside, or write the JSON to a file and `--data-binary @file.json`.1091104. **`-shortest` cuts video to VO duration if you swap the args.** The pattern `ffmpeg -i video.mp4 -i vo.mp3 ... -shortest` plus `apad` on the audio side will preserve full video length only if the apad'd audio is longer than the video. Always `apad` the audio.1111125. **Dead space at the end reads as unfinished.** A 15s ad with a VO that ends at 11s shows the viewer 4 seconds of held-product silence. Either extend the script with a closer that maps to whatever's on screen during that window (use VidJutsu `/v1/watch` to identify the visual beat), or trim the video.1131146. **Upload to VidJutsu CDN, not the bare Higgsfield/Wavespeed CDN.** Source CDNs rotate or expire (Higgsfield CloudFront has a 7-day TTL). VidJutsu URLs persist. For anything the user will share or re-publish, always upload.115116## Reference run117118EP-01 Bloom Greens (Strawberry Kiwi UGC, 15s):119120- Voice: `lFGbjNWc9mbcFqOEMwq3` (cloned via `clone-voice-from-video`)121- Script: 54 words, ending with "Tap the orange cart, I linked the one I use."122- VO duration: 14.5s, lead-in 0.5s, video 15s → 0s tail silence123- Output: `https://cdn.vidjutsu.ai/uploads/mc_8ae830aed7914759ac75c693/8891660c-7e6d-4d03-a5c0-9240a8692dc5.mp4`124125This run produced the canonical "no dead space, native CTA" output. Use as the validation template for future runs.