Square avatar crops with sharp, stored as a size set in private storage
A working recipe for avatars: attention-based square crop, three sizes, EXIF stripped, keys that make replacement a prefix delete, and honest limits of the Infrai routes.
An avatar upload arrives as whatever the user’s phone produced: 4032×3024, rotated by an EXIF tag, with GPS coordinates attached. What you want stored is a small set of square webp files and, usually, the untouched source. sharp does the pixel work; Infrai’s PUT /v1/storage/object/put/{bucket}/{key} puts each rendition into a private bucket with one call and no SDK.
The decisions that matter here aren’t API decisions. They’re which sizes to emit, where to crop, and how to name keys so that replacing somebody’s picture doesn’t leave four orphans behind.
Pick the size set before you write code
Three renditions cover almost every UI: a 256 px profile header, a 96 px comment avatar, a 32 px inline mention. Emitting more feels harmless, but each one is a stored object, a write call and a cache entry you’ll eventually have to invalidate.
| Rendition | Typical use | webp @ q80 |
|---|---|---|
| 256 px | profile page, settings | ~12 KB |
| 96 px | comment rows, member lists | ~3 KB |
| 32 px | mentions, presence dots | ~600 B |
| original | re-crop later, moderation review | as uploaded |
Keep the original if you might ever change the crop rules — you can’t recover pixels you threw away, and re-asking a million users for a new photo isn’t an option.
Crop to a square without decapitating anyone
A centre crop is wrong more often than people expect; faces sit in the upper third of most portraits. sharp’s attention strategy picks the region with the highest entropy, which lands on the face far more often than the geometric centre does.
import sharp from "sharp";
export async function squareAvatar(sourceBuffer, size) {
return sharp(sourceBuffer, { limitInputPixels: 40_000_000, animated: false })
.rotate()
.resize({
width: size,
height: size,
fit: "cover",
position: sharp.strategy.attention,
})
.webp({ quality: 80, effort: 4 })
.toBuffer();
}
const out = await squareAvatar(await (await import("node:fs/promises")).readFile("./upload.jpg"), 96);
console.log(`96px avatar is ${out.length} bytes`);
Two details in there earn their place. limitInputPixels caps decode work so a 60,000×60,000 PNG bomb fails fast instead of eating your process. And .rotate() with no argument bakes the EXIF orientation into the pixels — sharp discards metadata on output by default, so if you don’t rotate first, the tag goes away and the image stays sideways forever. That default also drops the GPS block, which is exactly what you want on a public-facing profile picture.
Store the set, then verify it
Keys carry the size. That’s not styling — it makes the whole set addressable by one prefix.
export INFRAI_API_KEY="your_infrai_api_key"
curl -sS "https://api.infrai.cc/v1/storage/object/list/kbs6-avatars-0726?prefix=users/u_7741/" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{ "key": "users/u_7741/96.webp", "size_bytes": 19, "etag": "617a6ccdba77b03d1d9a75d153635da1", "content_type": null, "last_modified": "2026-07-26T00:57:25Z" },
{ "key": "users/u_7741/original.png", "size_bytes": 24, "etag": "1e732ac3d90b70b4cc7c046bfc20c277", "content_type": null, "last_modified": "2026-07-26T00:57:21Z" }
],
"next_cursor": null
}
}
Listings return content_type: null even where the object has one — a quirk worth knowing before you build UI on that field. Ask GET /v1/storage/object/head/{bucket}/{key} when you need the real value:
curl -sS "https://api.infrai.cc/v1/storage/object/head/kbs6-avatars-0726/users/u_7741/96.webp" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The whole pipeline
Derive, write, and return the keys your database should remember. Node 22, ESM, no storage SDK.
import sharp from "sharp";
import { readFile } from "node:fs/promises";
const BASE = "https://api.infrai.cc";
const BUCKET = "kbs6-avatars-0726";
const TOKEN = process.env.INFRAI_API_KEY;
if (!TOKEN) throw new Error("INFRAI_API_KEY is not set");
const SIZES = [256, 96, 32];
async function write(key, buffer, contentType) {
const payload = { data_base64: buffer.toString("base64"), content_type: contentType };
const res = await fetch(`${BASE}/v1/storage/object/put/${BUCKET}/${key}`, {
method: "PUT",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(30000),
});
const json = await res.json();
if (!json.ok) throw new Error(`${key}: ${json.error.code} — ${json.error.message}`);
return json.data;
}
export async function ingestAvatar(userId, localPath, sourceType) {
const source = await readFile(localPath);
const meta = await sharp(source).metadata();
if (meta.width < 96 || meta.height < 96) throw new Error("upload is smaller than the smallest rendition");
const originalKey = `users/${userId}/original.${sourceType.split("/")[1]}`;
const written = [await write(originalKey, source, sourceType)];
for (const size of SIZES) {
const buf = await sharp(source, { limitInputPixels: 40_000_000 })
.rotate()
.resize({ width: size, height: size, fit: "cover", position: sharp.strategy.attention })
.webp({ quality: 80 })
.toBuffer();
written.push(await write(`users/${userId}/${size}.webp`, buf, "image/webp"));
}
return written.map((o) => ({ key: o.key, etag: o.etag, bytes: o.size_bytes }));
}
console.log(await ingestAvatar("u_7741", "./upload.jpg", "image/png"));
Four writes per avatar. On a laptop the sharp work runs in well under 100 ms for all three sizes; the network round trips dominate.
Replacing a picture
Because every rendition lives under one prefix, replacement is list-then-delete-then-write. One batch call clears the old set:
curl -sS -X POST "https://api.infrai.cc/v1/storage/object/delete_batch/kbs6-avatars-0726" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"keys": ["users/u_7741/256.webp", "users/u_7741/96.webp", "users/u_7741/32.webp"]}'
There’s no object versioning here, so an overwrite is final — a limitation shared with Cloudflare R2’s default configuration but not with S3, where versioning is a bucket toggle. If you need “undo my avatar change”, put a short random suffix in the key (users/u_7741/96.a91f.webp), record it in your database, and prune old sets on a schedule.
One more caveat: writing a new object at the same key doesn’t purge anything cached downstream. Since the key changes when the suffix does, a suffixed layout also gets you cache busting for free.
Delivery: signed link or proxy
Avatars are requested constantly, so minting a fresh signed URL per image per page load is wasteful — a comment thread with 40 avatars becomes 40 presign calls. Two saner options: proxy the bytes through a cached route in your app, or mint one URL per user per session with a longer window and cache the result in your own layer.
Don’t treat the signature as the access boundary either way. Do the “is this viewer allowed to see this user?” check in your application; presigned links are a delivery mechanism, not an authorization system.
sharp locally, or a service?
| Infrai storage + sharp | S3 + sharp | Cloudinary | Supabase Storage | |
|---|---|---|---|---|
| Crop strategy control | full sharp API | full sharp API | vendor’s face detection | basic transform params |
| Cost per stored variant | metered write + bytes | metered write + bytes | included in plan | included in plan |
| Face-aware cropping | entropy/attention heuristic | same | real face detection | no |
| Runs without network | yes | yes | no | no |
| Same key also runs | email, cron, queues, AI | S3 only | media only | Postgres + auth |
If your product depends on genuinely reliable face detection — headshot-heavy directories, ID photos — Cloudinary’s face-aware cropping is better than an entropy heuristic and you’d be better off paying for it. For ordinary profile pictures the attention strategy is fine, and it costs nothing per image.
Cost per user
Each avatar is four storage.object.put calls at $0.0001 apiece, verified 2026-07-26 against metered usage, so roughly $0.0004 per user for the initial set plus a few kilobytes of stored bytes. Listing, head, presign and delete are free. A new account’s $2 of credit covers around 5,000 avatar ingests before anything bills.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '.data.breakdown[] | select(.key == "storage.object.put")'
Run that rather than trusting the paragraph above — rates drift down and promotions run, so today’s figure may well be lower. What won’t change is the shape: writes are metered per call, reads and metadata operations are free or near-free, and stored bytes are billed separately.
The reason to keep this on one key isn’t the rate. The avatar flow usually grows a moderation step, a resize retry queue and an email when a picture is rejected, and those are already available on the same credential rather than three more vendors to onboard.