Translate a video
Dub a video into another language in one call. A single job transcribes the audio, translates it, and generates a new voiceover that keeps the original speaker’s voice.
The mouth is not re-synced. To match the speaker’s lips to the dubbed audio, chain the result into lip sync.
The flow
POST /v1/workspaces/{workspaceId}/library ← upload the source video
GET /v1/ai/video-translate/languages ← list supported languages
POST /v1/ai/video-translate ← start translation
GET /v1/jobs/{id} ← poll until completed
Send workspace_id and the result lands in that workspace’s Library. Send project_id instead and it lands in that project’s uploads. Both are optional: omit them and the result goes to your Library, as long as the account has a single workspace.
List supported languages
output_language takes a language name, not an ISO code. Call this endpoint and use one of the id values it returns verbatim.
const API = "https://api.rendley.com/v1";
const headers = {
"Authorization": "Bearer YOUR_API_KEY",
};
const response = await fetch(`${API}/ai/video-translate/languages`, { headers });
const body = await response.json();
// Each entry is { id, name }. Pass the id straight to output_language.
for (const language of body.data) {
console.log(language.id);
}import requests
API = "https://api.rendley.com/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
response = requests.get(f"{API}/ai/video-translate/languages", headers=HEADERS)
response.raise_for_status()
# Each entry is {"id": ..., "name": ...}. Pass the id straight to output_language.
for language in response.json()["data"]:
print(language["id"])The list includes plain names like Spanish, Japanese and Portuguese, regional variants like Vietnamese (Vietnam), and two accent options for English: English - Your Accent and English - American Accent.
Translate a video
The polling helpers used below
const API_KEY = "YOUR_API_KEY";
const API = "https://api.rendley.com/v1";
const headers = {
"Authorization": `Bearer ${API_KEY}`,
"Content-Type": "application/json",
};
// Both endpoints share these three terminal statuses. They differ only in
// their in-progress names: queued/processing vs pending/running.
const TERMINAL = ["completed", "failed", "canceled"];
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// Generation and export jobs. The finished job carries output.url.
async function waitForJob(jobId, interval = 5000) {
let job = null;
while (job === null || !TERMINAL.includes(job.status)) {
await sleep(interval);
const response = await fetch(`${API}/jobs/${jobId}`, { headers });
if (!response.ok) {
throw new Error("Job lookup failed: " + response.status);
}
const body = await response.json();
job = body.data;
console.log("Job status:", job.status);
}
if (job.status !== "completed") {
throw new Error("Job " + job.status + ": " + (job.error || ""));
}
return job;
}
// Agent jobs. The finished job carries project_id, thread_id and
// last_message, but no output: export the project to get a file.
// onPause runs when an interactive run stops to ask something.
async function waitForAgentJob(jobId, interval = 5000, onPause) {
let job = null;
while (job === null || !TERMINAL.includes(job.status)) {
// This endpoint long-polls. Sleep anyway, so a fast response
// cannot turn this into a tight request loop.
await sleep(interval);
const response = await fetch(`${API}/agent/jobs/${jobId}`, { headers });
if (!response.ok) {
throw new Error("Job lookup failed: " + response.status);
}
const body = await response.json();
job = body.data;
console.log("Edit status:", job.status);
// Only interactive runs reach this; unattended runs never pause.
if (job.status === "waiting_input" && onPause) {
await onPause(job);
}
}
if (job.status !== "completed") {
throw new Error("The edit did not finish: " + (job.error || job.reason));
}
return job;
}import time
import requests
API_KEY = "YOUR_API_KEY"
API = "https://api.rendley.com/v1"
HEADERS = {"Authorization": f"Bearer {API_KEY}"}
# Both endpoints share these three terminal statuses. They differ only in
# their in-progress names: queued/processing vs pending/running.
TERMINAL = {"completed", "failed", "canceled"}
def wait_for_job(job_id, interval=5):
"""Generation and export jobs. The finished job carries output.url."""
job = None
while job is None or job["status"] not in TERMINAL:
time.sleep(interval)
response = requests.get(f"{API}/jobs/{job_id}", headers=HEADERS)
response.raise_for_status()
job = response.json()["data"]
print("Job status:", job["status"])
if job["status"] != "completed":
raise RuntimeError(f"Job {job['status']}: {job.get('error', '')}")
return job
def wait_for_agent_job(job_id, interval=5, on_pause=None):
"""Agent jobs. The finished job carries project_id, thread_id and
last_message, but no output: export the project to get a file.
on_pause runs when an interactive run stops to ask something.
"""
job = None
while job is None or job["status"] not in TERMINAL:
# This endpoint long-polls. Sleep anyway, so a fast response
# cannot turn this into a tight request loop.
time.sleep(interval)
response = requests.get(f"{API}/agent/jobs/{job_id}", headers=HEADERS)
response.raise_for_status()
job = response.json()["data"]
print("Edit status:", job["status"])
# Only interactive runs reach this; unattended runs never pause.
if job["status"] == "waiting_input" and on_pause:
on_pause(job)
if job["status"] != "completed":
raise RuntimeError("The edit did not finish: " + (job.get("error") or job.get("reason", "")))
return jobimport { readFile } from "node:fs/promises";
import { basename } from "node:path";
const SOURCE = "product-demo-en.mp4";
// A language name from GET /ai/video-translate/languages, not an ISO code.
const TARGET_LANG = "Spanish";
// Files live inside a workspace, so the upload needs one. An account
// always has at least one, and the first is the default.
async function firstWorkspaceId() {
const response = await fetch(`${API}/workspaces`, { headers });
if (!response.ok) {
throw new Error("Could not list workspaces: " + response.status);
}
const body = await response.json();
return body.data[0].id;
}
// Push the raw bytes into the media library. The response carries the
// file_hash the AI endpoints take as input.
async function upload(path, workspaceId) {
const bytes = await readFile(path);
const query = new URLSearchParams({
file_name: basename(path),
mime_type: "video/mp4",
});
const url = `${API}/workspaces/${workspaceId}/library?${query}`;
const response = await fetch(url, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/octet-stream",
},
body: bytes,
});
if (!response.ok) {
throw new Error("Upload failed: " + response.status);
}
const body = await response.json();
return body.data;
}
// Start the translation job. This returns straight away with a job id.
async function startTranslation(fileHash, outputLanguage, workspaceId) {
const response = await fetch(`${API}/ai/video-translate`, {
method: "POST",
headers: {
...headers,
"Content-Type": "application/json",
},
// To skip the upload, drop file_hash and send a public https URL
// as file_url at the top level of the body, next to params.
body: JSON.stringify({
workspace_id: workspaceId,
params: {
file_hash: fileHash,
output_language: outputLanguage,
},
}),
});
if (!response.ok) {
throw new Error("Could not start the job: " + response.status);
}
const body = await response.json();
return body.data.job_id;
}
const workspaceId = await firstWorkspaceId();
const asset = await upload(SOURCE, workspaceId);
console.log("Uploaded " + SOURCE + " -> " + asset.file_hash);
const jobId = await startTranslation(asset.file_hash, TARGET_LANG, workspaceId);
const job = await waitForJob(jobId, 10000);
// The signed URL is the result. Fetch it, pipe it to your storage,
// or hand it to the browser.
console.log(job.output.url);import os
import mimetypes
import requests
SOURCE = "product-demo-en.mp4"
# A language name from GET /ai/video-translate/languages, not an ISO code.
TARGET_LANG = "Spanish"
def first_workspace_id():
"""Files live inside a workspace. An account always has at least one,
and the first is the default."""
response = requests.get(f"{API}/workspaces", headers=HEADERS)
response.raise_for_status()
return response.json()["data"][0]["id"]
def upload(path, workspace_id):
"""Push the raw bytes into the media library. The response carries the
file_hash the AI endpoints take as input."""
mime = mimetypes.guess_type(path)[0] or "application/octet-stream"
with open(path, "rb") as f:
response = requests.post(
f"{API}/workspaces/{workspace_id}/library",
headers={**HEADERS, "Content-Type": "application/octet-stream"},
params={"file_name": os.path.basename(path), "mime_type": mime},
data=f,
)
response.raise_for_status()
return response.json()["data"]
def start_translation(file_hash, output_language, workspace_id):
"""Start the translation job. Returns straight away with a job id."""
response = requests.post(
f"{API}/ai/video-translate",
headers=HEADERS,
# To skip the upload, drop file_hash and send a public https URL
# as file_url at the top level of the body, next to params.
json={
"workspace_id": workspace_id,
"params": {
"file_hash": file_hash,
"output_language": output_language,
},
},
)
response.raise_for_status()
return response.json()["data"]["job_id"]
workspace_id = first_workspace_id()
asset = upload(SOURCE, workspace_id)
print(f"Uploaded {SOURCE} -> {asset['file_hash']}")
job_id = start_translation(asset["file_hash"], TARGET_LANG, workspace_id)
job = wait_for_job(job_id, 10)
# The signed URL is the result. Download it, or hand it straight to
# whatever consumes the video next.
video = requests.get(job["output"]["url"])
with open("product-demo-translated.mp4", "wb") as f:
f.write(video.content)
print("Saved product-demo-translated.mp4")Parameters
These go inside params. workspace_id, project_id and file_url sit at the top level of the body, next to params.
| Parameter | Type | Notes |
|---|---|---|
file_hash | string | The source video as a library or project file hash. Required unless you send a public URL as top-level file_url instead. |
output_language | string | Required. A language name from GET /v1/ai/video-translate/languages, for example Spanish. |
mode | string | speed (faster, cheaper) or precision (higher fidelity). Defaults to speed. |
precision costs twice as much per second of source video as speed.
Other models
model_id at the top level of the body selects a different model. eleven-labs-dubbing covers 100+ languages, handles overlapping speakers, and takes BCP-47 codes such as es-MX in output_language rather than language names. It returns the dubbed audio track on its own, not a video, so you place that track over the original clip yourself.
What happens under the hood
The video-translate endpoint runs a three-stage pipeline:
- Transcribe, extracts word-level timings from the source audio.
- Translate, converts the transcript to the target language while preserving timing cues.
- Text-to-speech, generates a voice-matched spoken track in the target language.
The video comes back with the dubbed audio muxed in. Lip movement is untouched, so the speaker’s mouth still matches the original language. Pass the result to lip sync if that matters for your footage.
Run each stage separately for more control. See lip sync and text-to-speech.