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 .glb files on a server: use renderBatch(), 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

OptionMeans
keyNames 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.
shotsSee Shots. Default ['three-quarter'], or ['room'] in a room.
any settingThe run's defaults.
versionAdded to every key as @version. Change it to render everything again.
maxCostRequired. The ceiling for the whole run.
maxCostPerImageA ceiling for each shot. A shot over it is refused alone.
lane, maxParallelSee Keep the run behind your users.
concurrencyHow many models are imported at once. Default 4, at most 16.
onProgressCalled after every product, with { product, products, images, skipped } and the run's counts.
signalStops 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 asIs
'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:

WordAzimuthElevation
front06
three-quarter3514
side906
back1806
three-quarter-back14514
top088

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.

SettingMeans
room, environment, backgroundSee Light the product.
quality, width, height, format, imageQuality, advancedAs 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.
fovThe lens, in degrees. Default 30, or the room's own lens in a room.
marginAir around the model, as a share of its size. Default 0.12, or 0.9 in a room.
fpsFrames 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:

RuleDetail
A secret keyA browser token is refused.
https:// on the default portNo username or password in the URL.
A public addressA host that resolves to a private address is refused. Up to three redirects are followed, each checked the same way.
No sign-inWe 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 MBUpload 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 fileA .glb for a scene. A .hdr or .exr for an hdri.
ErrorStatusMeansFix
ImportRefused400The URL breaks one of the rules above.Serve the file from a public https URL with no credentials in it, or upload it.
ImportFailed422The 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.
ImportFailed502The host had a fault: a 5xx, a timeout, or a name that does not resolve.Try again.
PlanRequired403The 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:

RequiresPublish a copy for rendering with
EXT_meshopt_compressiongltf-transform copy in.glb out.glb
KHR_texture_basisu (KTX2)gltf-transform ktxdecompress in.glb out.glb
The model hasWhat 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_variantsThe default variant renders.
Its own camerasThey are listed in facts.cameras and can be a shot's camera.
No trianglesIt 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.