Render a catalog from Node
Render every product in your catalog from a server, with no browser. You
list the .glb URLs and the shots you want, and we fetch the models and render
them. It needs a Production or Scale plan.
- You have
.glbfiles on a server: userenderBatch(), on this page. - You have a live Three.js scene in the browser: use
renderVariants(). It works on every plan.
import { Bakery3Server } from '@oeave/bakery3/node';
const api = new Bakery3Server(process.env.BAKERY3_SECRET_KEY!);
const run = await api.renderBatch({
key: 'fall-2026', // names the run, keep it the same on a re-run
products: [
{ id: 'chair-aria', url: 'https://cdn.shop.com/models/chair-aria.glb' },
{ id: 'sofa-arno', url: 'https://cdn.shop.com/models/sofa-arno.glb' },
// … five thousand more, or a generator, or a database cursor
],
shots: [
'three-quarter',
'front',
{ kind: 'video', from: 'three-quarter', to: 'three-quarter-back', seconds: 4 },
],
room: { preset: 'loft_golden' },
maxCost: 600, // the most the whole run may cost, in dollars
});
await run.wait({ onProgress: (p) => console.log(`${p.done}/${p.total} · $${p.cost.actual}`) });
for await (const shot of run.images()) {
const ext = shot.kind === 'video' ? 'mp4' : 'png';
await copyToBucket(shot.url, `catalog/${shot.variant}/${shot.shot}.${ext}`);
}
// shot: {
// key: 'fall-2026/chair-aria/front', id: 'k1793xk2qyb6p9nw0f8d5c4h3m7s2v6a', variant: 'chair-aria', shot: 'front',
// kind: 'image', url: 'https://storage…', width: 2048, height: 2048, cost: 0.24, …
// }Leave shots and room out and you get one three-quarter picture of every
product in a studio, on a transparent background with a shadow. The camera is
placed from each model's own size, so a vase and a sofa both fill the picture.
The call returns when every product is recorded. The shots keep rendering after
that, so your process can exit. A webhook delivers
each shot, and api.getRenderSet(run.id) reads the run from anywhere. Result
URLs are signed and expire, so copy the files into your own storage.
run.batch is the batch under the run. Pausing, retrying and
the errors are described on that page.
Options
| Option | Means |
|---|---|
key | Names the run. The same key re-opens it while it is unfinished. |
products | { id, url, shots?, metadata?, …settings }, as an array, a generator or an async iterable. |
shots | See Shots. Default ['three-quarter'], or ['room'] in a room. |
| any setting | The run's defaults. |
version | Added to every key as @version. Change it to render everything again. |
maxCost | Required. The ceiling for the whole run. |
maxCostPerImage | A ceiling for each shot. A shot over it is refused alone. |
lane, maxParallel | See Keep the run behind your users. |
concurrency | How many models are imported at once. Default 4, at most 16. |
onProgress | Called after every product, with { product, products, images, skipped } and the run's counts. |
signal | Stops between products. What was recorded keeps rendering. |
Which plans can use it
renderBatch, quoteBatch, testBatch and assets.import are on the
Production and Scale plans; see Pricing. On any other plan
the first import answers PlanRequired and nothing is recorded.
Choose the shots
A shot is one picture or one clip of a product. shots on a product replaces
the run's list for that product.
| A shot written as | Is |
|---|---|
'three-quarter' | An image from that angle. Its id is the word. |
{ azimuthDeg: -20, elevationDeg: 4 } | An image from those degrees. Its id is az-20-el4. |
{ id: 'low', view: 'front', fov: 20, margin: 0.3 } | An image with its own id and its own settings. |
{ kind: 'video', from: 'front', to: 'back', seconds: 4 } | A clip: the camera moves from one angle to the other. Its id is orbit-front-back. |
{ camera: 'Camera_Hero' } | An image from a camera inside the model, by node name. |
The id ends the shot's key (chair-aria/front) and comes back on every result
as shot, so it has to be unique within a product.
A clip is seconds long (4 by default, at most 30) at fps (30 by default).
The azimuth is used as written, so from: { azimuthDeg: 35 } to
{ azimuthDeg: 395 } is one full turn that loops.
Pick a camera angle
An angle is degrees around the model from its front (+Z) toward its right, and degrees above the horizon. Six have names:
| Word | Azimuth | Elevation |
|---|---|---|
front | 0 | 6 |
three-quarter | 35 | 14 |
side | 90 | 6 |
back | 180 | 6 |
three-quarter-back | 145 | 14 |
top | 0 | 88 |
In a room, 'room' is a seventh: the view the room was designed for,
from level with the product or above it.
You do not set the distance. The camera stands back until the whole model fits
the frame. Change fov or margin in the settings for a close-up.
Light the product
An environment. A studio or a sky from the preset library lights the model.
The default is studio_small_03 at an intensity of 0.6. By default the
background is transparent and the product stands on its shadow.
await api.renderBatch({
// …
environment: { preset: 'studio_kontrast_04', intensity: 0.8, rotationDeg: 40 },
background: '#f4f1ec', // or 'transparent' (the default), or 'environment'
});A clip has no transparency. In a clip, 'transparent' becomes a plain #f4f4f5
backdrop with the product's shadow on it.
environment: { url } takes your own HDRI, a public .hdr or .exr. It is
imported once, however many shots use it.
A room. room stands each product in a preset room, lit by the room's own
lights. The room is turned so its camera sees the product from viewFromDeg
(30 by default, a three-quarter view). The room's own exposure and lens are used
unless you set color or fov.
await api.renderBatch({
// …
room: { preset: 'loft_golden', mode: 'golden' },
shots: ['room', 'front', { kind: 'video', from: { azimuthDeg: -30 }, to: { azimuthDeg: 90 } }],
});What the product stands on follows its size. A small product (up to 0.6 m tall)
goes on the room's pedestal and a mid-sized one on a low plinth. Furniture, and
anything wider than its stand, goes on the floor. Set room: { stand: 'own' }
or 'floor' to choose yourself.
In a room the camera stays inside the walls, and the lens goes wider when a wall
is in the way. To avoid a wide-angle picture, keep angles within about sixty
degrees of 'room'.
Override settings per product or per shot
Every setting can be set on the run, on a product or on a shot. The nearest one wins.
await api.renderBatch({
key: 'fall-2026',
room: { preset: 'loft_golden' }, // every shot of every product
quality: 'studio',
width: 2048, height: 1536,
shots: ['room', 'front'],
products: [
{ id: 'chair-aria', url: '…' },
{
id: 'ring-solis', url: '…',
room: null, // except this one, in the studio
environment: { preset: 'studio_small_03' },
shots: [
'three-quarter',
{ view: 'top', width: 2048, height: 2048, format: 'jpeg', imageQuality: 90 }, // with one square close-up
],
},
],
maxCost: 600,
});room: null on a product or a shot removes the run's room, so its environment
applies.
| Setting | Means |
|---|---|
room, environment, background | See Light the product. |
quality, width, height, format, imageQuality, advanced | As on render(). format is png, jpeg or webp for an image, mp4, webm or png-sequence for a video. A png-sequence keeps a transparent background; see PNG frames. |
fov | The lens, in degrees. Default 30, or the room's own lens in a room. |
margin | Air around the model, as a share of its size. Default 0.12, or 0.9 in a room. |
fps | Frames a second, for a clip. Default 30. |
color | { outputColorSpace, toneMapping, toneMappingExposure }. Default sRGB, ACES filmic, 1, or a room's own exposure. |
Test with three products first
testBatch takes the same options and renders a small sample: three products,
every shot, at preview quality, with clips cut to two seconds. From an array it
picks the first, the middle and the last product. From a generator it picks the
first three.
const test = await api.testBatch(options);
for (const shot of await test.result()) {
console.log(shot.variant, shot.shot, shot.kind, shot.url);
}
// Optional second argument, with its defaults:
// api.testBatch(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
quoteBatch takes the same options and prices every shot. Nothing is rendered
or charged. The models are imported for the quote, and the real run reuses that
import.
const quote = await api.quoteBatch(options);
// { variants: 5000, images: 15000, adopted: 0, skipped: 3, estimated: 3300, maximum: 4455, … }Set maxCost a little above maximum. adopted counts shots an earlier run
already rendered. They cost nothing, so after a change the quote is the price of
the re-run. See Cost & estimates.
When a product fails
A product whose model cannot be imported is skipped, and the run goes on. That
covers a 404, a file that is not a .glb, and a camera the model does not
have. Shots that failed while rendering are in run.failures().
for (const skip of run.recorded.skips) console.error(skip.id, skip.url, skip.error.message);
for await (const failure of run.failures()) console.error(failure.key, failure.error.message);Once eight products have been tried, the run stops if more than half of them could not be imported. That is usually a wrong base URL or a bucket that is not public. A bad shot, a missing plan or a bad key stops the run at once.
After a stop, make the same call again. Models that did not change are not
downloaded again, and shots that already rendered are reused at no cost, as
long as they are still stored (30 days by default, see
retention). To render everything again after a change
of look, change version.
Import a file by URL
renderBatch imports each product's URL for you. api.assets.import(url) does
the same for one file: we fetch it, store it in your project like an upload, and
return its hash and what the file contains.
const model = await api.assets.import('https://cdn.shop.com/models/chair-aria.glb');
// {
// hash: 'sha256:2044…23e3', kind: 'scene', bytes: 9784036, unchanged: false,
// facts: { triangles: 116938, bounds: { min: […], max: […] }, cameras: [], objects: […], issues: [], … }
// }
const hdri = await api.assets.import('https://cdn.shop.com/hdri/showroom.exr', { kind: 'hdri' });The next import of the same URL only downloads the file if it changed.
Otherwise it answers unchanged: true.
The URL has to meet these rules, and they cannot be turned off:
| Rule | Detail |
|---|---|
| A secret key | A browser token is refused. |
https:// on the default port | No username or password in the URL. |
| A public address | A host that resolves to a private address is refused. Up to three redirects are followed, each checked the same way. |
| No sign-in | We send no cookies and no authorization. The URL has to download in a private browser window. Our user agent is Bakery3-Import/1. |
| At most 96 MB | Upload a larger file instead. A download that stalls for twenty seconds, or takes over two and a half minutes, is ended. |
| The right kind of file | A .glb for a scene. A .hdr or .exr for an hdri. |
| Error | Status | Means | Fix |
|---|---|---|---|
ImportRefused | 400 | The URL breaks one of the rules above. | Serve the file from a public https URL with no credentials in it, or upload it. |
ImportFailed | 422 | The URL did not answer with a usable file: a 404, a sign-in page, a file that is not a .glb. | Open the URL in a private browser window. It has to download the file there. |
ImportFailed | 502 | The host had a fault: a 5xx, a timeout, or a name that does not resolve. | Try again. |
PlanRequired | 403 | The account is not on a plan that has imports. | Upgrade, or use renderVariants(). |
Over HTTP, a render request cannot name a URL of your own, for example as its environment. Import the file first.
What a model can use
The model has to be a binary glTF (.glb) with its textures inside it. A
.gltf with separate files is not imported.
The core glTF materials render, and so do the material extensions
(KHR_materials_transmission, _volume, _ior, _clearcoat, _sheen,
_specular, _emissive_strength, _unlit), KHR_texture_transform,
KHR_mesh_quantization, EXT_texture_webp and Draco-compressed geometry.
Two compression extensions cannot be read. A model that requires one is skipped, with the fix in the error:
| Requires | Publish a copy for rendering with |
|---|---|
EXT_meshopt_compression | gltf-transform copy in.glb out.glb |
KHR_texture_basisu (KTX2) | gltf-transform ktxdecompress in.glb out.glb |
| The model has | What happens |
|---|---|
Its own lights (KHR_lights_punctual) | They render, on top of the environment you chose. The import adds a warning to facts.issues. |
KHR_materials_variants | The default variant renders. |
| Its own cameras | They are listed in facts.cameras and can be a shot's camera. |
| No triangles | It is skipped. |
A model by URL cannot hide some of its parts per shot. When every finish is
inside one model, use
finishes in the
browser, or publish one file per finish.
Run renderVariants() without a person at the browser
A .glb carries PBR materials, textures and the glTF material extensions. A
custom TSL material, your own lights or a product your configurator assembles
from parts are not in it. Render those with
renderVariants(). The page that calls it can
run under a script. Once the call returns, the browser can close.
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('https://shop.example.com/internal/recorder?run=fall-2026');
// Your recorder page calls renderVariants() and sets this when it resolves.
const runId = await page.waitForFunction(() => window.__bakery3Run, null, { timeout: 0 }).then((h) => h.jsonValue());
await browser.close();
const run = await api.getRenderSet(runId);
await run.wait();Shots from URLs and pictures recorded in a page can share one batch:
run.batch.write(item) takes either.
