Skip to content

Upscale Pro ​

FOTOhub Upscale Pro 4K is a generative upscaler for images and short video spans. It rebuilds detail instead of only interpolating pixels, up to 16.7 MP (4096 x 4096) for an image and up to 1080p for a video. It runs as a job: you start it, you are charged once, and you poll the job (an image usually finishes inside the request). A job that fails or is cancelled is refunded automatically.

Capabilities

  • Images: 2x, 3x or 4x, output up to 16.7 MP; the scale is lowered automatically when the requested one would not fit
  • Video: 2x, 3x or 4x of one span of up to 30 s and 240 frames, output up to 1920 x 1080
  • Price shown first: the free quote route tells you the applied scale, output size, ETA, billed units and whether your account can run it
  • Failed means not charged: every failure, timeout or cancel is refunded once
  • EU processing: Upscale Pro jobs are processed in the EU (processing_region: "eu")

Plan and availability

Upscale Pro is part of the Professional plan and above. On a lower plan every start answers 403 plan_required (with the plan and the upgrade path) and nothing is charged. While the feature is not released to your account, starts answer 403 feature_disabled and quote answers {"available": false, "reason": "feature_disabled", "flag_on": false}.

Endpoints ​

MethodPathChargedDescription
POST/v1/upscale/pro/imageyesUpscale an image (200 with the result when it finishes within ~55 s, else 202)
POST/v1/upscale/pro/videoyesUpscale a video span (always 202)
POST/v1/upscale/pro/quotenoPlan check, applied scale, output size, ETA, billed units, credits (video)
GET/v1/upscale/pro/jobsnoYour recent jobs, newest first
GET/v1/upscale/pro/jobs/{job_id}noStatus of one job; result once it succeeded
POST/v1/upscale/pro/jobs/{job_id}/cancelnoCancel a queued or running job; refunded at once

POST /v1/video/upscale with "quality": "pro" starts the same video job (see Video Editing).

Base URL: https://apis.fotohub.appAuthentication: Authorization: Bearer fh_live_your_api_key (see Authentication). Every route above also accepts a dashboard session.

Pricing ​

Prices are not hard-coded in this page. They come from the API:

  • An image is one unit, charged once when the job is accepted.
  • A video is charged per second of the span, rounded up to whole 10-second blocks (at least one block; a span up to 0.05 s over a block boundary is not billed as a new block). There are two video rates: output up to 1280 x 720 pixels, and output above that (up to 1080p).
  • POST /v1/upscale/pro/quote returns the price_key and the billed units for the size you send. The USD wallet rate of that key is listed by GET /v1/pricing; the charge of a started job is in its response (cost_usd, billing).

Upscale an image ​

POST /v1/upscale/pro/image
FieldTypeDefaultDescription
image_urlstringrequiredhttps:// URL or data:image/...;base64,...
scaleinteger42, 3 or 4. Lowered automatically to the largest whole scale whose output fits 16.7 MP; the job reports requested_scale, scale and clamped
waitbooleantrueWait up to ~55 s for the result and answer 200; when it is not done by then, answer 202 and poll
client_refobjectnoneYour own context, echoed back in the job. At most 2 KB as JSON

Input limits: at most 25 MB and 40 MP; each side at least 32 px. The output is limited to 16.7 MP, so a 2x upscale needs a source of at most 4.1 MP (for example 2048 x 2048). A larger source answers 422 pro_limit with code: "too_large".

bash
curl https://apis.fotohub.app/v1/upscale/pro/image \
  -H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
  -d '{"image_url": "https://example.com/product.jpg", "scale": 4}'
python
import os, time, requests

API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}

r = requests.post(f"{API}/v1/upscale/pro/image", headers=H,
                  json={"image_url": "https://example.com/product.jpg", "scale": 4}, timeout=90)
job = r.json()
if r.status_code == 202:  # not finished within ~55 s: poll
    while job["status"] in ("queued", "running"):
        time.sleep(5)
        job = requests.get(f"{API}{job['poll_url']}", headers=H, timeout=30).json()
if job["status"] == "succeeded":
    print(job["result"]["url"], job["result"]["width"], job["result"]["height"])
else:
    print(job.get("error_code"), job.get("error"), "refunded:", job.get("refunded"))
typescript
const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };

const res = await fetch(`${API}/v1/upscale/pro/image`, {
  method: "POST", headers: H,
  body: JSON.stringify({ image_url: "https://example.com/product.jpg", scale: 4 }),
});
let job: any = await res.json();
while (job.status === "queued" || job.status === "running") {
  await new Promise((r) => setTimeout(r, 5000));
  job = await (await fetch(`${API}${job.poll_url}`, { headers: H })).json();
}
console.log(job.status, job.result?.url);

Status codes: 200 finished within the wait (the body is the job with result), 202 accepted and still running, 424 the job failed or was cancelled within the wait (the body is the job with error_code, error and refunded). All three carry the charge fields.

Upscale a video ​

POST /v1/upscale/pro/video
FieldTypeDefaultDescription
video_urlstringrequiredhttps:// URL of the source video
scaleinteger22, 3 or 4. Lowered automatically to the largest whole scale whose output fits 1920 x 1080
start_secondsnumber0Where the span starts (>= 0)
max_secondsnumberto the endSpan length (> 0)
quote_creditsnumbernoneOptional. The credits of POST /v1/upscale/pro/quote you showed and confirmed. See Price check
client_refobjectnoneEchoed back in the job. At most 2 KB as JSON

Input limits:

  • The source file is at most 1 GB; each side at least 32 px.
  • One job is one span of at most 30 s and at most 240 frames (8 s of a 30 fps clip). A longer span is refused with the exact limit for that clip's frame rate (422 pro_limit, code: "too_long", limits.max_frames, limits.max_seconds), not split into several jobs: separate jobs would leave a visible seam. Use start_seconds / max_seconds to choose the span.
  • The output is at most 1920 x 1080 (long side 1920, short side 1080), so a 2x upscale needs a source of at most 960 x 540.
  • start_seconds past the end of the video answers 422 bad_input. The prepared span must stay under 95 MB, otherwise 413 too_large ("trim the clip").

The video route always answers 202 with the job (frames, seconds, billed_seconds included) and the charge fields.

bash
curl https://apis.fotohub.app/v1/upscale/pro/video \
  -H "Authorization: Bearer $FOTOHUB_API_KEY" -H "Content-Type: application/json" \
  -d '{"video_url": "https://example.com/clip-540p.mp4", "scale": 2, "start_seconds": 4, "max_seconds": 6}'
python
import os, time, requests

API = "https://apis.fotohub.app"
H = {"Authorization": f"Bearer {os.environ['FOTOHUB_API_KEY']}"}

job = requests.post(f"{API}/v1/upscale/pro/video", headers=H, json={
    "video_url": "https://example.com/clip-540p.mp4", "scale": 2,
    "start_seconds": 4, "max_seconds": 6,
}, timeout=300).json()
print("billed seconds:", job["billed_seconds"], "charged USD:", job["cost_usd"])
while job["status"] in ("queued", "running"):
    time.sleep(max(5, min(job.get("eta_s") or 15, 30)))
    job = requests.get(f"{API}{job['poll_url']}", headers=H, timeout=30).json()
print(job["status"], job.get("result", {}).get("url"))
typescript
const API = "https://apis.fotohub.app";
const H = { Authorization: `Bearer ${process.env.FOTOHUB_API_KEY}`, "Content-Type": "application/json" };

let job: any = await (await fetch(`${API}/v1/upscale/pro/video`, {
  method: "POST", headers: H,
  body: JSON.stringify({ video_url: "https://example.com/clip-540p.mp4", scale: 2, start_seconds: 4, max_seconds: 6 }),
})).json();
while (job.status === "queued" || job.status === "running") {
  await new Promise((r) => setTimeout(r, 15000));
  job = await (await fetch(`${API}${job.poll_url}`, { headers: H })).json();
}
console.log(job.status, job.result?.url);

The job ​

Every start, GET /jobs/{job_id} and a successful cancel answer the same job object:

jsonc
{
  "job_id": "3f0c6a8e-6f4e-4a8b-9a51-2f3d1c0e9b7a",
  "kind": "video",                 // "image" | "video"
  "status": "running",             // queued | running | succeeded | failed | cancelled
  "quality": "pro",
  "scale": 2,
  "requested_scale": 2,
  "clamped": false,                // true when the scale was lowered to fit
  "source_size": {"width": 960, "height": 540},
  "output_size": {"width": 1920, "height": 1080},
  "estimate_s": 952,               // the whole job
  "eta_s": 700,                    // left; null once finished
  "progress": 0.25,                // 1.0 once succeeded
  "queue_position": null,
  "created_at": "2026-10-09T10:00:00+00:00",
  "started_at": "2026-10-09T10:00:05+00:00",
  "finished_at": null,
  "client_ref": null,
  "processing_region": "eu",
  "poll_url": "/v1/upscale/pro/jobs/3f0c6a8e-6f4e-4a8b-9a51-2f3d1c0e9b7a",
  "frames": 180,                   // video only
  "seconds": 6.0,                  // video only
  "billed_seconds": 10             // video only
  // start responses add the charge fields: cost_usd, currency, billing
}
  • result (once status is succeeded): url, storage_path, bucket, mime_type, width, height, size_bytes, scale; a video adds duration_s and frames.
  • On failed / cancelled: error_code, error (a sentence for the user) and refunded (true once the money is back). error_code is one of engine_busy, engine_failed, engine_rejected, timeout, lost, bad_result, storage, interrupted, cancelled.
  • Charge fields (start responses only): cost_usd, currency: "USD" and billing (cost_usd, balance_usd, currency, method, model: "prepaid").

When a job succeeds, a image.upscale.pro.completed or video.upscale.pro.completed webhook event is sent with job_id, url, width and height.

List and cancel ​

GET /v1/upscale/pro/jobs returns {"jobs": [...]}, newest first. Query: active=true (unfinished only), kind=image|video, limit (1-50, default 20).

POST /v1/upscale/pro/jobs/{job_id}/cancel stops a queued or running job and refunds it at once; the answer is the job with status: "cancelled". A job that already finished, or is being delivered, answers 409:

json
{"detail": {"error": "not_cancellable", "status": "succeeded",
            "message": "This upscale has already finished or is being delivered."}}

An unknown id, or a job of another account, answers 404 {"detail": "Job not found"}.

Quote ​

POST /v1/upscale/pro/quote

Free. Send the size you are about to upscale; nothing is fetched and nothing is charged. The example below is a 640 x 360, 24 fps, 10 s span at scale: 2.

FieldTypeDefaultDescription
kind"image" | "video"required
width, heightintegerrequiredSource size in pixels, 1-100000
scaleinteger42, 3 or 4
fpsnumbernoneVideo only (required for a video quote), > 0 and <= 1000
secondsnumbernoneVideo only (required for a video quote): span length, > 0 and <= 86400
json
{
  "flag_on": true,
  "plan_ok": true,
  "required_plan": "professional",
  "upgrade_url": "/generate/prices",
  "kind": "video",
  "price_key": "<key to look up in GET /v1/pricing>",
  "processing_region": "eu",
  "limits": {"max_vid_frames": 240, "max_vid_long_side": 1920, "max_vid_short_side": 1080,
             "max_vid_out_px": 2073600, "max_img_out_px": 16777216},
  "scale": 2,
  "clamped": false,
  "frames": 240,
  "output_size": {"width": 1280, "height": 720},
  "units": 10,
  "billed_seconds": 10,
  "estimate_s": 572,
  "eligible": true,
  "available": true,
  "credits": 55.0
}
  • available is true only when your plan includes Upscale Pro, the size is eligible and the service is up. Otherwise reason says why: plan_required, engine_unavailable or pro_limit.
  • eligible: false comes with refusal: {code, message, limits}, the same codes a start would answer (too_small, too_large, too_long, bad_scale, unreadable). A video quote without fps and seconds is unreadable.
  • limits are the capacity the job would be planned with right now. The defaults are shown above; they can be higher at times, so read them rather than hard-coding them.
  • cold_start: true appears when the model has to load first; estimate_s already includes it.
  • credits (video only, when available) is the price of this span in FOTOhub credits, or null when the account pays only from the USD wallet. It is the number to send back as quote_credits.

Price check ​

The video quote prices the size and length you report; the start measures the real file. Send the quote's credits as quote_credits on POST /v1/upscale/pro/video (or POST /v1/video/upscale with quality: "pro") and the start is refused when the measured span costs more than that figure. A lower measured price is simply charged as measured. Without quote_credits there is no check, and when the account has no credit price the check is skipped.

The refusal is 409, before any charge:

json
{
  "detail": {
    "error": "price_changed",
    "message": "The price changed since it was shown: this video is longer or larger than quoted. Check the new price. Your wallet was not charged for this request.",
    "quoted_credits": 55.0,
    "current_credits": 110.0,
    "billed_seconds": 20
  }
}

Quote again, show the new price, and start again with the new quote_credits. See also Price changed (409).

Errors ​

Every refusal before the charge says so in its message ("Your wallet was not charged for this request.").

StatuserrorWhen
400bad_input, unreadableThe URL could not be fetched or is not https (video), the base64 is invalid, the file is not a readable image or video, or the video has no video stream
400url_blockedThe URL is not a public https URL (see URL refused)
402insufficient_fundsThe wallet cannot cover the charge (see 402 Payment Required)
403feature_disabledUpscale Pro is not released to your account yet: {"error": "feature_disabled", "flag": "<feature id>"}
403plan_requiredYour plan does not include it: required_plan, upgrade_url, feature, message
409host_capacityThe span or image needs more capacity than is available right now: code, limits, charged: false. Try again later or choose a shorter span
409price_changedThe measured video span costs more than quote_credits (see above)
409not_cancellableCancel of a finished job
413too_largeImage over 25 MB or 40 MP, video over 1 GB, or a prepared span over 95 MB
422pro_limitThe input cannot be upscaled as asked: code (too_small, too_large, too_long, bad_scale, unreadable), message, limits
422bad_inputstart_seconds is past the end of the video
424(job)Image only, with wait: true: the job failed or was cancelled within the wait; refunded
429engine_busyThe upscaler is busy: retry_after_s and a Retry-After header
503engine_unavailable, plan_unavailableThe upscaler or the plan check is temporarily unavailable

The 409 host_capacity, 422 pro_limit and 429 engine_busy bodies have this shape:

json
{
  "detail": {
    "error": "pro_limit",
    "message": "Pro 4K video handles up to 240 frames per job, i.e. 8 s of this 30 fps clip (this span is 12.0 s, 360 frames). Trim the clip or use Standard. Your wallet was not charged for this request.",
    "code": "too_long",
    "limits": {"max_frames": 240, "max_seconds": 8.0, "fps": 30.0, "frames": 360}
  }
}