Generate videos in bulk
One video per row, each with its own name, product or message. This script fans out agent jobs in parallel, polls them all, and downloads every finished MP4.
Use it for personalized outreach at scale, batch product videos, or templated social content.
The approach
There is no batch endpoint. Start several agent jobs at once and poll them concurrently. Each runs independently, so ten videos take roughly as long as one.
POST /v1/agent ← start job 1
POST /v1/agent ← start job 2
POST /v1/agent ← start job 3 …
GET /v1/agent/jobs/{id} ← poll all until done
POST /v1/export ← export each project
The script
This generates a personalized product demo for each customer in a list:
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 { writeFile } from "node:fs/promises";
// Each entry becomes a separate video.
const CUSTOMERS = [
{ name: "Acme Corp", product: "Analytics Dashboard", cta: "Start your free trial" },
{ name: "Globex Inc", product: "Workflow Automation", cta: "Book a demo today" },
{ name: "Initech", product: "Team Collaboration", cta: "See it in action" },
];
// One prompt per row, built from a fixed template.
function buildPrompt(customer) {
return [
"Create a 30-second product demo video.",
"Open with the text '" + customer.name + "' as a title card.",
"Show the product name '" + customer.product + "' with a sleek animation.",
"End with a call-to-action: '" + customer.cta + "'.",
"Use professional background music.",
].join(" ");
}
// Start one agent run. Returns before any editing happens.
async function startJob(customer) {
const response = await fetch(`${API}/agent`, {
method: "POST",
headers,
body: JSON.stringify({ prompt: buildPrompt(customer) }),
});
if (!response.ok) {
throw new Error("Could not start a job: " + response.status);
}
const body = await response.json();
const data = body.data;
return { ...customer, job_id: data.job_id, project_id: data.project_id };
}
// Render one finished project and save the MP4 beside this script.
async function exportAndDownload(name, job) {
const response = await fetch(`${API}/export`, {
method: "POST",
headers,
body: JSON.stringify({ project_id: job.project_id }),
});
const body = await response.json();
let exportJob;
try {
exportJob = await waitForJob(body.data.job_id);
} catch (error) {
console.log(" Export failed for " + name + ": " + error.message);
return;
}
// The finished job carries a signed URL. Download it before it expires.
const file = await fetch(exportJob.output.url);
if (!file.ok) {
console.log(" Download failed for " + name + ": " + file.status);
return;
}
const filename = name.toLowerCase().replace(/ /g, "-") + ".mp4";
const bytes = Buffer.from(await file.arrayBuffer());
await writeFile(filename, bytes);
console.log(" Saved " + filename);
}
// 1. Fan out: start all jobs at once.
console.log("Starting " + CUSTOMERS.length + " jobs...");
const jobs = await Promise.all(CUSTOMERS.map(startJob));
// 2. Poll in parallel, so the batch takes about as long as its slowest row.
// allSettled, not all: waitForAgentJob throws on a failed edit, and one bad
// prompt should not take the whole batch down with it.
console.log("Waiting for edits to finish...");
const results = await Promise.allSettled(
jobs.map((row) => waitForAgentJob(row.job_id)),
);
// 3. Export and download each video.
console.log("Exporting videos...");
for (const [i, result] of results.entries()) {
const name = jobs[i].name;
if (result.status === "rejected") {
console.log(" Skipping " + name + ": " + result.reason.message);
continue;
}
await exportAndDownload(name, result.value);
}
console.log("Done!");import concurrent.futures
import requests
# Each entry becomes a separate video.
CUSTOMERS = [
{"name": "Acme Corp", "product": "Analytics Dashboard", "cta": "Start your free trial"},
{"name": "Globex Inc", "product": "Workflow Automation", "cta": "Book a demo today"},
{"name": "Initech", "product": "Team Collaboration", "cta": "See it in action"},
]
TEMPLATE_PROMPT = (
"Create a 30-second product demo video. "
"Open with the text '{name}' as a title card. "
"Show the product name '{product}' with a sleek animation. "
"End with a call-to-action: '{cta}'. "
"Use professional background music."
)
def start_job(customer):
"""Start one agent run. Returns before any editing happens."""
prompt = TEMPLATE_PROMPT.format(**customer)
response = requests.post(
f"{API}/agent",
headers=HEADERS,
json={"prompt": prompt},
)
response.raise_for_status()
data = response.json()["data"]
return {**customer, "job_id": data["job_id"], "project_id": data["project_id"]}
def export_and_download(name, job):
"""Render one finished project and save the MP4 beside this script."""
response = requests.post(
f"{API}/export",
headers=HEADERS,
json={"project_id": job["project_id"]},
)
response.raise_for_status()
export_job_id = response.json()["data"]["job_id"]
try:
export_job = wait_for_job(export_job_id)
except Exception as error:
print(f" Export failed for {name}: {error}")
return
# The finished job carries a signed URL. Stream it straight to disk.
filename = f"{name.lower().replace(' ', '-')}.mp4"
with requests.get(export_job["output"]["url"], stream=True) as download:
download.raise_for_status()
with open(filename, "wb") as f:
for chunk in download.iter_content(1 << 16):
f.write(chunk)
print(f" Saved {filename}")
# 1. Fan out: start all jobs at once.
print(f"Starting {len(CUSTOMERS)} jobs...")
jobs = [start_job(customer) for customer in CUSTOMERS]
print("All jobs queued.")
# 2. Poll in parallel, so the batch takes about as long as its slowest row.
# submit rather than map: wait_for_agent_job raises on a failed edit, and
# map would surface that immediately and drop the remaining results.
# POLL_WORKERS caps the threads, not the jobs already running on Rendley.
print("Waiting for edits to finish...")
POLL_WORKERS = min(len(jobs), 8)
with concurrent.futures.ThreadPoolExecutor(max_workers=POLL_WORKERS) as pool:
futures = [pool.submit(wait_for_agent_job, row["job_id"]) for row in jobs]
# 3. Export and download each video.
print("Exporting videos...")
for row, future in zip(jobs, futures):
try:
job = future.result()
except Exception as error:
print(f" Skipping {row['name']}: {error}")
continue
export_and_download(row["name"], job)
print("Done!")Scaling tips
- Concurrency. Jobs run in parallel, but each account has a cap that depends on its plan. Going over it returns
429withAGENT_CONCURRENCY_LIMIT_REACHED. Keep a fixed number in flight and start the next job as one finishes, rather than firing every row at once. - Reuse media. Footage that appears in several videos belongs in the library. Upload it once, then pass
{ "media_id": "..." }infileson each job instead of the same URL every time. An entry needs aurl, astorage_urlor amedia_id. - Templates. Structure your prompts around variables (name, product, CTA) so every video follows a consistent layout.
- Error handling. Jobs are independent, so check each one’s status before exporting it rather than assuming the batch succeeded as a whole.