One take in, the same take in another language out, in the creator's own voice (ElevenLabs clones it from the take itself). The image stays the original: no lip-sync. Then /video-edit edits the translated take like any other, with captions in the new language.
Talk to them in their language. <slug>: the take's slug; <lang>: the target language code (en, es, fr, de, pt, it, ja...), <from>: the source one.
1. The ElevenLabs key (once)
The key lives in video-agent/.env, never in a chat message, a script or a commit:
grep -c ELEVENLABS_API_KEY video-agent/.env 2>/dev/null
0 or no file:
- They create a key at elevenlabs.io/app/settings/api-keys (an ElevenLabs account; dubbing uses the credits of their plan, the free plan adds a watermark).
- They paste it into
video-agent/.envthemselves, as one line:ELEVENLABS_API_KEY=sk_.... Offer to create the empty file and open it. Never ask them to paste the key in the chat; if they do anyway, write it to the file and tell them they can regenerate it. video-agent/.envmust be in the project's.gitignore(add the linevideo-agent/.envif it isn't).
Every command below loads it with set -a; . video-agent/.env; set +a and never prints it.
2. What to translate
Translate the take, not a finished video: a finished video has captions burned in the old language. Then video-edit cuts, captions and times everything on the new voice.
- A take already edited (
video-agent/edits/<slug>/edit.jsonexists): send only the kept parts, so no silence, stutter or retake is paid for. Make the plain cut with video-edit's ranges (fromvideo-agent/studio/):
The source isSEL=$(node -e 'const e=require("./public/<slug>.edit.json");console.log(e.ranges.map(r=>`between(t,${r.fromMs/1000},${r.toMs/1000})`).join("+"))') ffmpeg -y -i public/<slug>.mp4 -vf "select='$SEL',setpts=N/FRAME_RATE/TB" -af "aselect='$SEL',asetpts=N/SR/TB" -c:v libx264 -crf 18 -c:a aac -b:a 192k ../.tmp/<slug>-cut.mp4video-agent/.tmp/<slug>-cut.mp4. - A raw take: offer to run
/video-editup to its cut list first (steps 1 to 4) for the same reason. In a hurry: send the raw take as it is.
3. Estimate, then send
ffprobe -v error -show_entries format=duration -of csv=p=0 "<source>"
Show one line and wait for a yes: Dub <slug> fr → en · 0:52 of video · billed on your ElevenLabs credits for about 1 minute (check your plan's dubbing price on elevenlabs.io/pricing) · no lip-sync.
Then send it (about 10 s per minute of upload):
mkdir -p video-agent/.tmp
set -a; . video-agent/.env; set +a
curl -sS --fail-with-body -X POST https://api.elevenlabs.io/v1/dubbing \
-H "xi-api-key: $ELEVENLABS_API_KEY" \
-F "file=@<source>" -F "target_lang=<lang>" -F "source_lang=<from or auto>" \
-F "num_speakers=1" -F "watermark=false" -F "name=<slug>-<lang>" \
> video-agent/.tmp/<slug>-<lang>.job.json
cat video-agent/.tmp/<slug>-<lang>.job.json
It returns {"dubbing_id": "...", "expected_duration_sec": ...}. Keep the id: if anything stops, polling again with it costs nothing. A 401 is a wrong key; a 402 or "quota" means no credits left on their plan; watermark refused: send it again without that field.
4. Wait, then download
Poll every 15 s until status is dubbed (failed: show the error and stop). Usually 1 to 5 minutes for a short; run it in the background:
set -a; . video-agent/.env; set +a
curl -sS --fail-with-body -H "xi-api-key: $ELEVENLABS_API_KEY" https://api.elevenlabs.io/v1/dubbing/<dubbing_id>
Then the dubbed file:
set -a; . video-agent/.env; set +a
curl -sS --fail-with-body -H "xi-api-key: $ELEVENLABS_API_KEY" -o video-agent/.tmp/<slug>-<lang>.dub https://api.elevenlabs.io/v1/dubbing/<dubbing_id>/audio/<lang>
ffprobe -v error -show_entries stream=codec_type -of csv=p=0 video-agent/.tmp/<slug>-<lang>.dub
- It has a
videostream: it is already the dubbed video, move it tovideo-agent/takes/<slug>-<lang>.mp4. - Audio only: lay it back on the original image:
ffmpeg -y -i "<source>" -i video-agent/.tmp/<slug>-<lang>.dub -map 0:v -map 1:a -c:v copy -c:a aac -b:a 192k -shortest video-agent/takes/<slug>-<lang>.mp4
Check it: same length as the source (within a second). Then ask them to listen to the first 10 seconds before editing (voice, accent, a wrong word).
5. Edit the translated take
/video-edit <slug>-<lang>, and tell it:
- The language changed: in
video-agent/studio/whisper-config.mjs, setWHISPER_LANGto<lang>for this transcription (English:WHISPER_MODEL = "medium.en"works best; any other language:"medium"), and put it back after. The hook card, callouts andpost.mdare written in<lang>. - Same edit as the original when there is one: same hook card and callouts (translated), placed on the matching words of the new transcript.
- The ask (CTA) in
<lang>: ask them once for its translation, or translate the one in voice.md and show it.
Name everything <slug>-<lang> so both versions sit side by side. Then /video-publish <slug>-<lang> on the account for that language.
Delete video-agent/.tmp/<slug>-* at the end, except the job file if they may want to download the dub again.
Honest limits
- No lip-sync: the lips move in the original language. Fine with a lot of motion, a small face or quick cuts; noticeable on a long close-up.
- The voice clone comes from the take: a noisy take or music under the voice gives a worse clone.
- Machine translation: names, jargon and jokes can come out wrong. Ask them (or a native speaker) to check the transcript of the dubbed take before posting.
- Dubbing costs ElevenLabs credits per minute, and the price depends on their plan. Trimming the take first is the cheapest saving.