Resizing and converting user uploads the moment they arrive
Upload once, derive the sizes you serve, and let format auto-negotiate. The fit modes that decide whether faces get cropped, and the chain that avoids re-uploads.
A user uploads a 12-megapixel phone photo and your product needs a 400-pixel avatar and an 800-pixel card image. On Infrai that’s POST /v1/image/upload once, then POST /v1/image/resize against the returned image_id for each size you serve — the bytes never come back through your process between steps, which is what keeps a small server able to handle large uploads.
The API is simple. fit is the field that decides whether your users’ faces survive.
Upload once
curl -sS -X POST "https://api.infrai.cc/v1/image/upload" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"file": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...", "filename": "profile.jpg"}'
{
"ok": true,
"data": {
"image_id": "img_2fVc8nRqLmT4xBzY",
"url": "https://files.infrai.cc/image/2fVc8nRqLmT4xBzY.jpg",
"format": "jpeg",
"width": 4032,
"height": 3024,
"size_bytes": 3891200,
"sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
"original_sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
"ops_applied": [],
"created_at": "2026-09-21T03:50:00Z"
}
}
width and height come back immediately, which is the cheapest validation you’ll get: a 200×200 upload for a hero image is a problem you want to report at upload time, not when the page looks wrong.
original_sha256 tracks back to what the user actually sent, and it stays constant through derivations — useful for deduplicating the same photo uploaded twice.
Derive the sizes you serve
curl -sS -X POST "https://api.infrai.cc/v1/image/resize" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"image": "img_2fVc8nRqLmT4xBzY",
"width": 400,
"height": 400,
"fit": "cover",
"format": "auto",
"store": true
}'
fit accepts cover, contain, fill, inside and outside, and the difference matters for anything with a subject in it.
fit | Behaviour | Use for |
|---|---|---|
cover | fills the box, crops the overflow | avatars, card thumbnails |
contain | fits inside, letterboxes | product shots where nothing may be cut |
fill | stretches to the box | almost never — it distorts |
inside | shrinks to fit, never enlarges | bounding a maximum size |
outside | covers the box, may exceed it | when you crop afterwards |
fill is the one to avoid.
It produces a technically correct size and a squashed face, and it is nonetheless the mode people reach for first, because it is the only one that always returns exactly the dimensions asked for — which makes it the mode that passes every automated test about image size and fails every human looking at the result, and that combination is why it survives in codebases far longer than it should.
format: "auto" lets the platform pick a modern format — WebP or AVIF where appropriate — which is usually a large size saving over the original JPEG for no work.
The upload handler
import base64
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
RENDITIONS = [
{"name": "avatar", "width": 400, "height": 400, "fit": "cover"},
{"name": "card", "width": 800, "height": 450, "fit": "cover"},
{"name": "full", "width": 1600, "height": 1600, "fit": "inside"},
]
MIN_DIMENSION = 400
def upload(raw: bytes, filename: str) -> dict:
resp = SESSION.post(
f"{API}/v1/image/upload",
json={"file": base64.b64encode(raw).decode("ascii"), "filename": filename},
timeout=180,
)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
return body["data"]
def derive(image_id: str, spec: dict) -> dict:
resp = SESSION.post(
f"{API}/v1/image/resize",
json={"image": image_id, "width": spec["width"], "height": spec["height"],
"fit": spec["fit"], "format": "auto", "store": True},
timeout=120,
)
resp.raise_for_status()
return resp.json()["data"]
def ingest(raw: bytes, filename: str) -> dict:
"""Upload once, derive many. Each derivation takes the image_id rather than the
bytes, so a 4 MB photo crosses the wire once instead of four times."""
original = upload(raw, filename)
if min(original["width"], original["height"]) < MIN_DIMENSION:
return {"rejected": f"{original['width']}x{original['height']} is below "
f"{MIN_DIMENSION}px on the short edge"}
out = {"original_id": original["image_id"], "original_sha256": original["original_sha256"],
"renditions": {}}
for spec in RENDITIONS:
derived = derive(original["image_id"], spec)
out["renditions"][spec["name"]] = {
"image_id": derived["image_id"], "url": derived["url"],
"format": derived["format"], "bytes": derived["size_bytes"],
"dimensions": f"{derived['width']}x{derived['height']}",
}
return out
if __name__ == "__main__":
with open(os.environ["PHOTO_PATH"], "rb") as handle:
print(ingest(handle.read(), os.path.basename(os.environ["PHOTO_PATH"])))
Rejecting an under-sized upload before deriving anything is the cheap check worth having. Three derivations of a 200-pixel image are three calls producing three blurry results.
One call for several operations
When a rendition needs more than a resize, POST /v1/image/process takes an ordered ops array and applies them in one pass — cheaper and faster than chaining separate calls:
curl -sS -X POST "https://api.infrai.cc/v1/image/process" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"image": "img_2fVc8nRqLmT4xBzY",
"ops": [{"resize": {"width": 800, "height": 450, "fit": "cover"}}, {"compress": {"quality": 82}}],
"format": "auto",
"store": true
}'
Order matters: resize before compressing, because compressing a 4032-pixel image and then shrinking it wastes the compression work and produces artefacts the resize then blurs.
Where the renditions live
store: true keeps each result addressable with an image_id and url. For long-lived assets, copy into your own bucket with PUT /v1/storage/object/put/{bucket}/{key} and store your own path — same rule as any generated artefact, and one call on the same credential.
That single credential is the practical argument: the upload, the derivations, the bucket they land in, the moderation pass if you need one and the bill for all of it are one account and one GET /v1/account/usage, rather than an image vendor plus a storage vendor with separate invoices for the same user action.
Limitations
There’s no URL-based transformation: you can’t put parameters in a path and have the image rendered on request, which is how Cloudinary and imgix work and is genuinely more convenient for a front end that wants arbitrary sizes. Here you derive renditions ahead of time and serve them, so a size you didn’t anticipate needs a call rather than a URL change.
Some operations report available: false until a verified vendor credential backs them — check GET /v1/discovery/image.moderate and friends before designing around them. And there’s no CDN in front of the output, so delivery is your own concern.
ImageKit and imgix are the better fit if on-the-fly URL transformation is what you want. Resize and convert bill per call with the live figures in GET /v1/discovery/image.resize (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.