Auto-caption a video
Send a video and describe the captions you want. The agent transcribes the audio, builds the caption clips, and places them on the timeline.
The flow
POST /v1/agent ← prompt + video URL, returns a job
GET /v1/agent/jobs/{id} ← poll until the status is terminal
POST /v1/export ← render the captioned video to MP4
Writing the prompt
The prompt is the only steering the agent gets, so name the file and say how the captions should look. Vague prompts produce default styling.
A prompt that works:
Transcribe interview.mp4 and add captions.
Use bold white text with a black outline so they stay readable on light footage.
Worth covering:
- Which file. Name it as it appears in the URL,
interview.mp4here. Files land in the media library, not on the timeline, so the agent needs to be told what to caption. - Style. Weight, color, outline or background. Say what has to stay legible.
- Anything else that matters. Position on the frame, or how many words to group per caption. Leave it out and the agent picks a sensible default.
Add the language only when you want a translation. Transcription detects the source language on its own.
The script
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 jobconst PROMPT = [
"Transcribe interview.mp4 and add captions.",
"Use bold white text with a black outline so they stay readable on light footage.",
].join(" ");
// Start the edit. This returns immediately, before any work happens.
async function startCaptionJob() {
const response = await fetch(`${API}/agent`, {
method: "POST",
headers,
body: JSON.stringify({
prompt: PROMPT,
files: [{ url: "https://cdn.example.com/interview.mp4" }],
}),
});
if (!response.ok) {
throw new Error("Could not start the edit: " + response.status);
}
const body = await response.json();
return body.data;
}
// Render the finished project to an MP4.
async function exportProject(projectId) {
const response = await fetch(`${API}/export`, {
method: "POST",
headers,
body: JSON.stringify({
project_id: projectId,
settings: { target_resolution: "1080p", codec: "h264" },
}),
});
const body = await response.json();
return body.data.job_id;
}
const started = await startCaptionJob();
console.log("Job:", started.job_id, "Project:", started.project_id);
const edit = await waitForAgentJob(started.job_id);
console.log("Agent summary:", edit.last_message);
const exportJobId = await exportProject(edit.project_id);
const finished = await waitForJob(exportJobId);
// The signed URL is the result. Fetch it, pipe it to your storage,
// or hand it to the browser.
console.log(finished.output.url);import requests
PROMPT = (
"Transcribe interview.mp4 and add captions. "
"Use bold white text with a black outline so they stay readable on light footage."
)
def start_caption_job():
"""Start the edit. Returns immediately, before any work happens."""
response = requests.post(
f"{API}/agent",
headers=HEADERS,
json={
"prompt": PROMPT,
"files": [{"url": "https://cdn.example.com/interview.mp4"}],
},
)
response.raise_for_status()
return response.json()["data"]
def export_project(project_id):
"""Render the finished project to an MP4."""
response = requests.post(
f"{API}/export",
headers=HEADERS,
json={
"project_id": project_id,
"settings": {"target_resolution": "1080p", "codec": "h264"},
},
)
response.raise_for_status()
return response.json()["data"]["job_id"]
started = start_caption_job()
print("Job:", started["job_id"], "Project:", started["project_id"])
edit = wait_for_agent_job(started['job_id'])
print("Agent summary:", edit["last_message"])
export_job_id = export_project(edit["project_id"])
finished = wait_for_job(export_job_id)
# The signed URL is the result. Fetch it, pipe it to your storage,
# or hand it to the browser.
print(finished["output"]["url"])# 1. Start the edit.
curl -X POST https://api.rendley.com/v1/agent \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"prompt": "Transcribe interview.mp4 and add captions. Use bold white text with a black outline.",
"files": [{ "url": "https://cdn.example.com/interview.mp4" }]
}'
# 2. Poll the job until "status" is completed, failed or canceled.
curl https://api.rendley.com/v1/agent/jobs/YOUR_JOB_ID \
-H "Authorization: Bearer YOUR_API_KEY"
# 3. Export the finished project.
curl -X POST https://api.rendley.com/v1/export \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"project_id": "YOUR_PROJECT_ID",
"settings": { "target_resolution": "1080p", "codec": "h264" }
}'
# 4. Poll the export job. The finished job carries the download URL.
curl https://api.rendley.com/v1/jobs/YOUR_EXPORT_JOB_ID \
-H "Authorization: Bearer YOUR_API_KEY"Transcribing without editing
To get the transcript itself rather than a captioned video, call the transcription model directly. It returns word-level timings you can render however you like.
const response = await fetch(`${API}/ai/transcribe`, {
method: "POST",
headers,
// A public URL goes in file_url at the top level. A library or project
// file hash goes in params.file_hash instead.
body: JSON.stringify({
file_url: "https://cdn.example.com/interview.mp4",
params: {},
}),
});
const body = await response.json();
const jobId = body.data.job_id;
const job = await waitForJob(jobId);
// Transcription returns its result inline, not as a file. result_data is a
// JSON string holding the detected language and word-level timings.
const transcript = JSON.parse(job.result_data);
for (const word of transcript.words) {
console.log(word.start, word.end, word.word);
}import json
import requests
# A public URL goes in file_url at the top level. A library or project
# file hash goes in params.file_hash instead.
response = requests.post(
f"{API}/ai/transcribe",
headers=HEADERS,
json={"file_url": "https://cdn.example.com/interview.mp4", "params": {}},
)
response.raise_for_status()
job_id = response.json()["data"]["job_id"]
job = wait_for_job(job_id)
# Transcription returns its result inline, not as a file. result_data is a
# JSON string holding the detected language and word-level timings.
transcript = json.loads(job["result_data"])
for word in transcript["words"]:
print(word["start"], word["end"], word["word"])The source is either a public URL in top-level file_url, or a library or project file hash in params.file_hash. Send one or the other.
Transcription is the one AI action whose result is not a file, so the finished job has no output. Read result_data instead. It is a JSON string, so parse it before use.