Short-video UGC: an ingest and moderation pipeline that holds at 200 MB
Phone video is too big for a JSON API and too slow for a synchronous review. A three-lane design, with measured transport numbers and the frame work Infrai actually does.
Send the media file from the handset to object storage in parts, and let your API see only a manifest. The moderation record gets written when the upload completes, carries extracted frames rather than the clip, and never blocks the uploader’s request. Infrai covers the frame half of that pipeline — the transforms, the ladder, the batch job — and it’s worth saying up front that its image routes don’t take video and its AI screening routes aren’t serving right now, so the classifier is a piece you bring.
Getting this wrong usually looks the same: someone POSTs the whole clip to an application server, the request times out at 30 seconds behind a load balancer, and the retry uploads another 180 MB. Three lanes that never wait on each other is the fix.
The three lanes
| Lane | Runs where | Latency budget | Fails how |
|---|---|---|---|
| Ingest | handset → object storage, direct | as long as the network needs | resumable; a dropped part is re-sent, not the file |
| Derive | worker, triggered by upload-complete | seconds to a minute | retryable; the post stays in processing |
| Review | queue consumer + human console | minutes to hours | the post never leaves pending |
The uploader’s HTTP request finishes at the end of lane one. Everything after that is your infrastructure talking to itself, which is exactly the property you want when a video is 180 MB on a train.
“Every upload must hit the moderation queue immediately” is a requirement about lane three’s enqueue, not about its verdict. Write the review row in the same transaction that marks the upload complete, and the guarantee holds even when your classifier is backed up.
Why the clip can’t travel as JSON
Base64 in a request body is fine for a thumbnail and untenable for video. We measured round trips against a free image route to isolate the transport cost, on 2026-07-26:
| Decoded payload | Round trip |
|---|---|
| 6 MB | ~11.9 s |
| 12 MB | ~22.7 s |
| 30 MB | ~74 s |
Roughly two to three seconds per megabyte, before anything useful happens, and base64 inflates the wire by a third on top. Extrapolate to a 200 MB clip and you’re past every proxy timeout in the path. That’s the arithmetic behind “direct to storage, in parts” — it isn’t a preference.
Frames are what the pipeline actually processes
Pull a few stills at ingest and every downstream stage gets cheap. One at zero for the poster, one mid-clip, one late, because a video that opens on a black frame is exactly how people smuggle content past a poster-only check.
set -euo pipefail
CLIP="./upload.mp4"
DURATION=$(ffprobe -v error -show_entries format=duration -of csv=p=0 "${CLIP}")
for PCT in 0.02 0.50 0.85; do
AT=$(echo "${DURATION} * ${PCT}" | bc -l)
ffmpeg -loglevel error -ss "${AT}" -i "${CLIP}" -frames:v 1 -q:v 3 "frame-${PCT}.jpg"
done
ls -la frame-*.jpg
Three JPEGs at a few hundred KB each. That’s what goes to the review console, the tag pipeline, and the thumbnail ladder.
One job for the whole ladder
POST /v1/image/batch/submit takes an items array, each with its own image and ops, so three frames at three sizes is one request rather than nine:
export INFRAI_API_KEY="your_infrai_api_key"
FRAME=$(base64 < ./frame-0.02.jpg | tr -d '\n')
curl -sS -X POST "https://api.infrai.cc/v1/image/batch/submit" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"items\":[{\"image\":{\"base64\":\"${FRAME}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":320,\"height\":180,\"fit\":\"cover\"}},{\"op\":\"format_convert\",\"params\":{\"format\":\"webp\"}}]},{\"image\":{\"base64\":\"${FRAME}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":640,\"height\":360,\"fit\":\"cover\"}},{\"op\":\"format_convert\",\"params\":{\"format\":\"webp\"}}]},{\"image\":{\"base64\":\"${FRAME}\"},\"ops\":[{\"op\":\"resize\",\"params\":{\"width\":1280,\"height\":720,\"fit\":\"cover\"}},{\"op\":\"format_convert\",\"params\":{\"format\":\"webp\"}}]}],\"webhook_url\":\"https://your-app.example.com/hooks/frames\"}"
{
"ok": true,
"data": {
"job_id": "imgjob_8ee2cbb24b3a1b3ba552b71c",
"status": "completed",
"total_count": 3
}
}
Read the per-item results back — this job id is a real one from our testing, so the call resolves:
curl -sS "https://api.infrai.cc/v1/image/batch/status/imgjob_8ee2cbb24b3a1b3ba552b71c" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.data.items[] | {index, status, w: .result.width, h: .result.height, bytes: .result.size_bytes}]'
[
{ "index": 0, "status": "completed", "w": 320, "h": 180, "bytes": 194 },
{ "index": 1, "status": "completed", "w": 640, "h": 360, "bytes": 504 },
{ "index": 2, "status": "completed", "w": 1280, "h": 720, "bytes": 1722 }
]
Items report individually, so a corrupt frame marks one entry failed and the other two still land. Put format_convert in ops rather than as a per-item key — extra keys on an item are ignored without complaint, which is a quiet way to get PNGs you didn’t want.
The worker that ties ingest to review
// on-upload-complete.mjs — Node 22
import { readFile } from "node:fs/promises";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("set INFRAI_API_KEY");
const LADDER = [
{ width: 320, height: 180 },
{ width: 640, height: 360 },
{ width: 1280, height: 720 },
];
async function buildLadder(framePaths) {
const items = [];
for (const p of framePaths) {
const base64 = (await readFile(p)).toString("base64");
for (const size of LADDER) {
items.push({
image: { base64 },
ops: [
{ op: "resize", params: { ...size, fit: "cover" } },
{ op: "format_convert", params: { format: "webp" } },
],
});
}
}
const payload = { items, webhook_url: process.env.FRAMES_WEBHOOK };
const res = await fetch("https://api.infrai.cc/v1/image/batch/submit", {
method: "POST",
headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
body: JSON.stringify(payload),
});
const json = await res.json();
if (!res.ok || json.ok === false) throw new Error(`batch submit failed: ${json?.error?.code ?? res.status}`);
return json.data;
}
export async function onUploadComplete({ postId, framePaths, enqueueReview }) {
// The review row goes first. If the ladder throws, the post is still queued.
await enqueueReview({ postId, state: "pending", enqueuedAt: new Date().toISOString() });
try {
const job = await buildLadder(framePaths);
console.log(`post ${postId}: job ${job.job_id}, ${job.total_count} renditions, ${job.status}`);
return job;
} catch (err) {
console.error(`post ${postId}: ladder deferred — ${err.message}`);
return null;
}
}
const job = await buildLadder(["./frame-0.02.jpg"]);
console.log(job.job_id);
Order matters in that function and it’s the only thing in this article worth memorising: queue first, derive second.
Screening: the part you have to bring
Infrai publishes POST /v1/image/moderate, and on this account today it answers:
curl -sS -X POST "https://api.infrai.cc/v1/image/moderate" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d "{\"image\":\"data:image/jpeg;base64,${FRAME}\"}"
{
"ok": false,
"error": {
"code": "VENDOR_NOT_CONFIGURED",
"http_status": 503,
"message": "image.moderate: no image-process vision vendor key configured (vendor=infrai) — add a key to enable it",
"retryable": false
}
}
POST /v1/image/tag and POST /v1/image/ocr answer the same way. So automated screening is not something this API can do for you right now — you need either a dedicated moderation vendor or a vision model you call per frame, and you should design as though the verdict arrives asynchronously either way. Cloudinary sells video moderation add-ons that wrap third-party classifiers, and ImageKit covers the transform-and-deliver half if you’d rather buy the pipeline than assemble it; both are honest answers for a team that doesn’t want to own this.
Whatever you wire in, the policy question stays yours:
| Policy | Time to first view | Risk carried | Fits |
|---|---|---|---|
| Pre-moderate everything | minutes to hours | low | regulated, or small volume |
| Publish then review | instant | a bad clip is briefly live | most communities |
| Hybrid: trust tier decides | instant for trusted accounts | concentrated on new accounts | anything at scale |
What it costs
Verified 2026-07-26: POST /v1/image/batch/submit, GET /v1/image/batch/status/{id} and POST /v1/image/process are free rate-limited calls that don’t touch the trial credit. POST /v1/image/compress is $0.003 per call, POST /v1/image/smart_crop $0.015, POST /v1/image/background_remove $0.05, and POST /v1/image/moderate is priced around $0.00115 per call for when it comes back. New accounts hold $2 in credit. These numbers move down over time — read them rather than trusting a page:
curl -sS "https://api.infrai.cc/v1/discovery" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
| jq '[.capabilities[] | select(.id | startswith("image.")) | {id, unit: .billing.unit, price: .billing.price_usd}]'
The trade-off in this whole design is that you’re operating a video pipeline out of primitives instead of buying a finished one, and the transcode step — the actual H.264 ladder, not the poster frames — is still yours to run on ffmpeg somewhere. What the single account buys you is that the queue holding review jobs, the object store holding the parts, the error tracker catching a failed ladder, and the per-tenant usage view all sit behind one key. For a UGC product the operational surface is the cost, and consolidating it is worth more than any per-call rate.