Render every variant

Render every variant of your product, from every camera, with one call. You tell the SDK how to show a variant in your scene, and it renders all of them.

  • You have a live Three.js scene in the browser: use renderVariants(), on this page.
  • You have .glb files on a server: use renderBatch() from Node.
bakery3.attach({ scene, camera, renderer });

const run = await bakery3.renderVariants({
  key: 'fall-2026',                                 // names the run, keep it the same on a re-run
  variants: products,                               // your own rows: SKUs, { model, color }, anything
  apply: (product) => configurator.show(product),   // put your scene in that variant's state
  cameras: ['hero', 'front', 'detail'],             // names of cameras in your scene
  quality: 'studio',
  width: 2048,
  height: 2048,
  maxCost: 600,                                     // the most the whole run may cost, in dollars
});

await run.wait({
  onProgress: (p) => setStatus(`${p.done}/${p.total} · $${p.cost.actual.toFixed(2)}`),
});

for await (const image of run.images()) {
  await save(image.variant, image.camera, image.url);
}
// image: {
//   key: 'fall-2026/chair-aria/hero', id: 'k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a', variant: 'chair-aria', camera: 'hero',
//   url: 'https://storage…', width: 2048, height: 2048, cost: 0.24, metadata: { … }, …
// }

The call returns when every variant is recorded. The pictures keep rendering after that, even if the tab closes. After a reload, bakery3.getRenderSet(run.id) gives you the run back.

Each picture's key is the run's key, then the variant and the camera: fall-2026/chair-aria/hero. Result URLs are signed and expire, so copy the files into your own storage.

Options

OptionMeans
keyNames the run. The same key re-opens it while it is unfinished.
variantsAn array, a generator or an async iterable. Read one at a time.
applyPuts the scene in a variant's state. May be async. May return a function that undoes it.
idThe variant's id. Default: its id, sku or key, or the variant itself when it is a string.
camerasNames of cameras in the scene, Camera objects, or { id, position, target, fov }. See Cameras. Default: the live camera.
finishes(variant) => [{ id, show }]. See finishes inside one model.
metadata(variant) => ({ … }). Returned on every picture and every webhook, next to variant and camera.
versionAdded to every key as @version. Change it to render everything again.
maxCostRequired. A page of pictures that would take the run past it is refused and not charged.
maxCostPerImageA ceiling for each picture. A picture over it is refused alone.
lane, maxParallelSee Keep the run behind your users.
onProgressCalled after every variant is recorded, with { variant, variants, images } and the run's counts.
signalStops between variants. What was recorded keeps rendering.

quality, width, height, format, advanced, environment and background work as they do on render().

Load one model at a time

The SDK holds one variant at a time, so a catalog of any size fits in a browser tab. When apply loads a model, return a function that removes and disposes it. It runs before the next variant is applied, and when a run stops early. variants can be a generator, so the list does not have to fit in memory either.

import { Mesh } from 'three';

async function* products() {
  for (let page = 1; ; page++) {
    const rows = await fetch(`/api/products?page=${page}`).then((r) => r.json());
    if (!rows.length) return;
    yield* rows;
  }
}

await bakery3.renderVariants({
  key: 'fall-2026',
  variants: products(),
  apply: async (product) => {
    const model = await loadGltf(product.url);
    scene.add(model);
    return () => {
      scene.remove(model);
      model.traverse((object) => {    // free its geometries, materials and textures
        if (!(object instanceof Mesh)) return;
        object.geometry.dispose();
        for (const material of [object.material].flat()) {
          for (const value of Object.values(material)) if (value?.isTexture) value.dispose();
          material.dispose();
        }
      });
    };
  },
  cameras: ['hero', 'front', 'detail'],
  maxCost: 600,
});

Render finishes that are inside one model

When a model ships with every finish inside it, list the objects each finish shows. The model is uploaded once for all of them, and the key becomes key/variant/finish/camera.

await bakery3.renderVariants({
  key: 'fall-2026',
  variants: products,
  apply: loadAndMount,
  finishes: (product) => [
    { id: 'oak',    show: ['Seat_Oak', 'Legs_Oak'] },
    { id: 'walnut', show: ['Seat_Walnut', 'Legs_Walnut'] },
  ],
  cameras: ['hero', 'front', 'detail'],
  maxCost: 600,
});

For each picture, objects that belong to another finish are hidden. Objects no finish lists stay as they are.

Test with three variants first

testVariants takes the same options and renders a small sample: three variants, from every camera and in every finish, at preview quality. From an array it picks the first, the middle and the last. From a generator it picks the first three.

const test = await bakery3.testVariants(options);
for (const image of await test.result()) show(image.variant, image.camera, image.url);

// Optional second argument, with its defaults:
// bakery3.testVariants(options, { products: 3, size: 512, maxCost: 5 })

size is the long edge in pixels. A test uses its own keys, so the real run never reuses these small previews.

Get the price before you run

quoteVariants takes the same options and prices every picture. Nothing is uploaded, rendered or charged, and the run itself is not opened, so the maxCost you then give renderVariants() is the run's ceiling. Set it a little above maximum.

const quote = await bakery3.quoteVariants({ key: 'fall-2026', variants: products, apply, cameras });
// { variants: 214, images: 642, adopted: 0, refused: 0, refusals: [], estimated: 141.2, maximum: 190.6 }

adopted counts pictures an earlier run already rendered. They cost nothing, so after a change the quote is the price of the re-run. See Cost & estimates.

Run it again after a crash or a change

Make the same call again with the same key. Every picture whose key already rendered is reused at no cost (the API calls this adopted), and only the rest render. A picture is reused for as long as it is stored, which is the project's retention: 30 days by default. After that it renders, and is charged, again.

What happenedWhat to do
The run stopped halfway: a model failed to load, the balance ran out, the laptop closed.Fix the cause and make the same call again.
A page was refused with BatchBudgetExceeded.Make the same call again with a higher maxCost.
Some products changed.Give those variants a new id, such as chair-aria-v2. Only they render.
Everything must render again, for example after a new room.Change version.

A picture is matched by its key, not by what the scene looks like. A changed product with an unchanged key gets its old picture back. Every key starts with the run's key, so a run under another key, such as winter-2026 with the same products and cameras, renders its own pictures and reuses none of these.

The same call with the same key also takes a new maxCost, maxParallel or name. maxCost cannot go below what the run has already accepted, and lane cannot change: use a new key for that.

Pictures that did not render are in run.failures(), each with its variant, camera and an error that says how to fix it. When error.retryable is false, the scene has to change first. When it is true, the fault was ours and run.batch.retry() queues those pictures again.

for await (const failure of run.failures()) {
  console.error(failure.variant, failure.camera, failure.error.message);
}

Keep the run behind your users

A run never renders ahead of a person using your configurator, and it leaves some of your account's slots free for them. Pass lane: 'normal' to give the run the same place in line as ordinary API renders. Pass maxParallel to run fewer pictures at once than your plan allows. The details are under Limits.

Write the catalog as data

runCatalog does the same job for a catalog written down as data, a file a merchandiser can edit. Every product is a model URL with its finishes inside it.

import type { Catalog } from '@oeave/bakery3/catalog';

export const catalog: Catalog = {
  key: 'fall-2026-v3',
  quality: 'studio',
  output: { width: 2048, height: 2048, format: 'png' },
  maxCost: 600,
  cameras: [
    { id: 'front',   position: [0, 1.2, 3.2], target: [0, 0.6, 0], fov: 35 },
    { id: 'detail',  position: [0.6, 0.9, 0.9], target: [0.3, 0.7, 0], fov: 28 },
  ],
  room: { preset: 'gallery_day', mode: 'day' },
  products: [
    {
      id: 'chair-aria',
      url: '/models/chair-aria.glb',
      finishes: [
        { id: 'oak',    show: ['Seat_Oak', 'Legs_Oak'] },
        { id: 'walnut', show: ['Seat_Walnut', 'Legs_Walnut'] },
      ],
    },
    // … 200 more
  ],
};
import { createBakery3 } from '@oeave/bakery3';
import { runCatalog } from '@oeave/bakery3/catalog';
import { catalog } from './catalog';

const bakery3 = createBakery3({
  token: () => fetch('/api/bakery3-token', { method: 'POST' }).then((r) => r.json()),
});

await runCatalog(bakery3, catalog, {
  scene,                                  // your live scene: lights, room, environment
  loadProduct: (url) => loadGltf(url),    // your loader, your draco/ktx setup
  onProgress: ({ product, done, total, cost }) =>
    setStatus(`${product}: ${done}/${total}, $${cost.estimated}`),
});

It loads one product at a time and renders one picture per finish and camera, with the key key/product/finish/camera. Running it again resumes it, as above. quoteCatalog(bakery3, catalog, options) prices it without rendering.

Pause, cancel or retry a run

run.batch is the batch under the run. Each picture in it is an ordinary render: it is priced, charged once and delivered like any other.

CallDoes
run.batch.pause()Stops starting new pictures. Running ones finish.
run.batch.resume()Starts them again.
run.batch.cancel()Cancels every picture that has not finished. Finished pictures are not refunded.
run.batch.retry()Queues again every picture that failed on a fault of ours.
run.batch.items({ state })Walks the pictures in one state, a page at a time.
run.batch.export()Walks every picture, one line each. Secret key only.

A batch also pauses on its own when the scene is broken. Once 20 pictures have ended, if more than 20% of them were refused, the batch is paused and pausedReason names the most common error codes. Fix the cause, then call run.batch.resume().

Send items you built yourself

When you already have the render requests, for example from a CI job, open a batch from Node with a secret key. An item is a key, a spec (the body POST /v1/renders takes) and optional metadata.

import { Bakery3Server } from '@oeave/bakery3/node';

const api = new Bakery3Server(process.env.BAKERY3_SECRET_KEY!);
const batch = await api.batches.open({ key: 'fall-2026-v3', lane: 'batch', maxCost: 600 });

for await (const line of readLines(process.argv[2])) {   // one item per line
  await batch.write(JSON.parse(line));                   // sends a page when one is full
}
await batch.close();                                     // sends the rest, then takes no more items

await batch.wait({
  onProgress: (p) => console.log(`${p.completed}/${p.total} · $${p.cost.actual}`),
});
for await (const item of batch.items({ state: 'failed' })) {
  console.error(item.key, item.error.code, item.error.message);
}

batch.quote(items) prices items without admitting them. Over HTTP that is dryRun: true on POST /v1/batches/:id/items. Every route is listed under HTTP API.

Read the batch record

run.batch.refresh(), or GET /v1/batches/:id, returns the record.

{
  "id": "jd71cf4a8w2k6t9n3q5x0m7v1r8e4s2y",
  "key": "fall-2026-v3",
  "kind": "render",
  "state": "sealed",
  "lane": "batch",
  "maxCost": 600,
  "counts": { "staged": 412, "queued": 50, "running": 32, "completed": 1088,
              "failed": 0, "refused": 3, "cancelled": 0, "adopted": 215 },
  "total": 1800,
  "done": 1306,
  "cost": { "estimated": 402.5, "maximum": 543.4, "actual": 268.13 },
  "createdAt": 1735689568000,
  "sealedAt": 1735696800000
}
CountMeaning
stagedAccepted and priced, waiting to enter the queue.
queuedIn your account's queue.
runningRendering.
completedDone, stored and charged.
failedRan out of attempts on a fault of ours.
refusedFailed for a reason a retry would not change, such as an unsupported material.
cancelledCancelled before it finished.
adoptedAlready rendered under the same key. Cost nothing.

total is every item accepted, adopted ones included, and done is how many have ended. In cost, maximum is what the accepted items have reserved and actual is what finished items have cost so far. A sealed batch takes no more items.

With a secret key, GET /v1/batches/:id/export streams one line of JSON per item. A refused or failed item has error in place of resultUrl.

{ "key": "fall-2026-v3/chair-aria/oak/front", "id": "k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a", "state": "completed",
  "resultUrl": "https://storage…?X-Amz-Signature=…", "cost": 0.24,
  "metadata": { "variant": "chair-aria", "finish": "oak", "camera": "front" } }

Get a webhook for every picture

Every picture fires the usual render.* events, with render.metadata, render.batchId and render.batchKey set. A handler written for single renders already works. batch.progress, batch.completed and batch.paused report on the run as a whole; see Webhooks.

const { render, batch } = event;
switch (event.event) {
  case 'render.completed': {
    if (!render?.result) break;
    const { variant, camera } = render.metadata as Record<string, string>;
    await copyToBucket(render.result.url, `catalog/${variant}/${camera}.png`);
    break;
  }
  case 'batch.progress':
    if (batch) await db.batch.update(batch.id, batch.counts);
    break;
  case 'batch.completed':
  case 'batch.paused':
    if (batch) await notifyOps(batch);
    break;
}

Errors

Pictures are sent in pages of up to 100 items and 4 MB of JSON. A page is accepted whole or refused whole, and a refused page is never charged. Fix the cause and make the same call again, or call batch.flush() when you drive the batch yourself: the page is still buffered.

ErrorStatusMeansFix
InsufficientBalance402The page could cost more than the balance available to it. Every accepted render reserves its maximum until it finishes.Top up, or wait for accepted items to finish.
BatchBudgetExceeded402The page would take the batch past its maxCost.Raise maxCost, lower the quality or the size, or send fewer items.
BatchClosed409The batch is sealed, paused, completed or cancelled and takes no items.Resume a paused batch. Otherwise open a new one. Items that already rendered, and are still stored, are reused at no cost.
InvalidRequest400, 413The page has more than 100 items, or more than 4 MB of JSON.The SDK splits pages for you, so this only happens over HTTP.

Inside an accepted page, one item can still be refused alone. It carries error in the response and the rest of the page goes through.

Item errorMeans
InvalidRequestThe spec does not parse, the key appears twice in the page, or the item is over 900 KB. An item that large has something inline that should be an uploaded file.
RenderBudgetExceededThe item's maximum cost is over its own maxCost.

QueueFull never applies to a batch page, because batch items wait outside the account's queue until there is room.

An item that fails after it was accepted fails as a render does: refused with the name, type, path, reason and fix, or failed when a fault of ours ran out of attempts. Those errors are on the item, in run.failures() and the export.

run.result() collects every picture into one array. When a picture was refused as it was sent, it rejects with RenderSetRefused instead: error.refusals names each refused picture and the reason, error.refused and error.requested count them, and run.images() still walks the pictures that were accepted. For the same reason it rejects with code Cancelled when pictures were cancelled, and with code Expired when a picture rendered and its file has since been deleted.

When render({ cameras }) has a page refused, the call rejects with the API's own error, such as RenderBudgetExceeded or InsufficientBalance, and cancels whatever it had already sent, so nothing of it is left waiting. See Troubleshooting and Limits.