GPT Image 2.5 is live — OpenAI's newest image model, targeted edits that leave the rest of the frame alone
Upscale Video with Python: Batch API Pipeline and Costs
2026/08/14 ·

Upscale Video with Python: Batch API Pipeline and Costs

Upscale video with Python at batch scale: an 80-line pipeline with rate-limit budgeting, cost estimates from $0.002/s, and crash-safe resume on a video API.

To upscale video with Python at any real volume, the problem is not the API call. One clip is a POST and a polling loop, twenty lines, done. The problem is the hundred-clip version: staying under the rate limit while polling, knowing the bill before you submit, and resuming a batch that died at clip 61 without paying for clips 1 through 60 again.

This guide builds that pipeline against reAPI's video enhancement endpoints, where Topaz Video Upscaler runs $0.044/s of source footage on the standard tier and enhance-video-1.0 starts at $0.002054/s[1][2]. Every number and contract detail below comes from the models' live pages in August 2026. If you want the tool comparison instead (open source, desktop, API), that is a separate guide: how to upscale video with AI.

TL;DR

  • The API contract is batch-friendly by design: async submit returns a task id immediately, billing is per second of server-probed source footage, and failed tasks refund automatically, so rerunning a crashed batch never pays twice for a task it already recorded[1].
  • Two limits shape the design: 50 requests/second per user, polling included, and at most 100 tasks processing at once per user[3][4]. The pipeline keeps 20 tasks open and polls them every 5 seconds, about 4 req/s, so one pipeline stays clear of both; several scripts sharing one key can still hit them, which is why it treats HTTP 429 as "try again next pass".
  • Estimate before you submit: 100 one-minute clips cost $264 through Topaz standard, $24.64 through enhance-video-1.0 at standard 1080p[1][2]. The tier decision is worth ten times more than any code optimization.
  • Persist task ids to disk as you submit. Resume then costs nothing: completed tasks are already paid and their output URLs still resolve.
  • The full pipeline is about 80 lines, standard library plus requests.

What the API contract gives a batch job

Three contract details shape the whole design, all from the model's own reference[1]:

Submit is asynchronous. POST /api/v1/videos/generations returns {"id", "status": "processing"} immediately; the render happens server-side and you poll GET /api/v1/tasks/<id> until status is completed or failed. A batch is therefore a set of open tasks you track, not a queue you block on.

Billing follows the source clip, not the output. The platform probes the source video's length server-side and bills per second of it: $0.044/s standard or $0.077/s max for Topaz upscaling, $0.002054/s to $0.016429/s for enhance-video's standard tier depending on target resolution[1][2]. That makes cost a pure function of footage duration, which is why the estimator below works.

Failures refund themselves. Credits are reserved at submit and refunded in full when a task fails[1]. The client never needs compensation logic; it only needs to record what failed and decide whether to resubmit.

Estimate the bill before you submit

Duration is the only input that matters. If the clips are local before upload, ffprobe reads it in one call; if they are already hosted, take durations from your own metadata.

import math, subprocess, json

def probe_seconds(path: str) -> float:
    out = subprocess.run(
        ["ffprobe", "-v", "quiet", "-print_format", "json",
         "-show_format", path],
        capture_output=True, text=True, check=True,
    )
    return float(json.loads(out.stdout)["format"]["duration"])

RATES = {                     # $ per second of source, August 2026
    "topaz-standard": 0.044,
    "topaz-max": 0.077,
    "enhance-720p": 0.002054,
    "enhance-1080p": 0.004107,
    "enhance-4k": 0.016429,
}

def estimate(paths, rate_key):
    # Billed in whole seconds per clip: Topaz rounds up, enhance-video
    # rounds down, so ceil() keeps the estimate on the safe side.
    total = sum(math.ceil(probe_seconds(p)) for p in paths)
    return total, total * RATES[rate_key]

What that arithmetic looks like for 100 one-minute clips:

TierRate100 × 60 s
enhance-video-1.0, standard 720p$0.002054/s$12.32[2]
enhance-video-1.0, standard 1080p$0.004107/s$24.64[2]
enhance-video-1.0, standard 4K$0.016429/s$98.57[2]
Topaz Video Upscaler, standard$0.044/s$264.00[1]
Topaz Video Upscaler, max$0.077/s$462.00[1]

These totals are before per-task rounding: each task is billed in whole credits ($0.001), rounded up, which adds less than $0.001 per clip and under $0.10 to the 100-clip batch.

The 37× spread between the cheapest and most expensive row is the real optimization surface. No amount of Python tightens a bill the way choosing the right tier does; the selection logic is simple enough to encode as a rule (clean source going up in resolution: enhance tier; degraded source needing detail recovery: Topaz).

Upscale video with Python: an 80-line batch pipeline

The design: keep a bounded number of tasks open, top the batch up as tasks finish, and persist every task id to disk the moment it exists. State lives in one JSON file keyed by source URL.

import json, pathlib, time
import requests

API = "https://reapi.ai/api/v1"
HEADERS = {"Authorization": "Bearer YOUR_API_KEY"}
STATE = pathlib.Path("batch_state.json")
MAX_OPEN = 20         # the platform allows 100 processing tasks per user
POLL_INTERVAL = 5     # one pass over <=20 open tasks every 5 s = 4 req/s

def load_state():
    return json.loads(STATE.read_text()) if STATE.exists() else {}

def save_state(state):
    STATE.write_text(json.dumps(state, indent=2))

def submit(video_url, state):
    r = requests.post(f"{API}/videos/generations", headers=HEADERS, json={
        "model": "topaz-video-upscaler",
        "video_url": video_url,
        "upscale_factor": "2",
    }, timeout=30)
    if r.status_code == 429:
        # 50002: 100 tasks already processing (another script on this key?)
        # 50001: over 50 requests/second. Nothing was created or charged.
        return False
    r.raise_for_status()
    state[video_url] = {"task_id": r.json()["id"], "status": "processing"}
    save_state(state)               # persist before moving on
    return True

def check(video_url, state):
    task_id = state[video_url]["task_id"]
    r = requests.get(f"{API}/tasks/{task_id}", headers=HEADERS, timeout=30)
    if r.status_code == 429:        # over the rate limit: next pass
        return
    r.raise_for_status()
    body = r.json()
    if body["status"] == "completed":
        state[video_url].update(status="completed", output=body["output"])
    elif body["status"] == "failed":
        state[video_url].update(status="failed", error=body.get("error"))
    else:
        return
    save_state(state)

def run(video_urls):
    state = load_state()            # a rerun skips completed and open tasks
    pending = [u for u in dict.fromkeys(video_urls)  # drop duplicate URLs
               if state.get(u, {}).get("status") in (None, "failed")]
    while True:
        open_urls = [u for u, e in state.items() if e["status"] == "processing"]
        while pending and len(open_urls) < MAX_OPEN:
            if not submit(pending[0], state):
                break               # limit reached: retry on the next pass
            open_urls.append(pending.pop(0))
            time.sleep(0.25)        # submits: at most 4/s
        if not open_urls and not pending:
            break
        time.sleep(POLL_INTERVAL)
        for url in open_urls:
            check(url, state)

    done = sum(1 for e in state.values() if e["status"] == "completed")
    failed = [u for u, e in state.items() if e["status"] == "failed"]
    print(f"{done} completed, {len(failed)} failed")
    for u in failed:
        print("FAILED:", u, state[u].get("error"))

run([
    "https://your-cdn.com/clip-001.mp4",
    "https://your-cdn.com/clip-002.mp4",
])

Swap the payload for enhance-video-1.0 (tool_version, scene, resolution instead of upscale_factor) and nothing else changes[2]. Source URLs must be public HTTPS; base64 uploads are rejected platform-wide.

The rate-limit budget, explicitly

Each user gets two budgets, and both are shared by every script and key on the account[3][4]:

  • 50 requests/second, polling included. Exceeding it returns HTTP 429 with code 50001 and a Retry-After header.
  • 100 tasks processing at once. One more submit returns HTTP 429 with code 50002 until a running task finishes.

Neither 429 creates a task or charges credits, so the pipeline simply leaves the clip in pending and tries again on the next pass. Its own spend:

  • Submits: at most 4/s, and only while fewer than 20 tasks are open.
  • Polling: one pass over at most 20 open tasks every 5 seconds, about 4 req/s on average; the busiest second is one 20-request pass plus a few submits.
  • Headroom: 80 task slots and most of the 50 req/s left for other scripts, a dashboard, or manual curl checks.

A higher MAX_OPEN does not make any single render faster; render time is server-side. It only raises how many clips render side by side, so going up to 100 shortens a large batch, but it leaves no room for anything else on the same key, and the polling rate grows with it (100 open tasks at a 5-second interval is 20 req/s). Keep MAX_OPEN below the 100-task cap by however many tasks your other scripts may hold open.

Handling failures without double-paying

The platform's failure contract does the heavy lifting: a failed task refunds its reserved credits in full, automatically[1]. The pipeline's job reduces to bookkeeping:

  1. Record the failure, including the error object with its code, message, and request_id (the error-code reference lives in the API docs[3]).
  2. Do not auto-resubmit blindly. A clip that failed because the source URL 404s will fail again at the same cost of zero, but a wall of retries burns your rate budget. Fix the input, then rerun the script: it resubmits the clips that failed and skips everything already completed or still processing.
  3. Trust resume. Completed entries keep their output URLs, which point at CDN-rehosted files that do not expire[1], so a batch interrupted at clip 61 restarts with 60 paid results intact and only the remainder outstanding.
  4. Check before resubmitting after a lost response. If a submit timed out without a response, the task may still have been created and billed: submits are not deduplicated. Look for it in the dashboard's Generation Logs before rerunning, and if it is there, add its id to batch_state.json by hand.

FAQ

How do I upscale video with Python for free?

Not through a hosted API; rendering costs someone GPU time. The free route is running open-source upscalers like video2x or Real-ESRGAN on your own GPU, which trades money for hardware and setup, covered in our tool comparison. The API route starts at $0.002054 per source second[2], and signup credits cover the first test calls.

How many clips can I process in parallel?

Up to 100 tasks can be processing at once per user, and all requests together, submits plus polling, must stay under 50 per second[3][4]. Submitting beyond 100 returns HTTP 429 with code 50002, so keep a queue and top it up as tasks finish, as the pipeline above does with 20 open tasks.

What does it cost to upscale 100 videos?

Duration decides. At one minute per clip: from $12.32 (enhance-video, 720p) to $462 (Topaz max)[1][2]. Run the estimator on real durations before submitting; it is four lines of ffprobe.

Can I resume a batch after my script crashes?

Yes, if task ids were persisted at submit time. Completed tasks stay completed and paid, their output URLs remain valid, and failed tasks were already refunded[1]. The state-file pattern above makes resume the default behavior rather than a recovery feature.

Should I upscale to 4K in a batch job?

Only when the destination screen demands it. The 4K tier costs 8× the 720p tier on enhance-video[2], and social feeds re-compress uploads anyway. A common pattern is batching everything at 1080p and re-running the handful of hero clips at 4K.

How do I know the source duration the platform will bill?

The platform probes the hosted file server-side and bills its real length[1]. Local ffprobe on the same file gives the same number; discrepancies mean the hosted copy differs from the local one, which is worth catching before submitting a hundred of them.

Running the first batch

Start with three clips, not a hundred: one clean, one degraded, one long. That run validates the state file, shows real per-clip costs against the estimate, and surfaces input problems while they cost cents. Then point the script at the full list and let the refund contract and the state file absorb whatever goes wrong. The whole point of doing this in Python is that to upscale video with Python a second time, the command is just rerunning the script, and the second run only pays for what the first one did not finish.

References

  1. reAPI. Topaz Video Upscaler — model page: live pricing, task lifecycle, rate limits. Retrieved August 2026 from reapi.ai/models/topaz-video-upscaler
  2. reAPI. Enhance Video 1.0 — model page: live per-second tier pricing. Retrieved August 2026 from reapi.ai/models/enhance-video-1-0
  3. reAPI. API error codes reference. Retrieved October 2026 from reapi.ai/docs/api/errors
  4. reAPI. API reference: rate limits. Retrieved October 2026 from reapi.ai/docs/api

Further reading