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