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
.glbfiles on a server: userenderBatch()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
| Option | Means |
|---|---|
key | Names the run. The same key re-opens it while it is unfinished. |
variants | An array, a generator or an async iterable. Read one at a time. |
apply | Puts the scene in a variant's state. May be async. May return a function that undoes it. |
id | The variant's id. Default: its id, sku or key, or the variant itself when it is a string. |
cameras | Names 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. |
version | Added to every key as @version. Change it to render everything again. |
maxCost | Required. A page of pictures that would take the run past it is refused and not charged. |
maxCostPerImage | A ceiling for each picture. A picture over it is refused alone. |
lane, maxParallel | See Keep the run behind your users. |
onProgress | Called after every variant is recorded, with { variant, variants, images } and the run's counts. |
signal | Stops 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 happened | What 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.
| Call | Does |
|---|---|
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
}| Count | Meaning |
|---|---|
staged | Accepted and priced, waiting to enter the queue. |
queued | In your account's queue. |
running | Rendering. |
completed | Done, stored and charged. |
failed | Ran out of attempts on a fault of ours. |
refused | Failed for a reason a retry would not change, such as an unsupported material. |
cancelled | Cancelled before it finished. |
adopted | Already 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.
| Error | Status | Means | Fix |
|---|---|---|---|
InsufficientBalance | 402 | The 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. |
BatchBudgetExceeded | 402 | The page would take the batch past its maxCost. | Raise maxCost, lower the quality or the size, or send fewer items. |
BatchClosed | 409 | The 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. |
InvalidRequest | 400, 413 | The 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 error | Means |
|---|---|
InvalidRequest | The 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. |
RenderBudgetExceeded | The 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.
