Rendley docs

Remove a background

Cut the subject out of footage in one call, with no green screen. Returns WebM (VP9) with alpha, ready to composite over any backdrop. The original audio track is kept.

The output container and codec are fixed. There is no MP4 or ProRes option, because MP4 carries no alpha channel.

The source video must be 60 seconds or shorter. Longer clips are rejected before any credits are spent.

The flow

POST /v1/workspaces/{workspaceId}/library   ← upload the source video
POST /v1/ai/remove-video-background         ← start the removal 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 = "speaker.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 removal job. This returns straight away with a job id.
async function startRemoval(fileHash, workspaceId) {
  const response = await fetch(`${API}/ai/remove-video-background`, {
    method: "POST",
    headers: {
      ...headers,
      "Content-Type": "application/json",
    },
    // file_hash must be a library or project upload. A public URL is
    // not accepted here, so the upload above is required.
    body: JSON.stringify({
      workspace_id: workspaceId,
      params: { file_hash: fileHash },
    }),
  });

  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 startRemoval(asset.file_hash, workspaceId);
const job = await waitForJob(jobId);

// The signed URL is the result: a WebM with a transparent background.
// 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 = "speaker.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_removal(file_hash, workspace_id):
    """Start the removal job. Returns straight away with a job id."""
    response = requests.post(
        f"{API}/ai/remove-video-background",
        headers=HEADERS,
        # file_hash must be a library or project upload. A public URL is
        # not accepted here, so the upload above is required.
        json={
            "workspace_id": workspace_id,
            "params": {"file_hash": file_hash},
        },
    )
    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_removal(asset["file_hash"], workspace_id)
job = wait_for_job(job_id)

# The signed URL is the result: a WebM with a transparent background.
video = requests.get(job["output"]["url"])

with open("speaker-no-bg.webm", "wb") as f:
    f.write(video.content)

print("Saved speaker-no-bg.webm (transparent background)")

Use cases

  • Virtual backgrounds, replace the background with a branded backdrop for corporate videos.
  • Product demos, isolate a presenter and composite them over a screen recording.
  • Social media, create transparent-background clips for TikTok and Instagram Reels overlays.
  • E-commerce, remove backgrounds from product videos for clean listings.

Also works for images

Use POST /v1/ai/remove-image-background for still images. Same flow and the same file_hash parameter, but a different model, and it returns a PNG with alpha instead of a WebM. The 60-second limit does not apply.