--- name: auto-dubbing description: Dub a video into one or more other languages using Sonilo, translating and re-voicing the speech into a new video per language. Optionally supply your own target-language script per language, so the dub speaks those lines verbatim instead of the pipeline's own translation, and get a re-timed SRT back per language. Use when a user needs a video localized into another language, not just subtitled. Billed per language; a self-serve account's first single-language call is a free 15-second preview — confirm language count with the user before calling. license: MIT compatibility: "Requires Sonilo through either transport — the MCP server connected, or the `sonilo` CLI installed and signed in — plus credentials: a `sonilo login` sign-in, the hosted OAuth plugin, or SONILO_API_KEY. See the setup-api-key skill." allowed-tools: Bash, Read, Write, mcp__sonilo__* --- # Sonilo Dubbing Dub a video into one or more other languages: the speech is translated and re-voiced, producing a new `.mp4` per target language (not just an audio track or subtitles). If the user already has approved translations, pass them as `subtitles` and the dub speaks those lines instead of translating the source itself. > **Setup:** See the [setup-api-key](../setup-api-key) skill. > ⚠️ **Cost — read before calling:** this is billed **per language**. The only free run is a **15-second preview**: a self-serve account's first call with ONE language and no `subtitles` translates just the first 15 seconds of the video, at no charge, and the result carries `trial_preview` with what the whole video would cost. Every call after that — and any call with several languages or scripts — is charged. Requesting four languages costs four times as much as one. Confirm the exact language list with the user before calling; do not guess a long list "to be helpful." > ⏱ **This call is slow.** It polls for **at least two hours** internally regardless of any shorter `TIME_OUT_SECONDS` — that's the backend's own ceiling for the dubbing pipeline. A call that sits for an hour or more is normal, not a hang. Do not cancel it: the job keeps running and charging either way, and cancelling just loses the easy path to the result (use `get_sfx_task`, or `get_generation_task` on the hosted server, to recover it instead). ## Transport: MCP or CLI Pick one at the start of the session and stay on it. Do not mix the two inside a single job, and do not announce the choice. 1. **Sonilo MCP tools visible in this session** (`dubbing` and friends) — use them. This is the preferred path: it needs no shell, and it is the only one that survives a very long generation. If a call fails to authenticate — rather than failing on its inputs — this transport is not usable in this session: go to 2 instead of retrying it. 2. **No usable Sonilo MCP tools, but `sonilo account` exits 0** — use the CLI commands below. Same API, same account, same credential file. Probe with `sonilo account`, not `sonilo whoami`: whoami exits 0 even when signed out, so it cannot tell the two states apart. 3. **Neither** — stop and run the [setup-api-key](../setup-api-key) skill. Do not call `api.sonilo.com` with curl to work around it; both transports handle uploads, polling and retries that a bare request does not. > ⏱ **On the CLI path this call cannot be one command.** The backend polls for > up to two hours, while a host's shell tool is capped far below that (ten > minutes in Claude Code), so `sonilo dubbing` run in the foreground will be > killed with the job still running and already charged. Submit it and poll > separately instead: > > ```bash > # --timeout is the CLI's own wait, not the job's: this returns before a host > # shell can kill the process. The id comes from the "Submitted task ..." line. > sonilo dubbing --video-url https://example.com/clip.mp4 --languages es,fr --timeout 300 > sonilo tasks wait # repeat until it finishes > ``` > > The MCP path has no such limit and is the better transport for dubbing. ## Quick Start ### MCP tool call (recommended) ``` dubbing( video_path="~/Desktop/product-demo.mp4", languages=["es", "fr"] ) ``` With the user's own scripts, and a re-timed `.srt` back per language: ``` dubbing( video_path="~/Desktop/product-demo.mp4", languages=["es", "fr"], subtitles={"es": "~/Desktop/demo.es.srt", "fr": "~/Desktop/demo.fr.srt"}, export_srt=True ) ``` > On the **hosted** server every subtitle value must be an `https://` URL — that server has no filesystem to read a path from, and `video_url` is likewise its only source. A local video or `.srt`/`.vtt` file gets such a URL from the hosted server's `create_upload_url` tool (steps in [preflight](../references/preflight.md), Step 4). The **local** `sonilo-mcp` server takes either a local `.srt`/`.vtt` path or an https URL. ### Python (`pip install sonilo`) ```python from sonilo import Sonilo with Sonilo() as client: # reads SONILO_API_KEY result = client.dubbing.generate( video="product-demo.mp4", languages=["es", "fr"], # Optional: the lines to speak in each target language. A value is # either a local .srt/.vtt path or an https URL. subtitles={"es": "demo.es.srt", "fr": "demo.fr.srt"}, export_srt=True, timeout=7200, # seconds — matches the backend's own ~2h ceiling ) for language, path in result.save_all("./dubbed").items(): print(language, path) # Only present with export_srt. Iterate this map, not the videos: a # language whose export was blocked still has its .mp4. for language, path in result.save_all_subtitles("./dubbed").items(): print(language, path) ``` ### JavaScript / TypeScript (`npm install sonilo`) ```ts import { SoniloClient } from "sonilo"; import type { DubbingResult } from "sonilo"; const client = new SoniloClient(); // reads SONILO_API_KEY const result = await client.dubbing.generate( { video: "./product-demo.mp4", languages: ["es", "fr"], // Optional: a local .srt/.vtt path, an https URL, or a File in a browser. subtitles: { es: "./demo.es.srt", fr: "https://example.com/demo.fr.vtt" }, exportSrt: true, }, { timeout: 7_200_000 }, // milliseconds — matches the backend's own ~2h ceiling ); for (const [language, url] of Object.entries((result as DubbingResult).outputs ?? {})) { console.log(language, url); } // Only present with exportSrt, and a language whose export was blocked is absent. for (const [language, url] of Object.entries((result as DubbingResult).subtitles ?? {})) { console.log(language, url); } ``` ### CLI (`npm install -g sonilo-cli` or `pip install sonilo-cli`) ```bash sonilo dubbing --video-url https://example.com/product-demo.mp4 --languages es,fr --output dubbed.mp4 # writes dubbed.es.mp4 and dubbed.fr.mp4 # With the user's own scripts. --subtitle is repeatable, once per language, and # its value is a local .srt/.vtt path or an https URL. sonilo dubbing --video-url https://example.com/product-demo.mp4 --languages es,fr --subtitle es=demo.es.srt --subtitle fr=demo.fr.srt --export-srt --output dubbed.mp4 # also writes dubbed.es.srt and dubbed.fr.srt beside the videos, so --output may not end in .srt ``` `--timeout` defaults to 7200 seconds already, matching the backend's ceiling. That is fine in a normal terminal; inside an agent host shell, use the shorter submit-and-poll pattern above so the shell tool does not kill the foreground command. ### cURL (raw REST API, no MCP host) ```bash curl -X POST "https://api.sonilo.com/v1/dubbing" \ -H "Authorization: Bearer $SONILO_API_KEY" \ -F "video=@product-demo.mp4" \ -F 'languages=["es","fr"]' \ -F "subtitles[es]=@demo.es.srt" \ -F "subtitles[fr]=https://example.com/demo.fr.vtt" \ -F "export_srt=true" # -> {"task_id": "...", "subtitle_preflight": {...}} # poll GET /v1/tasks/{task_id} — can take up to ~2 hours ``` `video_url` is also accepted instead of an uploaded file, but **must be https** — the dubbing pipeline fetches the source itself and rejects plain http. One `subtitles[]` field per language, each either an uploaded `.srt`/`.vtt` part or an https URL string. The bracket key is required: a bare `subtitles`, a repeated key, or a near-miss code (`subtitles[zh-CN]` — the codes use underscores, `zh_cn`) is rejected, never silently ignored. ## Tool | Tool | Description | |------|-------------| | `dubbing(video_path? \| video_url?, languages?, ducking?, lipsync?, subtitles?, export_srt?, output_directory?)` | Dub a video into each requested language; one `.mp4` saved per language, plus one `.srt` per language with `export_srt`. | ## Parameters | Parameter | Type | Default | Notes | |-----------|------|---------|-------| | `video_path` | string | — | `.mp4/.mov/.webm/.m4v/.gif` (gif must be animated). Max **300s (5 min)**, subject to the account's upload-size cap. | | `video_url` | string | — | **Must be https** (not just http). Exactly one of `video_path`/`video_url`. | | `languages` | list[str] | `["zh_cn", "es", "fr"]` | Target language codes. Supported: `en`, `zh_cn`, `ja`, `ko`, `pt`, `pt_br`, `es`, `es_419`, `de`, `fr`, `it`, `ru`, `th`, `ar`, `tr`, `vi`, `id`, `ta`, `ml`, `kn`, `gu`, `pa_in`, `sd_in`, `hi`. `pt_br` is Brazilian Portuguese and `es_419` Latin American Spanish; plain `pt`/`es` are unqualified, so ask which the user wants when it matters. `ar` is unqualified Arabic rather than a country dialect, so there is nothing to ask there. `pa_in` and `sd_in` are Punjabi and Sindhi as spoken in India — the only variants available, so say so if the user's audience is in Pakistan, where both are written in another script. `hi` is Hindi. **Omitting this still dubs into 3 languages and bills for 3** — pass an explicit single-element list if the user only wants one. | | `ducking` | bool | off | Duck the background music/effects bed under the dubbed voice while it speaks. Off by default: the bed is always kept, at a constant level. Free either way. | | `lipsync` | bool | `true` | Whether the speaker's mouth is re-rendered to match the dubbed speech. Set `false` to leave the picture completely untouched instead — the video comes back at its original resolution and frame rate rather than re-rendered, and only the audio is replaced, so the mouths keep moving to the original language. Reach for it when the footage has no on-camera speaker (screen recordings, b-roll, voice-over), or when preserving the exact original picture matters more than matching lip movement. Same price either way. | | `subtitles` | dict[str, str] | — | One script per target language, e.g. `{"es": "demo.es.srt"}`. Each value is a local `.srt`/`.vtt` path or an https URL (the hosted MCP server takes URLs only). These are **target-language** scripts carrying the lines to speak, not source transcripts — the dub says them verbatim instead of translating. The key set must equal `languages` exactly. See the rules below; every one of them is a `422` before any charge. | | `export_srt` | bool | `false` | Also return each language's lines re-timed against its delivered audio, by forced alignment, keeping the wording verbatim. **Requires `subtitles`** — sending it alone is a `422`. | | `output_directory` | string | `SONILO_MCP_BASE_PATH` | Absolute, or relative to the base path. | ## Bringing Your Own Scripts Use `subtitles` when the user already has approved translations and wants them spoken word for word. If the user wants to *review or correct* the translation before it is spoken and has no script yet, run the [proofread](../proofread) skill first — it returns one editable `.srt` per language, and the corrected files are exactly what `subtitles` takes (dropping its source-language file, which is an extra key here). Every rule below is checked before anything is charged, so breaking one costs a `422` and not money — but it also costs the user a round trip, and the first two are easy to get wrong: - **Cover every language, and only those languages.** The key set must equal `languages` exactly; a missing or extra code is refused and the error names it. If the user gives you a script for two of three languages, ask for the third rather than dropping it — and remember that omitting `languages` altogether still means three (`zh_cn`, `es`, `fr`), so all three need a script. - **Target-language scripts, not source transcripts.** A file of the original English lines submitted under `"es"` is a valid-looking script that produces a Spanish dub speaking English. The check reads the text and rejects a script whose language is not the one it was submitted under, but read what the user handed you before sending it. - **`.srt` or `.vtt` only**, lower-cased extension, up to 1 MiB each. Anything else is refused. - **`export_srt` needs `subtitles`.** There is nothing to align the audio against without a script, so on its own it is a `422`. - **A blocked export does not fail the job.** The dubbed videos are still delivered and charged; that language simply has no `.srt`. Report it as a missing extra, never as a failed dub, and do not re-run the job for it. - The `202` carries `subtitle_preflight` — what the check made of each script — and the finished task repeats it alongside `subtitle_export` (per-language `status`, `alignment_loss`, `issues`). The pipeline stores those numbers as JSON of whatever type it recorded, so a count or a loss may arrive as a string rather than a number; do not assume one when you surface them. ## Workflow Tips - **Always ask which language(s)** if the user hasn't said, rather than relying on the `["zh_cn", "es", "fr"]` default — that default silently bills for three languages. - **Consider `lipsync=false` when nobody is speaking on camera.** Screen recordings, b-roll and voice-over have no mouth to match, and lip-syncing them re-renders the picture for no benefit — the original frames are returned untouched instead, at their own resolution and frame rate. Do not turn it off on a talking-head video unless the user asks: the mouths will visibly keep speaking the original language. - **Ask for scripts when the user has them.** If they mention approved copy, an existing localization, or a translation they want said exactly, that is `subtitles` — the pipeline's own translation would overwrite it. Do not invent one: a script you wrote yourself is a translation the user never approved, spoken in their video. - **This is not the music or SFX skills** ([text-to-music](../text-to-music), [video-to-music](../video-to-music), [text-to-sfx](../text-to-sfx), [video-to-sfx](../video-to-sfx)). It doesn't touch music/SFX at all — it translates and re-voices existing speech. - **Set expectations on time.** Tell the user up front this can take up to ~2 hours and that walking away is fine — the result is recoverable afterward. - **The free run is a preview, not the whole video.** A self-serve account gets one free single-language call, and it translates only the first 15 seconds. When the result carries `trial_preview`, tell the user exactly that — the clip is the first 15 seconds of their video, and `full_video_cost_usd` is what the whole thing costs — rather than presenting the clip as the finished translation. Once that run is spent, an account without a payment method fails with `trial_exhausted`; check `get_account_services` (see [account](../account)) first if unsure, and warn the user before calling. ## Recovering a Timed-Out Call If the call's own long poll is interrupted (e.g. the host itself times out or the session is closed), the error message — or the task id printed to stderr at submission time — gives you a `task_id`. Call `get_sfx_task(task_id)` — `get_generation_task(task_id)` on the hosted server — to check status and download finished files once ready; see [task-recovery](../task-recovery). ## Output Files One `.mp4` per requested language, named `dubbing-..mp4` — there's no prompt to name files after, so all dubbing output shares the task-id-based name. With `export_srt`, one `.srt` lands beside each video under the same stem — `dubbing-..srt` — carrying that language's own lines re-timed against its delivered audio. A language whose export was blocked has its video and no `.srt`; that is reported as a note, not an error. On the CLI the SRTs follow `--output` the same way the videos do (`--output dubbed.mp4` writes `dubbed.es.mp4` and `dubbed.es.srt`), which is why `--output` may not itself end in `.srt`. ### Free preview When the run was the account's free 15-second preview, the task carries `trial_preview` in every state: ```json "trial_preview": { "preview_seconds": 15, "source_duration_seconds": 60.0, "trimmed": true, "languages": 1, "full_video_cost_usd": 3.49, "message": "Free preview: the first 15 seconds of your 60-second video, in 1 language. Translating the full video costs $3.49 — add funds at https://platform.sonilo.com/dashboard/billing" } ``` `duration_seconds` is then 15, not the source's length, and the delivered `.mp4` is that first 15 seconds. The MCP tool and both CLIs print the `message` with their other status lines; the SDKs expose it as `DubbingResult.trial_preview`. Relay it to the user, and offer the full run (add funds, call again — that run is billed) rather than treating the clip as the whole video. ## Error Handling Common errors: `401` invalid key, `402` insufficient balance (dubbing has no trial to exhaust — it bills immediately), `413` file too large, `422` invalid parameters or unsupported language code (rejected before any charge), `429` rate limit. See the [account](../account) skill. A `422` with `code: SUBTITLE_PREFLIGHT_BLOCKED` means a submitted script itself did not pass the check. The message names the blocked languages and their issue codes, and `details` carries the per-language report. Nothing was charged — fix or replace that script and resubmit; do not strip `subtitles` and run the job anyway, which would silently deliver the pipeline's own translation instead of the user's approved lines.