Batch product-image generation in Node: bulk prompts, pooled image calls

Turning a catalogue of titles and descriptions into generated images is a two-stage job. Here's the working split, the async batch call, and the part that can't go in a batch.

Generating imagery for a few thousand SKUs splits cleanly in two: writing a good image prompt from each product title and description, and rendering the pictures. The first half is text work and belongs in an async batch — one submit, many requests, poll and page. The second half is a pooled fan-out of image calls, because Infrai’s batch endpoint runs chat and embedding requests, not image generations.

That’s the detail worth knowing before you architect around it. Post an image request into POST /v1/ai/batch/submit and every row comes back with ok: false and a VENDOR_NOT_CONFIGURED error naming ai.chat — the batch runner routes everything as a chat call. Batch the prompts; pool the pixels.

Stage one: product rows to image prompts, in one call

A raw title like “Walnut cutting board, 40×30cm, oiled finish” is a poor image prompt. A model can turn it into a good one, and doing that for 500 rows is exactly what an async batch is for.

export INFRAI_API_KEY="your_infrai_api_key"

curl -sS -X POST "https://api.infrai.cc/v1/ai/batch/submit" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "requests": [
      {"model":"glm-4-flash","max_tokens":120,"messages":[
        {"role":"system","content":"Write one image-generation prompt: studio product photo, white seamless background, soft light, no text, no people."},
        {"role":"user","content":"Walnut cutting board, 40x30cm, oiled finish"}]},
      {"model":"glm-4-flash","max_tokens":120,"messages":[
        {"role":"system","content":"Write one image-generation prompt: studio product photo, white seamless background, soft light, no text, no people."},
        {"role":"user","content":"Ceramic pour-over coffee dripper, matte white, 02 size"}]}
    ],
    "metadata": {"job": "catalog-prompts", "batch": "spring-drop"},
    "store": true
  }'
{
  "ok": true,
  "data": { "batch_id": "batch_4618bacbdd12c2a0fc985d89", "state": "completed", "total_count": 2 }
}

Small jobs can come back already completed; large ones start queued and you poll. Either way the loop is the same, and every job endpoint except the submit itself is free — status, results, list, export and cancel cost nothing, so watching a long job is not a line item.

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const BASE = "https://api.infrai.cc";
const auth = { Authorization: `Bearer ${KEY}` };

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function waitForBatch(batchId, { intervalMs = 5000, maxWaitMs = 900000 } = {}) {
  const deadline = Date.now() + maxWaitMs;
  while (Date.now() < deadline) {
    const res = await fetch(`${BASE}/v1/ai/batch/status/${batchId}`, { headers: auth });
    if (!res.ok) throw new Error(`status failed: ${res.status}`);
    const { data } = await res.json();
    if (["completed", "failed", "expired", "cancelled"].includes(data.state)) return data;
    console.log(`${data.state} ${data.completed_count}/${data.total_count}`);
    await sleep(intervalMs);
  }
  throw new Error(`batch ${batchId} did not settle in time`);
}

export async function collectPrompts(batchId) {
  const prompts = [];
  let cursor = null;
  do {
    const url = new URL(`${BASE}/v1/ai/batch/results/${batchId}`);
    if (cursor) url.searchParams.set("cursor", cursor);
    const res = await fetch(url, { headers: auth });
    if (!res.ok) throw new Error(`results failed: ${res.status}`);
    const { data } = await res.json();
    for (const item of data.items) {
      if (!item.ok) { console.warn(`row ${item.request_index}: ${item.error?.code}`); continue; }
      prompts[item.request_index] = item.result.content.trim();
    }
    cursor = data.next_cursor;
  } while (cursor);
  return prompts;
}

Each result row carries request_index, so a partial failure is a list of indices to resubmit rather than a lost night. If you’d rather hand the whole thing to a downstream job, POST /v1/ai/batch/export/{id} returns the set as JSONL in one response.

Stage two: render, with a bounded pool

First check what the image catalogue serves, because this list changes more often than the chat one:

curl -sS "https://api.infrai.cc/v1/ai/models?capability=image&available=true" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "capability": "image", "count": 5,
  "data": [
    { "id": "gpt-image-1.5", "owned_by": "azure_foundry", "unit": "per_token",
      "token_rates": { "input_usd_per_mtok": 8, "cached_input_usd_per_mtok": 2, "output_usd_per_mtok": 32 } },
    { "id": "wan-t2i", "owned_by": "wanxiang", "price_usd": 0.02, "unit": "per_image" },
    { "id": "wanx2.1-t2i-turbo", "owned_by": "wanxiang", "price_usd": 0.014, "unit": "per_image" }
  ]
}

Two different billing shapes sit in that list — per image for the Wanxiang models, per token for the GPT image models — which matters more than the headline number when you’re rendering thousands. Quote the catalogue for planning, then reconcile against infrai.cost_usd on each response: in our testing a single 1024×1024 wanx2.1-t2i-turbo render took roughly 11 seconds and billed $0.04, because size and quality options move the real figure away from the base per-image rate. Rerun the catalogue call on the day you plan the job (rates drift downward here, and the image tier changes faster than the chat tier), and treat cost_usd as the number that counts.

Now the pool.

Image endpoints are slow and rate-limited, so six in flight is a reasonable starting point and a queue beats a burst. At 11 seconds a render, six workers clear roughly 1,900 images an hour — which is the arithmetic that tells you whether this is an overnight job or a coffee break.

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

async function renderOne(prompt, model = "wanx2.1-t2i-turbo") {
  const payload = { model, prompt, size: "1024x1024", n: 1, response_format: "url" };
  const res = await fetch("https://api.infrai.cc/v1/images/generations", {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
  if (!res.ok) throw new Error(`image failed: ${res.status} ${await res.text()}`);
  const json = await res.json();
  return { url: json.data[0].url, spend: json.infrai?.cost_usd ?? 0 };
}

export async function renderAll(prompts, concurrency = 6) {
  const out = new Array(prompts.length);
  let next = 0;
  let spend = 0;
  async function worker() {
    while (next < prompts.length) {
      const i = next++;
      try {
        const r = await renderOne(prompts[i]);
        out[i] = r.url;
        spend += r.spend;
      } catch (err) {
        console.error(`row ${i} failed: ${err.message}`);
        out[i] = null;
      }
    }
  }
  await Promise.all(Array.from({ length: concurrency }, worker));
  console.log(`rendered ${out.filter(Boolean).length}/${prompts.length} for ${spend.toFixed(3)} USD`);
  return out;
}

The returned URLs expire — copy the bytes

This trips people up on the first real run. A generated image comes back as a signed vendor URL with an expiry on it, so a catalogue row that stores that string will show a broken image in a day or two. Fetch it and put it somewhere you own, which on the same key is a PUT /v1/storage/object/put/{bucket}/{key} with the raw bytes as the request body:

const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

export async function archive(imageUrl, bucket, objectKey) {
  const img = await fetch(imageUrl);
  if (!img.ok) throw new Error(`download failed: ${img.status}`);
  const bytes = Buffer.from(await img.arrayBuffer());

  const put = await fetch(`https://api.infrai.cc/v1/storage/object/put/${bucket}/${objectKey}`, {
    method: "PUT",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "image/png" },
    body: bytes,
  });
  if (!put.ok) throw new Error(`store failed: ${put.status} ${await put.text()}`);
  return (await put.json()).data;
}

That’s the shape of the argument for running this on one account rather than four: the prompts, the images, the object store and the error tracking around the job are the same credential and the same invoice, and attributing the run to a tenant is a metadata field rather than a spreadsheet.

StageRouteBillingConcurrency
Prompt writingPOST /v1/ai/batch/submitper submit, plus model tokensone call for the whole set
Job controlstatus / results / list / exportfreepoll every few seconds
RenderingPOST /v1/images/generationsper image or per tokenyour pool, start around 6
Optional upscalePOST /v1/ai/image/upscaleper imagesame pool
ArchivalPUT /v1/storage/object/put/{bucket}/{key}per call plus storagefire and forget

What this doesn’t do, and who does it better

Generated studio shots are good for placeholders, marketplace listings and long-tail SKUs where photography was never going to happen. They’re not a substitute for real photography of a real product, and no prompt discipline makes two renders of the same SKU consistent enough for a spec-sheet page. If your requirement is a templated composition — the same layout with different text and a product cut-out — Bannerbear-style template rendering is a better tool than a diffusion model.

If you want OpenAI’s image models with day-one parameter support, buy them from OpenAI; the aggregator adds a hop and sometimes lags a parameter. If your stack is already on Google Cloud, Gemini’s image generation lives next to the rest of your IAM. And a caveat that applies here more than anywhere else in this API: the image catalogue is smaller than the chat catalogue and moves, so make the availability call part of your deploy check rather than a one-off you did in a notebook.

References

Browse more ai developer guides