Upscale a video
Upscaling raises the resolution of low-quality footage. Use it on old recordings, user-generated content, or anything compressed before you publish it.
The flow
POST /v1/workspaces/{workspaceId}/library ← upload the source video
POST /v1/ai/upscale-video ← start the upscale job
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.
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 jobimport { readFile } from "node:fs/promises";
import { basename } from "node:path";
const SOURCE = "old-clip.mp4";
// 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 upscale job. This returns straight away with a job id.
async function startUpscale(fileHash, workspaceId) {
const response = await fetch(`${API}/ai/upscale-video`, {
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,
target_resolution: "4k",
target_fps: 30,
},
}),
});
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);
const jobId = await startUpscale(asset.file_hash, workspaceId);
const job = await waitForJob(jobId);
// 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 = "old-clip.mp4"
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_upscale(file_hash, workspace_id):
"""Start the upscale job. Returns straight away with a job id."""
response = requests.post(
f"{API}/ai/upscale-video",
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,
"target_resolution": "4k",
"target_fps": 30,
},
},
)
response.raise_for_status()
return response.json()["data"]["job_id"]
workspace_id = first_workspace_id()
asset = upload(SOURCE, workspace_id)
job_id = start_upscale(asset["file_hash"], workspace_id)
job = wait_for_job(job_id)
# 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("upscaled.mp4", "wb") as f:
f.write(video.content)
print("Saved upscaled.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. |
target_resolution | string | 720p, 1080p, 2k or 4k. Defaults to 4k. Lowercase k here, unlike export settings. |
target_fps | integer | 24, 30 or 60. Defaults to 30. |
scene | string | aigc, short_series, ugc, old_film or common. Defaults to aigc. Tunes the model for the kind of footage. |
Cost scales with resolution and frame rate. 4K at 60fps costs sixteen times as much per second as 720p at 30fps, so set target_resolution rather than taking the 4K default when you do not need it.
Other models
model_id at the top level of the body selects a different model. flux-video-upscale takes upscale_factor, creativity and an optional prompt instead of the parameters above. It costs more and accepts sources of at most 20 seconds.
Also available
POST /v1/ai/upscale-image upscales still images. It is a different model with its own parameters: file_hash and scale (2 or 4). Its file_hash accepts a public URL directly, since the price does not depend on the file’s duration.