Rendley docs

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 job
import { 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.

ParameterTypeNotes
file_hashstringThe source video as a library or project file hash. Required unless you send a public URL as top-level file_url instead.
target_resolutionstring720p, 1080p, 2k or 4k. Defaults to 4k. Lowercase k here, unlike export settings.
target_fpsinteger24, 30 or 60. Defaults to 30.
scenestringaigc, 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.