@oeave/bakery3 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +562 -2
  3. package/dist/animation-B1h0Ryvj.d.ts +97 -0
  4. package/dist/bake/index.d.ts +369 -0
  5. package/dist/bake/index.js +9 -0
  6. package/dist/bake/index.js.map +1 -0
  7. package/dist/bake-DaOlenyx.d.ts +1357 -0
  8. package/dist/catalog/index.d.ts +100 -0
  9. package/dist/catalog/index.js +154 -0
  10. package/dist/catalog/index.js.map +1 -0
  11. package/dist/catalog.gen-BM-aNf7n.d.ts +733 -0
  12. package/dist/chunk-25ZXSVQ3.js +1107 -0
  13. package/dist/chunk-25ZXSVQ3.js.map +1 -0
  14. package/dist/chunk-3G5QL4F4.js +145 -0
  15. package/dist/chunk-3G5QL4F4.js.map +1 -0
  16. package/dist/chunk-4E5VV4QY.js +12 -0
  17. package/dist/chunk-4E5VV4QY.js.map +1 -0
  18. package/dist/chunk-54WJDN5R.js +3286 -0
  19. package/dist/chunk-54WJDN5R.js.map +1 -0
  20. package/dist/chunk-6NYR73Y7.js +832 -0
  21. package/dist/chunk-6NYR73Y7.js.map +1 -0
  22. package/dist/chunk-6YVQANMW.js +1463 -0
  23. package/dist/chunk-6YVQANMW.js.map +1 -0
  24. package/dist/chunk-AF5BIELI.js +70 -0
  25. package/dist/chunk-AF5BIELI.js.map +1 -0
  26. package/dist/chunk-APBOWWSV.js +92 -0
  27. package/dist/chunk-APBOWWSV.js.map +1 -0
  28. package/dist/chunk-ARMTTZXZ.js +1157 -0
  29. package/dist/chunk-ARMTTZXZ.js.map +1 -0
  30. package/dist/chunk-CX4WTVGH.js +4492 -0
  31. package/dist/chunk-CX4WTVGH.js.map +1 -0
  32. package/dist/chunk-HQTUFVMM.js +1412 -0
  33. package/dist/chunk-HQTUFVMM.js.map +1 -0
  34. package/dist/chunk-MD2U6S4N.js +1952 -0
  35. package/dist/chunk-MD2U6S4N.js.map +1 -0
  36. package/dist/chunk-S4AJTLLN.js +23 -0
  37. package/dist/chunk-S4AJTLLN.js.map +1 -0
  38. package/dist/chunk-TGFNQBKC.js +551 -0
  39. package/dist/chunk-TGFNQBKC.js.map +1 -0
  40. package/dist/chunk-V3QINEEG.js +87 -0
  41. package/dist/chunk-V3QINEEG.js.map +1 -0
  42. package/dist/chunk-W6WQ5UP7.js +91 -0
  43. package/dist/chunk-W6WQ5UP7.js.map +1 -0
  44. package/dist/chunk-WBIAFIHV.js +346 -0
  45. package/dist/chunk-WBIAFIHV.js.map +1 -0
  46. package/dist/chunk-ZEBVAIJJ.js +156 -0
  47. package/dist/chunk-ZEBVAIJJ.js.map +1 -0
  48. package/dist/devtools/index.d.ts +536 -0
  49. package/dist/devtools/index.js +15 -0
  50. package/dist/devtools/index.js.map +1 -0
  51. package/dist/environments/index.d.ts +93 -0
  52. package/dist/environments/index.js +382 -0
  53. package/dist/environments/index.js.map +1 -0
  54. package/dist/hotspots/index.d.ts +111 -0
  55. package/dist/hotspots/index.js +285 -0
  56. package/dist/hotspots/index.js.map +1 -0
  57. package/dist/index.d.ts +944 -0
  58. package/dist/index.js +15 -0
  59. package/dist/index.js.map +1 -0
  60. package/dist/node/index.cjs +5115 -0
  61. package/dist/node/index.cjs.map +1 -0
  62. package/dist/node/index.d.cts +4862 -0
  63. package/dist/node/index.d.ts +576 -0
  64. package/dist/node/index.js +1279 -0
  65. package/dist/node/index.js.map +1 -0
  66. package/dist/prepare-C5qVvaw4.d.ts +112 -0
  67. package/dist/presets/index.d.ts +526 -0
  68. package/dist/presets/index.js +14 -0
  69. package/dist/presets/index.js.map +1 -0
  70. package/dist/r3f/index.d.ts +159 -0
  71. package/dist/r3f/index.js +592 -0
  72. package/dist/r3f/index.js.map +1 -0
  73. package/dist/room-S5TLCK4F.js +9 -0
  74. package/dist/room-S5TLCK4F.js.map +1 -0
  75. package/dist/rooms.gen-vhj6sFW5.d.ts +1434 -0
  76. package/dist/session-YCQS4MRH.js +9 -0
  77. package/dist/session-YCQS4MRH.js.map +1 -0
  78. package/dist/shapes/index.d.ts +222 -0
  79. package/dist/shapes/index.js +836 -0
  80. package/dist/shapes/index.js.map +1 -0
  81. package/dist/testRun-BIPXaVPb.d.ts +1130 -0
  82. package/dist/timeline-ChwgD7bT.d.ts +470 -0
  83. package/dist/tsl/index.d.ts +165 -0
  84. package/dist/tsl/index.js +310 -0
  85. package/dist/tsl/index.js.map +1 -0
  86. package/package.json +170 -4
@@ -0,0 +1,1279 @@
1
+ import { warnIfKeyIsExposed, Bakery3Client, quoteKey, testSettings, sampleOf, Batch } from '../chunk-25ZXSVQ3.js';
2
+ import { RenderSet, emptyRecorded } from '../chunk-ZEBVAIJJ.js';
3
+ import { QUALITY, isClipFormat, isVideoFormat, protocolVersionFor, SDK_VERSION } from '../chunk-APBOWWSV.js';
4
+ import { HDRI_PRESETS, PRESETS_VERSION, PEDESTAL_PRESETS } from '../chunk-6NYR73Y7.js';
5
+ import { ROOM_PRESETS } from '../chunk-MD2U6S4N.js';
6
+ import { Bakery3Error } from '../chunk-3G5QL4F4.js';
7
+ export { Bakery3Error, BatchPageRefused } from '../chunk-3G5QL4F4.js';
8
+
9
+ // src/frame.ts
10
+ var VIEW_WORDS = {
11
+ front: { azimuthDeg: 0, elevationDeg: 6 },
12
+ "three-quarter": { azimuthDeg: 35, elevationDeg: 14 },
13
+ side: { azimuthDeg: 90, elevationDeg: 6 },
14
+ back: { azimuthDeg: 180, elevationDeg: 6 },
15
+ "three-quarter-back": { azimuthDeg: 145, elevationDeg: 14 },
16
+ top: { azimuthDeg: 0, elevationDeg: 88 }
17
+ };
18
+ function angleOf(view) {
19
+ if (typeof view === "string")
20
+ return view in VIEW_WORDS ? { ...VIEW_WORDS[view] } : null;
21
+ if (view && typeof view === "object" && typeof view.azimuthDeg === "number") {
22
+ const { azimuthDeg, elevationDeg } = view;
23
+ return {
24
+ azimuthDeg,
25
+ ...elevationDeg !== void 0 ? { elevationDeg } : {}
26
+ };
27
+ }
28
+ return null;
29
+ }
30
+ function angleBetween(from, to, t) {
31
+ const fromEl = from.elevationDeg ?? 12;
32
+ const toEl = to.elevationDeg ?? 12;
33
+ return {
34
+ azimuthDeg: from.azimuthDeg + (to.azimuthDeg - from.azimuthDeg) * t,
35
+ elevationDeg: fromEl + (toEl - fromEl) * t
36
+ };
37
+ }
38
+ function directionOf(angle) {
39
+ const azimuth = angle.azimuthDeg * Math.PI / 180;
40
+ const elevation = (angle.elevationDeg ?? 12) * Math.PI / 180;
41
+ return [
42
+ Math.sin(azimuth) * Math.cos(elevation),
43
+ Math.sin(elevation),
44
+ Math.cos(azimuth) * Math.cos(elevation)
45
+ ];
46
+ }
47
+ function framedDistance(radius, fov, aspect, margin) {
48
+ const vertical = fov * Math.PI / 180;
49
+ const horizontal = 2 * Math.atan(Math.tan(vertical / 2) * aspect);
50
+ return radius * (1 + margin) / Math.sin(Math.min(vertical, horizontal) / 2);
51
+ }
52
+ function radiusOf(bounds) {
53
+ return Math.hypot(
54
+ bounds.max[0] - bounds.min[0],
55
+ bounds.max[1] - bounds.min[1],
56
+ bounds.max[2] - bounds.min[2]
57
+ ) / 2 || 0.5;
58
+ }
59
+ function centerOf(bounds) {
60
+ return [
61
+ (bounds.min[0] + bounds.max[0]) / 2,
62
+ (bounds.min[1] + bounds.max[1]) / 2,
63
+ (bounds.min[2] + bounds.max[2]) / 2
64
+ ];
65
+ }
66
+ function framedCamera(bounds, view, aspect) {
67
+ const fov = view.fov ?? 30;
68
+ if (!(fov > 1 && fov < 120))
69
+ throw badView(view.id, `fov ${fov} is outside 1 to 120 degrees`);
70
+ const center = centerOf(bounds);
71
+ const radius = radiusOf(bounds);
72
+ const distance = framedDistance(radius, fov, aspect, view.margin ?? 0.12);
73
+ return cameraAt(
74
+ center,
75
+ radius,
76
+ directionOf(view),
77
+ distance,
78
+ fov,
79
+ aspect,
80
+ view.id
81
+ );
82
+ }
83
+ function cameraAt(center, radius, direction, distance, fov, aspect, name) {
84
+ const position = [
85
+ center[0] + direction[0] * distance,
86
+ center[1] + direction[1] * distance,
87
+ center[2] + direction[2] * distance
88
+ ];
89
+ return {
90
+ type: "perspective",
91
+ fov,
92
+ aspect,
93
+ near: Math.max(1e-3, (distance - radius) / 10),
94
+ far: (distance + radius) * 10,
95
+ position,
96
+ quaternion: lookAt(position, center),
97
+ name
98
+ };
99
+ }
100
+ function lookAt(position, target) {
101
+ let z = normalize([
102
+ position[0] - target[0],
103
+ position[1] - target[1],
104
+ position[2] - target[2]
105
+ ]);
106
+ if (!z) z = [0, 0, 1];
107
+ const up = Math.abs(z[0]) < 1e-9 && Math.abs(z[2]) < 1e-9 ? [0, 0, -1] : [0, 1, 0];
108
+ const x = normalize(cross(up, z)) ?? [1, 0, 0];
109
+ const y = cross(z, x);
110
+ return quaternionOf(x, y, z);
111
+ }
112
+ function quaternionOf(x, y, z) {
113
+ const m00 = x[0], m10 = x[1], m20 = x[2];
114
+ const m01 = y[0], m11 = y[1], m21 = y[2];
115
+ const m02 = z[0], m12 = z[1], m22 = z[2];
116
+ const trace = m00 + m11 + m22;
117
+ if (trace > 0) {
118
+ const s2 = 0.5 / Math.sqrt(trace + 1);
119
+ return [(m21 - m12) * s2, (m02 - m20) * s2, (m10 - m01) * s2, 0.25 / s2];
120
+ }
121
+ if (m00 > m11 && m00 > m22) {
122
+ const s2 = 2 * Math.sqrt(1 + m00 - m11 - m22);
123
+ return [0.25 * s2, (m01 + m10) / s2, (m02 + m20) / s2, (m21 - m12) / s2];
124
+ }
125
+ if (m11 > m22) {
126
+ const s2 = 2 * Math.sqrt(1 + m11 - m00 - m22);
127
+ return [(m01 + m10) / s2, 0.25 * s2, (m12 + m21) / s2, (m02 - m20) / s2];
128
+ }
129
+ const s = 2 * Math.sqrt(1 + m22 - m00 - m11);
130
+ return [(m02 + m20) / s, (m12 + m21) / s, 0.25 * s, (m10 - m01) / s];
131
+ }
132
+ function cross(a, b) {
133
+ return [
134
+ a[1] * b[2] - a[2] * b[1],
135
+ a[2] * b[0] - a[0] * b[2],
136
+ a[0] * b[1] - a[1] * b[0]
137
+ ];
138
+ }
139
+ function normalize(v) {
140
+ const length = Math.hypot(v[0], v[1], v[2]);
141
+ return length > 1e-12 ? [v[0] / length, v[1] / length, v[2] / length] : null;
142
+ }
143
+ function badView(id, what) {
144
+ return new Bakery3Error({
145
+ code: "InvalidRequest",
146
+ message: `Camera "${id}": ${what}.`,
147
+ docs: "https://bakery3.com/docs/catalog#angles"
148
+ });
149
+ }
150
+
151
+ // src/node/renderBatch.ts
152
+ var DOCS = "https://bakery3.com/docs/catalog";
153
+ var PRESET_LIBRARY = "https://cdn.bakery3.com/presets";
154
+ var SKIPS_KEPT = 50;
155
+ var BREAKER_AFTER = 8;
156
+ var BREAKER_SHARE = 0.5;
157
+ var MAX_SECONDS = 30;
158
+ var MAX_FPS = 60;
159
+ async function recordBatch(host, batch, options, { dryRun = false, force } = {}) {
160
+ validateShots(options.shots ?? [], "the run");
161
+ const environments = new EnvironmentCache(host);
162
+ const tally = {
163
+ ...emptyRecorded(),
164
+ skipped: 0,
165
+ skips: [],
166
+ estimated: 0,
167
+ maximum: 0
168
+ };
169
+ const seen = /* @__PURE__ */ new Set();
170
+ let attempted = 0;
171
+ const one = async (product) => {
172
+ let items;
173
+ try {
174
+ if (product.shots)
175
+ validateShots(product.shots, `product "${product.id}"`);
176
+ const imported = await host.importAsset({
177
+ url: product.url,
178
+ kind: "scene"
179
+ });
180
+ items = await itemsOf(product, imported, options, environments, force);
181
+ } catch (error) {
182
+ if (!(error instanceof Bakery3Error) || error.code !== "ImportFailed" && error.code !== "ImportRefused")
183
+ throw error;
184
+ attempted++;
185
+ tally.skipped++;
186
+ if (tally.skips.length < SKIPS_KEPT)
187
+ tally.skips.push({ id: product.id, url: product.url, error });
188
+ if (attempted >= BREAKER_AFTER && tally.skipped / attempted > BREAKER_SHARE)
189
+ throw mostlyFailing(tally, attempted);
190
+ report(product.id);
191
+ return;
192
+ }
193
+ attempted++;
194
+ let outcomes;
195
+ if (dryRun) {
196
+ outcomes = await batch.quote(items);
197
+ } else {
198
+ for (const item of items) batch.add(item);
199
+ outcomes = await batch.flush();
200
+ }
201
+ tally.variants++;
202
+ tally.images += items.length;
203
+ for (const outcome of outcomes) {
204
+ if (outcome.error) {
205
+ tally.refused++;
206
+ if (tally.refusals.length < SKIPS_KEPT)
207
+ tally.refusals.push({ key: outcome.key, error: outcome.error });
208
+ } else if (outcome.adopted) tally.adopted++;
209
+ else {
210
+ tally.estimated += outcome.estimatedCost;
211
+ tally.maximum += outcome.maximumCost;
212
+ }
213
+ }
214
+ report(product.id);
215
+ };
216
+ const report = (product) => options.onProgress?.({
217
+ ...batch.progress(),
218
+ product,
219
+ products: tally.variants,
220
+ images: tally.images,
221
+ skipped: tally.skipped
222
+ });
223
+ const limit = Math.max(1, Math.min(16, Math.floor(options.concurrency ?? 4)));
224
+ const flying = /* @__PURE__ */ new Set();
225
+ let failure;
226
+ for await (const product of options.products) {
227
+ if (failure) break;
228
+ if (options.signal?.aborted) {
229
+ failure = options.signal.reason ?? abortError();
230
+ break;
231
+ }
232
+ validateProduct(product, seen);
233
+ const task = one(product).catch((error) => {
234
+ failure ??= error;
235
+ }).finally(() => flying.delete(task));
236
+ flying.add(task);
237
+ if (flying.size >= limit) await Promise.race(flying);
238
+ }
239
+ await Promise.all(flying);
240
+ if (failure) throw failure;
241
+ tally.estimated = Math.round(tally.estimated * 1e4) / 1e4;
242
+ tally.maximum = Math.round(tally.maximum * 1e4) / 1e4;
243
+ return tally;
244
+ }
245
+ async function itemsOf(product, imported, run, environments, force) {
246
+ const facts = imported.facts;
247
+ if (!facts)
248
+ throw unreadable(product, "The import returned no facts about the model.");
249
+ const blocking = facts.issues.find((issue) => issue.severity === "error");
250
+ if (blocking) throw unreadable(product, blocking.message);
251
+ if (!facts.bounds)
252
+ throw unreadable(
253
+ product,
254
+ "The model has no geometry with bounds, so there is nowhere to point a camera."
255
+ );
256
+ const bounds = facts.bounds;
257
+ const runSettings = settingsOf(run);
258
+ const productSettings = settingsOf(product);
259
+ const shots = (product.shots ?? run.shots ?? []).map(
260
+ (shot) => resolveShot(shot, runSettings, productSettings, force)
261
+ );
262
+ if (!shots.length) {
263
+ const inRoom = layered("room", runSettings, productSettings, force) != null;
264
+ shots.push(
265
+ resolveShot(
266
+ inRoom ? "room" : "three-quarter",
267
+ runSettings,
268
+ productSettings,
269
+ force
270
+ )
271
+ );
272
+ }
273
+ const counts = { info: 0, warning: 0, error: 0 };
274
+ for (const issue of facts.issues) counts[issue.severity]++;
275
+ const version = force?.version ?? run.version;
276
+ const items = [];
277
+ for (const shot of shots) {
278
+ const quality = shot.quality ?? "studio";
279
+ const edge = QUALITY[quality].defaultLongEdge;
280
+ const video = shot.kind === "video";
281
+ const output = {
282
+ width: shot.width ?? edge,
283
+ height: shot.height ?? edge,
284
+ format: shot.format ?? (video ? "mp4" : "png"),
285
+ ...shot.imageQuality !== void 0 ? { quality: shot.imageQuality } : {}
286
+ };
287
+ if (video !== isClipFormat(output.format)) {
288
+ throw new Bakery3Error({
289
+ code: "InvalidRequest",
290
+ message: `Shot "${shot.id}" of ${product.id} is ${video ? "a video in a still" : "an image in a video"} format (${output.format}).`,
291
+ fix: "A video is mp4, webm or png-sequence; an image is png, jpeg or webp. Leave `format` out for the default.",
292
+ path: `${product.id}/${shot.id}`,
293
+ docs: `${DOCS}#shots`
294
+ });
295
+ }
296
+ const aspect = output.width / output.height;
297
+ const room = shot.room ? roomFor(shot.room, bounds) : void 0;
298
+ const background = isVideoFormat(output.format) && (shot.background ?? "transparent") === "transparent" ? CLIP_BACKDROP : shot.background;
299
+ const environment = room ? roomBackground() : await environments.get(shot.environment, background);
300
+ const color = {
301
+ outputColorSpace: "srgb",
302
+ toneMapping: "aces-filmic",
303
+ toneMappingExposure: room ? room.exposure : 1,
304
+ ...shot.color
305
+ };
306
+ const lens = { fov: shot.fov, margin: shot.margin };
307
+ let camera;
308
+ let animation;
309
+ if (video) {
310
+ const from = angleFor(shot.from, room, product, shot.id);
311
+ const to = angleFor(shot.to, room, product, shot.id);
312
+ const fps = Math.min(MAX_FPS, Math.max(1, Math.round(shot.fps ?? 30)));
313
+ const seconds = Math.min(MAX_SECONDS, shot.seconds ?? 4);
314
+ const frameCount = Math.max(2, Math.round(seconds * fps));
315
+ const positions = [];
316
+ const quaternions = [];
317
+ let first;
318
+ for (let frame = 0; frame < frameCount; frame++) {
319
+ const at = angleBetween(from, to, frame / (frameCount - 1));
320
+ const spec = cameraForAngle(at, bounds, aspect, lens, room, shot.id);
321
+ first ??= spec;
322
+ positions.push(...spec.position);
323
+ quaternions.push(...spec.quaternion);
324
+ }
325
+ camera = first;
326
+ animation = {
327
+ fps,
328
+ frameCount,
329
+ channels: [
330
+ {
331
+ target: { type: "camera" },
332
+ property: "position",
333
+ size: 3,
334
+ values: positions
335
+ },
336
+ {
337
+ target: { type: "camera" },
338
+ property: "quaternion",
339
+ size: 4,
340
+ values: quaternions
341
+ }
342
+ ]
343
+ };
344
+ } else if (shot.camera) {
345
+ camera = ownCamera(shot.camera, facts, aspect, product, shot.id);
346
+ } else {
347
+ camera = cameraForAngle(
348
+ angleFor(
349
+ shot.view ?? (room ? "room" : "three-quarter"),
350
+ room,
351
+ product,
352
+ shot.id
353
+ ),
354
+ bounds,
355
+ aspect,
356
+ lens,
357
+ room,
358
+ shot.id
359
+ );
360
+ }
361
+ const manifest = {
362
+ protocolVersion: protocolVersionFor({
363
+ ...room ? { room: room.spec } : {},
364
+ ...animation ? { animation } : {}
365
+ }),
366
+ sdkVersion: SDK_VERSION,
367
+ sceneAsset: imported.hash,
368
+ camera,
369
+ environment,
370
+ color,
371
+ ...room ? { room: room.spec } : {},
372
+ ...animation ? { animation } : {},
373
+ compatibility: { score: 1, issues: facts.issues, counts },
374
+ stats: {
375
+ triangles: facts.triangles + (room?.triangles ?? 0),
376
+ // A room is priced by how enclosed and how full it is, measured when
377
+ // it was published; the product adds one object to it. Without a room
378
+ // the shot is an open set, which needs saying nothing.
379
+ ...room?.complexity ? {
380
+ enclosure: room.complexity.enclosure,
381
+ objects: room.complexity.objects + 1
382
+ } : {}
383
+ },
384
+ source: { renderer: "catalog" }
385
+ };
386
+ const key = `${product.id}/${shot.id}${version ? `@${version}` : ""}`;
387
+ if (key.length > 200)
388
+ throw unreadable(
389
+ product,
390
+ `The key "${key.slice(0, 40)}\u2026" is over 200 characters. Use shorter ids.`
391
+ );
392
+ items.push({
393
+ key,
394
+ spec: {
395
+ manifest,
396
+ quality,
397
+ output,
398
+ ...shot.advanced ? { advanced: shot.advanced } : {},
399
+ ...video ? { preview: false } : {}
400
+ },
401
+ metadata: {
402
+ ...product.metadata,
403
+ variant: product.id,
404
+ shot: shot.id,
405
+ kind: shot.kind
406
+ },
407
+ ...run.maxCostPerImage !== void 0 ? { maxCost: run.maxCostPerImage } : {}
408
+ });
409
+ }
410
+ return items;
411
+ }
412
+ var SETTING_KEYS = [
413
+ "room",
414
+ "environment",
415
+ "background",
416
+ "quality",
417
+ "width",
418
+ "height",
419
+ "format",
420
+ "imageQuality",
421
+ "fov",
422
+ "margin",
423
+ "fps",
424
+ "advanced",
425
+ "color"
426
+ ];
427
+ function settingsOf(source) {
428
+ const out = {};
429
+ for (const key of SETTING_KEYS)
430
+ if (source[key] !== void 0)
431
+ out[key] = source[key];
432
+ return out;
433
+ }
434
+ function layered(key, ...layers) {
435
+ for (let i = layers.length - 1; i >= 0; i--) {
436
+ const value = layers[i]?.[key];
437
+ if (value !== void 0) return value;
438
+ }
439
+ return void 0;
440
+ }
441
+ function shotOf(shot) {
442
+ if (typeof shot === "string") return { view: shot };
443
+ if (shot && typeof shot === "object" && "azimuthDeg" in shot && !("view" in shot) && !("kind" in shot) && !("camera" in shot)) {
444
+ const { azimuthDeg, elevationDeg, ...rest } = shot;
445
+ return {
446
+ ...rest,
447
+ view: {
448
+ azimuthDeg,
449
+ ...elevationDeg !== void 0 ? { elevationDeg } : {}
450
+ }
451
+ };
452
+ }
453
+ return shot;
454
+ }
455
+ function resolveShot(shot, run, product, force) {
456
+ const own = shotOf(shot);
457
+ const settings = {};
458
+ for (const key of SETTING_KEYS) {
459
+ const value = layered(key, run, product, settingsOf(own), force);
460
+ if (value !== void 0) settings[key] = value;
461
+ }
462
+ if (own.kind === "video") {
463
+ return {
464
+ ...settings,
465
+ kind: "video",
466
+ id: own.id ?? `orbit-${viewName(own.from)}-${viewName(own.to)}`,
467
+ from: own.from,
468
+ to: own.to,
469
+ seconds: force?.seconds !== void 0 ? Math.min(force.seconds, own.seconds ?? 4) : own.seconds
470
+ };
471
+ }
472
+ const image = own;
473
+ return {
474
+ ...settings,
475
+ kind: "image",
476
+ id: image.id ?? (image.camera ? image.camera : viewName(image.view ?? (settings.room ? "room" : "three-quarter"))),
477
+ ...image.view !== void 0 ? { view: image.view } : {},
478
+ ...image.camera !== void 0 ? { camera: image.camera } : {}
479
+ };
480
+ }
481
+ function viewName(view) {
482
+ if (typeof view === "string") return view;
483
+ const el = view.elevationDeg;
484
+ return `az${Math.round(view.azimuthDeg)}${el !== void 0 ? `-el${Math.round(el)}` : ""}`.replace(
485
+ "--",
486
+ "-neg"
487
+ );
488
+ }
489
+ function angleFor(view, room, product, shot) {
490
+ if (view === "room") {
491
+ if (!room) {
492
+ throw new Bakery3Error({
493
+ code: "InvalidRequest",
494
+ message: `Shot "${shot}" of ${product.id} looks from 'room', but has no room.`,
495
+ fix: "Set `room: { preset: \u2026 }` on the run, the product or the shot, or use a view such as 'three-quarter'.",
496
+ path: `${product.id}/${shot}`,
497
+ docs: `${DOCS}#angles`
498
+ });
499
+ }
500
+ return room.published;
501
+ }
502
+ const angle = angleOf(view);
503
+ if (angle) return angle;
504
+ throw new Bakery3Error({
505
+ code: "InvalidRequest",
506
+ message: `Shot "${shot}" of ${product.id}: "${typeof view === "string" ? view : JSON.stringify(view)}" is not a view.`,
507
+ fix: `A view is one of ${Object.keys(VIEW_WORDS).join(", ")}, room, or \`{ azimuthDeg, elevationDeg? }\`. For a camera inside the model, use \`camera: '<node name>'\`.`,
508
+ path: `${product.id}/${shot}`,
509
+ docs: `${DOCS}#angles`
510
+ });
511
+ }
512
+ function cameraForAngle(angle, bounds, aspect, lens, room, name) {
513
+ if (room) return room.camera(angle, aspect, lens, name);
514
+ return framedCamera(
515
+ bounds,
516
+ {
517
+ id: name,
518
+ ...angle,
519
+ ...lens.fov !== void 0 ? { fov: lens.fov } : {},
520
+ ...lens.margin !== void 0 ? { margin: lens.margin } : {}
521
+ },
522
+ aspect
523
+ );
524
+ }
525
+ function ownCamera(name, facts, aspect, product, shot) {
526
+ const own = facts.cameras.find((camera) => camera.name === name);
527
+ if (own)
528
+ return {
529
+ ...own.camera,
530
+ ...own.camera.type === "perspective" ? { aspect } : {},
531
+ name: shot
532
+ };
533
+ const names = facts.cameras.map((camera) => camera.name);
534
+ throw new Bakery3Error({
535
+ code: "ImportFailed",
536
+ message: `Shot "${shot}": ${product.id}'s model has no camera named "${name}".`,
537
+ fix: names.length ? `Its cameras are: ${names.slice(0, 8).join(", ")}.` : "It carries no cameras. Use a view instead: an angle is framed from the model itself.",
538
+ path: `${product.id}/${shot}`,
539
+ docs: `${DOCS}#angles`
540
+ });
541
+ }
542
+ var DEFAULT_ENVIRONMENT = {
543
+ preset: "studio_small_03",
544
+ intensity: 0.6
545
+ };
546
+ var CLIP_BACKDROP = "#f4f4f5";
547
+ var EnvironmentCache = class {
548
+ constructor(host) {
549
+ this.host = host;
550
+ }
551
+ host;
552
+ hashes = /* @__PURE__ */ new Map();
553
+ async get(chosen, background) {
554
+ const environment = chosen ?? DEFAULT_ENVIRONMENT;
555
+ if (environment.preset && environment.url) {
556
+ throw new Bakery3Error({
557
+ code: "InvalidRequest",
558
+ message: "environment takes a `preset` or a `url`, not both.",
559
+ docs: `${DOCS}#light`
560
+ });
561
+ }
562
+ let hdri;
563
+ if (environment.url) {
564
+ const url = environment.url;
565
+ let pending = this.hashes.get(url);
566
+ if (!pending) {
567
+ pending = this.host.importAsset({ url, kind: "hdri" }).then((imported) => imported.hash);
568
+ this.hashes.set(url, pending);
569
+ pending.catch(() => this.hashes.delete(url));
570
+ }
571
+ hdri = await pending;
572
+ } else {
573
+ const name = environment.preset ?? "studio_small_03";
574
+ if (!(name in HDRI_PRESETS)) {
575
+ throw new Bakery3Error({
576
+ code: "InvalidRequest",
577
+ message: `There is no environment preset "${name}".`,
578
+ fix: `The presets are: ${Object.keys(HDRI_PRESETS).join(", ")}.`,
579
+ docs: `${DOCS}#light`
580
+ });
581
+ }
582
+ hdri = `${PRESET_LIBRARY}/v${PRESETS_VERSION}/hdri/${name}/render.exr`;
583
+ }
584
+ return {
585
+ hdri,
586
+ ...environment.intensity !== void 0 ? { intensity: environment.intensity } : {},
587
+ ...environment.rotationDeg ? {
588
+ rotation: [0, environment.rotationDeg * Math.PI / 180, 0]
589
+ } : {},
590
+ background: backgroundOf(background)
591
+ };
592
+ }
593
+ };
594
+ function backgroundOf(background) {
595
+ const chosen = background ?? "transparent";
596
+ if (chosen === "transparent") return { type: "transparent" };
597
+ if (chosen === "environment") return { type: "environment" };
598
+ if (!/^#[0-9a-f]{6}$/i.test(chosen)) {
599
+ throw new Bakery3Error({
600
+ code: "InvalidRequest",
601
+ message: `background "${chosen}" is not 'transparent', 'environment' or a #rrggbb color.`,
602
+ docs: `${DOCS}#light`
603
+ });
604
+ }
605
+ return { type: "color", color: chosen };
606
+ }
607
+ function roomBackground() {
608
+ return { background: { type: "transparent" } };
609
+ }
610
+ var STAND_RULES = {
611
+ fit: 0.85,
612
+ tallFrom: 0.6,
613
+ smallHeight: 0.6,
614
+ plinthHeight: 1
615
+ };
616
+ var PLINTH = "pedestal_plinth";
617
+ var ROOM_MARGIN = 0.9;
618
+ var WALL_CLEARANCE = 0.3;
619
+ var FIT_MARGIN = 0.12;
620
+ var MAX_ROOM_FOV = 100;
621
+ var ROOM_MIN_ELEVATION_DEG = 0;
622
+ function fovToFit(radius, distance, aspect) {
623
+ const half = Math.asin(Math.min(1, radius / distance));
624
+ const vertical = aspect >= 1 ? half : Math.atan(Math.tan(half) / aspect);
625
+ return 2 * vertical * 180 / Math.PI;
626
+ }
627
+ function standFor(room, info, bounds) {
628
+ const footprint = Math.max(
629
+ bounds.max[0] - bounds.min[0],
630
+ bounds.max[2] - bounds.min[2]
631
+ );
632
+ const height = bounds.max[1] - bounds.min[1];
633
+ const fits = (size) => {
634
+ if (!size) return false;
635
+ if (footprint > STAND_RULES.fit * Math.min(size.x, size.z)) return false;
636
+ return height <= (size.y >= STAND_RULES.tallFrom ? STAND_RULES.smallHeight : STAND_RULES.plinthHeight);
637
+ };
638
+ const choice = room.stand ?? "auto";
639
+ if (choice === "own" || choice === "auto" && fits(info.stand.size))
640
+ return { top: info.stand.top };
641
+ if (choice === "auto" && fits(PEDESTAL_PRESETS[PLINTH].size))
642
+ return { pedestal: { type: PLINTH }, top: PEDESTAL_PRESETS[PLINTH].size.y };
643
+ return { pedestal: null, top: 0 };
644
+ }
645
+ function roomFor(room, bounds) {
646
+ const info = ROOM_PRESETS[room.preset];
647
+ if (!info) {
648
+ throw new Bakery3Error({
649
+ code: "InvalidRequest",
650
+ message: `There is no room preset "${room.preset}".`,
651
+ fix: `The rooms are: ${Object.keys(ROOM_PRESETS).join(", ")}.`,
652
+ docs: `${DOCS}#light`
653
+ });
654
+ }
655
+ const lighting = room.mode ?? info.lighting;
656
+ const standing = standFor(room, info, bounds);
657
+ const stand = [info.stand.x, standing.top, info.stand.z];
658
+ const base = [
659
+ (bounds.min[0] + bounds.max[0]) / 2,
660
+ bounds.min[1],
661
+ (bounds.min[2] + bounds.max[2]) / 2
662
+ ];
663
+ const size = [
664
+ bounds.max[0] - bounds.min[0],
665
+ bounds.max[1] - bounds.min[1],
666
+ bounds.max[2] - bounds.min[2]
667
+ ];
668
+ const radius = radiusOf(bounds);
669
+ const line = [
670
+ info.camera.position[0] - info.camera.target[0],
671
+ info.camera.position[1] - info.camera.target[1],
672
+ info.camera.position[2] - info.camera.target[2]
673
+ ];
674
+ const designed = Math.hypot(line[0], line[1], line[2]);
675
+ const publishedAzimuth = Math.atan2(line[0], line[2]);
676
+ const viewFrom = (room.viewFromDeg ?? 30) * Math.PI / 180;
677
+ const theta = viewFrom - publishedAzimuth;
678
+ const c = Math.cos(theta);
679
+ const s = Math.sin(theta);
680
+ const place = ([x, y, z]) => {
681
+ const dx = x - stand[0];
682
+ const dz = z - stand[2];
683
+ return [
684
+ base[0] + dx * c + dz * s,
685
+ base[1] + (y - stand[1]),
686
+ base[2] - dx * s + dz * c
687
+ ];
688
+ };
689
+ const origin = place([0, 0, 0]);
690
+ const matrix = [
691
+ c,
692
+ 0,
693
+ -s,
694
+ 0,
695
+ 0,
696
+ 1,
697
+ 0,
698
+ 0,
699
+ s,
700
+ 0,
701
+ c,
702
+ 0,
703
+ origin[0],
704
+ origin[1],
705
+ origin[2],
706
+ 1
707
+ ];
708
+ const spec = {
709
+ name: room.preset,
710
+ build: info.build,
711
+ lighting,
712
+ ...standing.pedestal !== void 0 ? { pedestal: standing.pedestal } : {},
713
+ matrix
714
+ };
715
+ const published = {
716
+ azimuthDeg: viewFrom * 180 / Math.PI,
717
+ elevationDeg: Math.max(
718
+ ROOM_MIN_ELEVATION_DEG,
719
+ Math.atan2(line[1], Math.hypot(line[0], line[2])) * 180 / Math.PI
720
+ )
721
+ };
722
+ const lensFov = (aspect) => 2 * Math.atan((aspect >= 1 ? 36 / aspect : 36) / (2 * info.camera.lensMm)) * 180 / Math.PI;
723
+ return {
724
+ spec,
725
+ triangles: info.preview.triangles,
726
+ complexity: info.complexity,
727
+ exposure: info.exposure[lighting],
728
+ published,
729
+ /**
730
+ * A camera in the room from any angle: framed as in the open, from as
731
+ * far as the published camera stood when that is the view, and never
732
+ * behind a wall, the floor or the ceiling.
733
+ */
734
+ camera(angle, aspect, lens, name) {
735
+ const fov = lens.fov ?? lensFov(aspect);
736
+ const center = centerOf(bounds);
737
+ const direction = directionOf(angle);
738
+ const framed = framedDistance(
739
+ radius,
740
+ fov,
741
+ aspect,
742
+ lens.margin ?? ROOM_MARGIN
743
+ );
744
+ const isPublished = Math.abs(angle.azimuthDeg - published.azimuthDeg) < 1e-6 && Math.abs((angle.elevationDeg ?? 12) - published.elevationDeg) < 1e-6;
745
+ const rx = direction[0] * c - direction[2] * s;
746
+ const rz = direction[0] * s + direction[2] * c;
747
+ const ry = direction[1];
748
+ const from = [stand[0], stand[1] + size[1] / 2, stand[2]];
749
+ const half = [
750
+ info.size.width / 2 - WALL_CLEARANCE,
751
+ info.size.depth / 2 - WALL_CLEARANCE
752
+ ];
753
+ let reach = Infinity;
754
+ if (rx !== 0)
755
+ reach = Math.min(
756
+ reach,
757
+ ((rx > 0 ? half[0] : -half[0]) - from[0]) / rx
758
+ );
759
+ if (rz !== 0)
760
+ reach = Math.min(
761
+ reach,
762
+ ((rz > 0 ? half[1] : -half[1]) - from[2]) / rz
763
+ );
764
+ if (ry > 0)
765
+ reach = Math.min(
766
+ reach,
767
+ (info.size.height - WALL_CLEARANCE - from[1]) / ry
768
+ );
769
+ if (ry < 0) reach = Math.min(reach, (WALL_CLEARANCE - from[1]) / ry);
770
+ const wanted = Math.max(isPublished ? designed : 0, framed);
771
+ const distance = Math.max(radius * 1.2, Math.min(wanted, reach));
772
+ const lensFit = distance < framed ? fovToFit(radius * (1 + FIT_MARGIN), distance, aspect) : fov;
773
+ return cameraAt(
774
+ center,
775
+ radius,
776
+ direction,
777
+ distance,
778
+ Math.min(MAX_ROOM_FOV, Math.max(fov, lensFit)),
779
+ aspect,
780
+ name
781
+ );
782
+ }
783
+ };
784
+ }
785
+ function validateShots(shots, where) {
786
+ const ids = /* @__PURE__ */ new Set();
787
+ shots.forEach((shot, index) => {
788
+ if (!shot || typeof shot !== "string" && typeof shot !== "object")
789
+ throw badShot(where, index, "is not a shot");
790
+ const own = shotOf(shot);
791
+ if (own.kind === "video") {
792
+ if (angleOf(own.from) === null && own.from !== "room")
793
+ throw badShot(where, index, "`from` is not a view");
794
+ if (angleOf(own.to) === null && own.to !== "room")
795
+ throw badShot(where, index, "`to` is not a view");
796
+ if (own.seconds !== void 0 && !(own.seconds > 0 && own.seconds <= MAX_SECONDS))
797
+ throw badShot(
798
+ where,
799
+ index,
800
+ `\`seconds\` is outside 0 to ${MAX_SECONDS}`
801
+ );
802
+ } else if (own.kind !== void 0 && own.kind !== "image") {
803
+ throw badShot(
804
+ where,
805
+ index,
806
+ `kind "${String(own.kind)}" is not 'image' or 'video'`
807
+ );
808
+ } else if (own.view !== void 0 && own.camera !== void 0) {
809
+ throw badShot(where, index, "has both a `view` and a `camera`");
810
+ }
811
+ const id = own.id ?? (own.kind === "video" ? `orbit-${viewName(own.from)}-${viewName(own.to)}` : own.camera ?? (own.view !== void 0 ? viewName(own.view) : void 0));
812
+ if (id !== void 0) {
813
+ if (!/^[A-Za-z0-9_.-]{1,60}$/.test(id))
814
+ throw badShot(
815
+ where,
816
+ index,
817
+ `id "${id}" is not 1 to 60 letters, digits, dots, dashes or underscores`
818
+ );
819
+ if (ids.has(id)) throw badShot(where, index, `id "${id}" is used twice`);
820
+ ids.add(id);
821
+ }
822
+ });
823
+ }
824
+ function badShot(where, index, what) {
825
+ return new Bakery3Error({
826
+ code: "InvalidRequest",
827
+ message: `Shot ${index + 1} of ${where} ${what}.`,
828
+ why: 'A shot is a word, `{ view }`, or `{ kind: "video", from, to }`, plus settings, and its id ends the picture\u2019s key.',
829
+ docs: `${DOCS}#shots`
830
+ });
831
+ }
832
+ function validateProduct(product, seen) {
833
+ if (!product || typeof product.id !== "string" || !product.id || typeof product.url !== "string" || !product.url) {
834
+ throw new Bakery3Error({
835
+ code: "InvalidRequest",
836
+ message: "Every product needs an `id` and a `url`.",
837
+ why: "The id starts every shot\u2019s key: it is what a re-run adopts by and what a failure is reported under.",
838
+ docs: DOCS
839
+ });
840
+ }
841
+ if (seen.has(product.id)) {
842
+ throw new Bakery3Error({
843
+ code: "InvalidRequest",
844
+ message: `Two products are called "${product.id}".`,
845
+ why: "The second would be adopted as the first: same key, same picture.",
846
+ fix: "Make the ids unique.",
847
+ path: product.id,
848
+ docs: DOCS
849
+ });
850
+ }
851
+ seen.add(product.id);
852
+ }
853
+ function unreadable(product, message) {
854
+ return new Bakery3Error({
855
+ code: "ImportFailed",
856
+ message,
857
+ path: product.id,
858
+ docs: `${DOCS}#what-a-model-can-use`
859
+ });
860
+ }
861
+ function mostlyFailing(tally, attempted) {
862
+ const first = tally.skips[0];
863
+ return new Bakery3Error({
864
+ code: "ImportFailed",
865
+ message: `${tally.skipped} of the first ${attempted} models could not be imported. The first: ${first.id}: ${first.error.message}`,
866
+ why: "One bad model is skipped and listed. Most of them failing is something wrong with all of them: a base URL, a bucket that is not public, a format.",
867
+ fix: "Open one of the URLs in a private browser window. It has to download a .glb there. What was recorded stays admitted; call again with the same key to go on.",
868
+ docs: `${DOCS}#importing-by-url`
869
+ });
870
+ }
871
+ function abortError() {
872
+ const error = new Error("The recording was aborted.");
873
+ error.name = "AbortError";
874
+ return error;
875
+ }
876
+
877
+ // src/node/webhook.ts
878
+ var TOLERANCE_MS = 5 * 60 * 1e3;
879
+ async function verifyWebhook(input) {
880
+ const { body, signature, secret } = input;
881
+ const now = input.now ?? Date.now();
882
+ if (typeof secret !== "string" || !secret) {
883
+ throw new Bakery3Error({
884
+ code: "InvalidRequest",
885
+ message: "verifyWebhook() was given no secret.",
886
+ fix: "Pass the signing secret from your webhook settings, from a server-only environment variable: secret: process.env.BAKERY3_WEBHOOK_SECRET!",
887
+ docs: "https://bakery3.com/docs/webhooks"
888
+ });
889
+ }
890
+ if (typeof signature !== "string" || !signature) {
891
+ throw invalid(
892
+ 'The request has no bakery3-signature header. Pass request.headers.get("bakery3-signature").'
893
+ );
894
+ }
895
+ const parts = Object.fromEntries(
896
+ signature.split(",").map((piece) => {
897
+ const index = piece.indexOf("=");
898
+ return [piece.slice(0, index).trim(), piece.slice(index + 1).trim()];
899
+ })
900
+ );
901
+ const timestamp = Number(parts.t);
902
+ const provided = parts.v1;
903
+ if (!timestamp || !provided) {
904
+ throw invalid('Signature header is malformed. Expected "t=\u2026,v1=\u2026".');
905
+ }
906
+ if (Math.abs(now - timestamp) > TOLERANCE_MS) {
907
+ throw invalid("Signature timestamp is outside the five-minute tolerance.");
908
+ }
909
+ const key = await crypto.subtle.importKey(
910
+ "raw",
911
+ new TextEncoder().encode(secret),
912
+ { name: "HMAC", hash: "SHA-256" },
913
+ false,
914
+ ["sign"]
915
+ );
916
+ const mac = await crypto.subtle.sign(
917
+ "HMAC",
918
+ key,
919
+ new TextEncoder().encode(`${timestamp}.${body}`)
920
+ );
921
+ if (!timingSafeEqual(toHex(new Uint8Array(mac)), provided)) {
922
+ throw invalid("Signature does not match the body.");
923
+ }
924
+ return JSON.parse(body);
925
+ }
926
+ function invalid(message) {
927
+ return new Bakery3Error({
928
+ code: "Unauthorized",
929
+ message,
930
+ why: "The request did not carry a valid Bakery3 signature, so it may not have come from us.",
931
+ fix: "Pass the raw request body (not a re-serialized object) and the secret from your webhook settings.",
932
+ docs: "https://bakery3.com/docs/webhooks"
933
+ });
934
+ }
935
+ function timingSafeEqual(a, b) {
936
+ if (a.length !== b.length) return false;
937
+ let diff = 0;
938
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
939
+ return diff === 0;
940
+ }
941
+ function toHex(bytes) {
942
+ let out = "";
943
+ for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
944
+ return out;
945
+ }
946
+
947
+ // src/node/index.ts
948
+ var Bakery3Server = class {
949
+ tokens;
950
+ renders;
951
+ batches;
952
+ assets;
953
+ secretKey;
954
+ options;
955
+ transport;
956
+ /**
957
+ * @param secretKey Your `bk_sk_` key, as a string, from a server-only
958
+ * environment variable. Not the browser SDK's `{ apiKey }` object.
959
+ *
960
+ * The key is checked on the first request, not here. A route module that
961
+ * builds its client at the top level is evaluated by `next build`, often
962
+ * where the server's secrets are not set, and a constructor that threw
963
+ * there would fail the build over a key the build never uses.
964
+ */
965
+ constructor(secretKey, options = {}) {
966
+ if (secretKey != null && typeof secretKey !== "string") {
967
+ throw new Bakery3Error({
968
+ code: "InvalidRequest",
969
+ message: "new Bakery3Server() takes the secret key as a string, not an options object.",
970
+ fix: "new Bakery3Server(process.env.BAKERY3_SECRET_KEY!, options)",
971
+ docs: "https://bakery3.com/docs/auth"
972
+ });
973
+ }
974
+ this.secretKey = secretKey || void 0;
975
+ this.options = options;
976
+ if (this.secretKey) warnIfKeyIsExposed(this.secretKey, { source: "node" });
977
+ const client = () => this.client();
978
+ const request = async (method, path, body) => client().request(method, path, body);
979
+ this.tokens = new TokensApi(request);
980
+ this.renders = new RendersApi(request, client);
981
+ this.batches = new BatchesApi(client);
982
+ this.assets = new AssetsApi(client);
983
+ }
984
+ /**
985
+ * The same transport the browser SDK uses (one error shape, the same
986
+ * 429/503 backoff) with the secret key as the bearer. It has nothing to
987
+ * re-mint, so a 401 here is a 401. Made on first use, after the key is
988
+ * checked.
989
+ */
990
+ client() {
991
+ if (this.transport) return this.transport;
992
+ const secretKey = this.secretKey;
993
+ if (!secretKey) {
994
+ throw new Bakery3Error({
995
+ code: "Unauthorized",
996
+ message: "A secret key is required. Create one at https://bakery3.com/keys, then pass it: new Bakery3Server(process.env.BAKERY3_SECRET_KEY!).",
997
+ docs: "https://bakery3.com/docs/auth"
998
+ });
999
+ }
1000
+ if (secretKey.startsWith("bk_pt_")) {
1001
+ throw new Bakery3Error({
1002
+ code: "Unauthorized",
1003
+ message: "That is a browser token, not a secret key.",
1004
+ why: "Browser tokens (bk_pt_\u2026) are minted by this SDK. Secret keys (bk_sk_\u2026) create them.",
1005
+ fix: "Use your bk_sk_ key from https://bakery3.com/keys, kept in a server-only environment variable.",
1006
+ docs: "https://bakery3.com/docs/auth"
1007
+ });
1008
+ }
1009
+ if (!secretKey.startsWith("bk_sk_")) {
1010
+ throw new Bakery3Error({
1011
+ code: "Unauthorized",
1012
+ message: "That is not a secret key. Secret keys start with bk_sk_. Create one at https://bakery3.com/keys.",
1013
+ docs: "https://bakery3.com/docs/auth"
1014
+ });
1015
+ }
1016
+ this.transport = new Bakery3Client({
1017
+ apiKey: secretKey,
1018
+ ...this.options.baseUrl ? { baseUrl: this.options.baseUrl } : {},
1019
+ ...this.options.fetch ? { fetch: this.options.fetch } : {},
1020
+ ...this.options.retry ? { retry: this.options.retry } : {}
1021
+ });
1022
+ return this.transport;
1023
+ }
1024
+ /**
1025
+ * Render a catalog of models that already sit on a CDN: one call, no
1026
+ * browser, and none of the models' bytes through this process. Needs a
1027
+ * plan that includes imports (see https://bakery3.com/docs/catalog#plans).
1028
+ *
1029
+ * const run = await api.renderBatch({
1030
+ * key: 'fall-2026',
1031
+ * products: [{ id: 'chair-aria', url: 'https://cdn.shop.com/models/chair-aria.glb' }],
1032
+ * shots: ['three-quarter', 'front', { kind: 'video', from: 'front', to: 'back', seconds: 4 }],
1033
+ * room: { preset: 'loft_golden' },
1034
+ * maxCost: 600,
1035
+ * });
1036
+ * await run.wait(); // or let a webhook tell you, and exit now
1037
+ *
1038
+ * Resolves once every product is recorded and the run is sealed. The
1039
+ * shots render on from there; this process does not have to stay up.
1040
+ * A product whose model cannot be imported is skipped and listed in
1041
+ * `run.recorded.skips`, and the run goes on. Calling it again with the
1042
+ * same `key` resumes a recording that stopped, re-downloads no model that
1043
+ * did not change, and adopts every shot that already rendered.
1044
+ *
1045
+ * What a model by URL cannot carry (node materials, a shader-lit set) is
1046
+ * recorded where that code runs; see `renderVariants()` in `@oeave/bakery3`.
1047
+ */
1048
+ async renderBatch(options) {
1049
+ if (!(options.maxCost > 0)) {
1050
+ throw new Bakery3Error({
1051
+ code: "InvalidRequest",
1052
+ message: "renderBatch() needs a maxCost.",
1053
+ why: "A run is as many renders as products times shots, and a ceiling is what makes that number safe to be wrong about: a page that would cross it is refused, never charged.",
1054
+ fix: "Call quoteBatch() with the same options for the real number, or testBatch() to look at three products first.",
1055
+ docs: "https://bakery3.com/docs/catalog#what-it-will-cost"
1056
+ });
1057
+ }
1058
+ const batch = await this.openRun(options);
1059
+ const recorded = await recordBatch(this.client(), batch, options);
1060
+ await batch.close();
1061
+ return new RenderSet(batch, recorded);
1062
+ }
1063
+ /**
1064
+ * What `renderBatch()` would cost. The models are imported (that is how
1065
+ * they are priced); nothing is rendered or charged.
1066
+ */
1067
+ async quoteBatch(options) {
1068
+ const batch = await this.openRun({
1069
+ key: options.key && quoteKey(options.key),
1070
+ name: `${options.name ?? options.key} (quote)`,
1071
+ ...options.lane ? { lane: options.lane } : {}
1072
+ });
1073
+ try {
1074
+ return await recordBatch(this.client(), batch, options, {
1075
+ dryRun: true
1076
+ });
1077
+ } finally {
1078
+ await batch.cancel().catch(() => {
1079
+ });
1080
+ }
1081
+ }
1082
+ /**
1083
+ * Look before paying: three products (the first, the middle and the last
1084
+ * of an array; the first three of anything else), every shot, at preview
1085
+ * quality and a small size, in the ordinary lane so they come back in
1086
+ * about a minute. A clip is cut to two seconds at twelve frames. Same
1087
+ * options as `renderBatch()`. The test run has keys of its own, so the
1088
+ * real run never adopts these previews as finished shots.
1089
+ *
1090
+ * const test = await api.testBatch(options);
1091
+ * for (const shot of await test.result()) console.log(shot.variant, shot.shot, shot.url);
1092
+ */
1093
+ async testBatch(options, test = {}) {
1094
+ const edge = QUALITY[options.quality ?? "studio"].defaultLongEdge;
1095
+ const settings = testSettings(
1096
+ {
1097
+ key: options.key,
1098
+ width: options.width ?? edge,
1099
+ height: options.height ?? edge
1100
+ },
1101
+ test
1102
+ );
1103
+ const run = {
1104
+ ...options,
1105
+ key: settings.key,
1106
+ lane: settings.lane,
1107
+ maxCost: settings.maxCost,
1108
+ products: sampleOf(options.products, test.products),
1109
+ name: `${options.name ?? options.key} (test)`
1110
+ };
1111
+ const batch = await this.openRun(run);
1112
+ const recorded = await recordBatch(this.client(), batch, run, {
1113
+ force: {
1114
+ version: settings.version,
1115
+ quality: settings.quality,
1116
+ width: settings.width,
1117
+ height: settings.height,
1118
+ fps: 12,
1119
+ seconds: 2
1120
+ }
1121
+ });
1122
+ await batch.close();
1123
+ return new RenderSet(batch, recorded);
1124
+ }
1125
+ /**
1126
+ * A run by id, from another process, a webhook handler, or tomorrow
1127
+ * morning.
1128
+ */
1129
+ async getRenderSet(id) {
1130
+ const client = this.client();
1131
+ return new RenderSet(new Batch(client, await client.getBatch(id)));
1132
+ }
1133
+ async openRun(options) {
1134
+ if (!options.key) {
1135
+ throw new Bakery3Error({
1136
+ code: "InvalidRequest",
1137
+ message: "A run needs a key.",
1138
+ why: "The key names the run. It is what lets the same call resume a recording that stopped, instead of starting a second one.",
1139
+ fix: "Give it something stable that names this body of work: `key: 'fall-2026'`.",
1140
+ docs: "https://bakery3.com/docs/catalog"
1141
+ });
1142
+ }
1143
+ const client = this.client();
1144
+ return new Batch(
1145
+ client,
1146
+ await client.openBatch({
1147
+ key: options.key,
1148
+ ...options.name ? { name: options.name } : {},
1149
+ ...options.maxCost !== void 0 ? { maxCost: options.maxCost } : {},
1150
+ lane: options.lane ?? "batch",
1151
+ ...options.maxParallel !== void 0 ? { maxParallel: options.maxParallel } : {}
1152
+ })
1153
+ );
1154
+ }
1155
+ };
1156
+ var AssetsApi = class {
1157
+ constructor(client) {
1158
+ this.client = client;
1159
+ }
1160
+ client;
1161
+ /**
1162
+ * Have the service fetch a public https URL into the project's assets,
1163
+ * and say what it found: the hash to put in a manifest, and for a model
1164
+ * its triangles, bounds, cameras and anything the renderer cannot read.
1165
+ *
1166
+ * Secret key only, which is why it is here and not in the browser SDK.
1167
+ * What is fetched is fenced on the service's side: https, public
1168
+ * addresses only, no credentials, a size limit, and the file must be a
1169
+ * model or an HDRI. An unchanged URL is answered without a download
1170
+ * (`unchanged: true`).
1171
+ */
1172
+ async import(url, options = {}) {
1173
+ return this.client().importAsset({
1174
+ url,
1175
+ ...options.kind ? { kind: options.kind } : {}
1176
+ });
1177
+ }
1178
+ };
1179
+ var TokensApi = class {
1180
+ constructor(request) {
1181
+ this.request = request;
1182
+ }
1183
+ request;
1184
+ /**
1185
+ * Mint a browser token.
1186
+ *
1187
+ * Everything about it is a ceiling rather than a grant: it cannot outlive
1188
+ * its expiry, cannot start more renders than `maxRenders`, cannot spend past
1189
+ * `maxCost`, cannot be used from an origin outside `allowedOrigins`, and
1190
+ * cannot perform a single billing or admin operation. That last one is not
1191
+ * a policy: those routes require a `bk_sk_` key and this is not one.
1192
+ */
1193
+ create(options = {}) {
1194
+ return this.request("POST", "/v1/tokens", {
1195
+ expiresInSeconds: options.expiresInSeconds ?? 300,
1196
+ maxRenders: options.maxRenders ?? 5,
1197
+ maxCost: options.maxCost ?? 2,
1198
+ ...options.allowedOrigins ? { allowedOrigins: options.allowedOrigins } : {},
1199
+ ...options.subject ? { subject: options.subject } : {}
1200
+ });
1201
+ }
1202
+ /** Revoke a token before its expiry. */
1203
+ revoke(token) {
1204
+ return this.request("POST", "/v1/tokens/revoke", { token });
1205
+ }
1206
+ };
1207
+ var RendersApi = class {
1208
+ constructor(request, client) {
1209
+ this.request = request;
1210
+ this.client = client;
1211
+ }
1212
+ request;
1213
+ client;
1214
+ /**
1215
+ * Render from a manifest you already have: a batch job, a re-render, a
1216
+ * queued webhook. The browser flow is `@oeave/bakery3`; this is the API.
1217
+ * Repeated on a 429/503 only when `spec.idempotencyKey` makes that safe.
1218
+ */
1219
+ async create(spec) {
1220
+ return this.client().createRender(spec);
1221
+ }
1222
+ /** A render's record, with its state, cost and result URLs. */
1223
+ get(id) {
1224
+ return this.request("GET", `/v1/renders/${id}`);
1225
+ }
1226
+ /** Cancel a render that has not finished. Already terminal is a no-op. */
1227
+ cancel(id) {
1228
+ return this.request("POST", `/v1/renders/${id}/cancel`, {});
1229
+ }
1230
+ /**
1231
+ * One more attempt for a render that failed with `attemptsExhausted`.
1232
+ * Returns the new render, linked to the old one; a render that is not in
1233
+ * that state answers 409.
1234
+ */
1235
+ async retry(id) {
1236
+ return this.client().retryRender(id);
1237
+ }
1238
+ /**
1239
+ * Delete a render now rather than when retention would: the result, the
1240
+ * preview, and every signed URL that pointed at them. The record survives
1241
+ * as `expired`, so the receipt is still readable. A live render is
1242
+ * cancelled first.
1243
+ */
1244
+ delete(id) {
1245
+ return this.request("DELETE", `/v1/renders/${id}`);
1246
+ }
1247
+ /** The project's renders, newest first, a page at a time. */
1248
+ list(options = {}) {
1249
+ const query = new URLSearchParams();
1250
+ if (options.limit) query.set("limit", String(options.limit));
1251
+ if (options.cursor) query.set("cursor", options.cursor);
1252
+ const suffix = query.toString() ? `?${query}` : "";
1253
+ return this.request("GET", `/v1/renders${suffix}`);
1254
+ }
1255
+ /** The quote for a spec: a likely cost and a maximum, in USD. */
1256
+ estimate(spec) {
1257
+ return this.request("POST", "/v1/estimate", spec);
1258
+ }
1259
+ };
1260
+ var BatchesApi = class {
1261
+ constructor(client) {
1262
+ this.client = client;
1263
+ }
1264
+ client;
1265
+ /** Open, or re-open by key. */
1266
+ async open(request) {
1267
+ const client = this.client();
1268
+ return new Batch(client, await client.openBatch(request));
1269
+ }
1270
+ /** A batch by id, with its latest counts. */
1271
+ async get(id) {
1272
+ const client = this.client();
1273
+ return new Batch(client, await client.getBatch(id));
1274
+ }
1275
+ };
1276
+
1277
+ export { Bakery3Server, verifyWebhook };
1278
+ //# sourceMappingURL=index.js.map
1279
+ //# sourceMappingURL=index.js.map