@nodaro/shared 2.24.0 → 2.27.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.
package/src/scene3d.ts ADDED
@@ -0,0 +1,615 @@
1
+ /**
2
+ * Scene3D previsualization — the frozen wire contract (v1).
3
+ *
4
+ * ONE validated `Scene3DPlan` revision is shared by four consumers that must
5
+ * never disagree about what a scene IS: the authoring LLM jobs
6
+ * (`backend/src/routes/3d-scene.ts` + its worker), the browser preview and the
7
+ * frame-deterministic Remotion export (`packages/remotion`), the canvas
8
+ * (`frontend/`), and the SDK/MCP surface. Everything here is STRUCTURE —
9
+ * geometry, timing, identity, validation. No creative prompts, no provider
10
+ * names, no pricing: those live in the backend (see the IP-placement rule in
11
+ * the repo CLAUDE.md — this package is published Apache-2.0 and every
12
+ * published version is an irrevocable grant).
13
+ *
14
+ * ## World conventions (fixed for v1, relied on by the renderer)
15
+ *
16
+ * - Units are METERS, Y is up, right-handed (Three.js default).
17
+ * - Rotations are Euler angles in RADIANS applied XYZ.
18
+ * - Frames are ZERO-BASED; `fps` defaults to 24 at the authoring layer.
19
+ * - Interpolation between keyframes is deterministic and closed-form:
20
+ * `linear` or `easeInOut` (smoothstep). There is no spring, no physics and
21
+ * no randomness — the browser preview and the export MUST agree frame for
22
+ * frame, so nothing here may depend on wall-clock time or a RNG.
23
+ * - A channel's BASE value (the object's/camera's own `position`/`rotation`/
24
+ * `scale`/`target`/`focalLengthMm`) behaves as an implicit keyframe at frame
25
+ * 0. So a track whose first key is at frame 30 INTERPOLATES from the base
26
+ * value at frame 0 to that key — it does not hold the base and then jump.
27
+ * Author a real key at frame 0 when you want a hold. Before frame 0 and
28
+ * after the last key the nearest key's value is held.
29
+ * - `easing` belongs to the DESTINATION keyframe: the easing named on a key
30
+ * governs the segment ENDING at it. The easing on the first key therefore
31
+ * governs base → first key; a key's own easing never affects the segment
32
+ * leaving it.
33
+ * Sampling itself lives with the renderer (`packages/remotion`) — this file
34
+ * only guarantees the data it samples is well-formed.
35
+ *
36
+ * ## Revisions
37
+ *
38
+ * A plan is IMMUTABLE. Every accepted edit produces a NEW `revisionId` and
39
+ * records the one it came from in `parentRevisionId`; the input object is
40
+ * never mutated (deep-copied before any write). That is what lets an
41
+ * asynchronous job completion be REJECTED when the canvas has moved on — the
42
+ * completion carries the parent it was computed from.
43
+ */
44
+ import { z } from "zod"
45
+
46
+ /** Discriminates a Scene3D plan from every other composer plan on the wire. */
47
+ export const SCENE3D_PLAN_TYPE = "3d-scene"
48
+ /** Bumped only for a BREAKING change to the shape below. */
49
+ export const SCENE3D_SCHEMA_VERSION = 1
50
+ /** Frames per second an authoring request gets when it does not say. */
51
+ export const SCENE3D_DEFAULT_FPS = 24
52
+ /** Seconds a generate request gets when it does not say. */
53
+ export const SCENE3D_DEFAULT_DURATION_SECONDS = 4
54
+
55
+ /**
56
+ * Every bound in one object so the route Zod, the canvas inputs, the LLM
57
+ * draft schema and the docs quote the SAME numbers. Widening one of these is
58
+ * a contract change, not a tweak.
59
+ */
60
+ export const SCENE3D_LIMITS = {
61
+ minDimensionPx: 100,
62
+ maxDimensionPx: 1920,
63
+ minFps: 15,
64
+ maxFps: 60,
65
+ minDurationInFrames: 1,
66
+ maxDurationInFrames: 3600,
67
+ /** Shortest scene an authoring request may ask for, in seconds. One second
68
+ * is the frozen v1 floor the SDK/MCP surface and the public docs state; the
69
+ * route Zod and the canvas path both quote it from here so they cannot
70
+ * drift below it. */
71
+ minDurationSeconds: 1,
72
+ /** Hard ceiling on wall-clock length, checked against fps × frames. */
73
+ maxDurationSeconds: 60,
74
+ minObjects: 1,
75
+ maxObjects: 100,
76
+ /** Per-object and per-camera track length. */
77
+ maxKeyframes: 240,
78
+ maxReferences: 8,
79
+ maxOperations: 100,
80
+ /** |x|, |y|, |z| ceiling for positions and camera/target coordinates. */
81
+ maxCoordinate: 1000,
82
+ minSize: 0.001,
83
+ maxSize: 1000,
84
+ minScale: 0.001,
85
+ maxScale: 1000,
86
+ minFocalLengthMm: 10,
87
+ maxFocalLengthMm: 200,
88
+ defaultSensorWidthMm: 36,
89
+ minSensorWidthMm: 1,
90
+ maxSensorWidthMm: 200,
91
+ maxIntensity: 100,
92
+ /** How deep a parent chain may nest. Bounds the renderer's transform walk. */
93
+ maxHierarchyDepth: 8,
94
+ maxIdLength: 64,
95
+ maxNameLength: 120,
96
+ maxUrlLength: 2048,
97
+ maxChangeSummaryLength: 2000,
98
+ } as const
99
+
100
+ export type Vec3 = [number, number, number]
101
+
102
+ export type Scene3DPrimitive =
103
+ | "box"
104
+ | "sphere"
105
+ | "cylinder"
106
+ | "cone"
107
+ | "plane"
108
+ | "capsule"
109
+ /** A transform-only node: no geometry of its own, children inherit it. */
110
+ | "group"
111
+
112
+ export const SCENE3D_PRIMITIVES: readonly Scene3DPrimitive[] = [
113
+ "box",
114
+ "sphere",
115
+ "cylinder",
116
+ "cone",
117
+ "plane",
118
+ "capsule",
119
+ "group",
120
+ ]
121
+
122
+ export type Scene3DEasing = "linear" | "easeInOut"
123
+
124
+ export type Scene3DReferenceKind = "image" | "video"
125
+
126
+ /**
127
+ * What the reference is FOR. The role is not decoration: it decides how the
128
+ * authoring backend conditions on the asset (appearance → colour/material
129
+ * cues, layout → placement, motion → timing), and it is stored with the job
130
+ * so a re-run reproduces the same conditioning.
131
+ */
132
+ export type Scene3DReferenceRole = "appearance" | "layout" | "motion"
133
+
134
+ export interface Scene3DObjectKeyframe {
135
+ /** Zero-based, integral, inside the scene's duration. */
136
+ frame: number
137
+ position?: Vec3
138
+ rotation?: Vec3
139
+ scale?: Vec3
140
+ easing?: Scene3DEasing
141
+ }
142
+
143
+ export interface Scene3DCameraKeyframe {
144
+ frame: number
145
+ position?: Vec3
146
+ target?: Vec3
147
+ focalLengthMm?: number
148
+ easing?: Scene3DEasing
149
+ }
150
+
151
+ export interface Scene3DObject {
152
+ id: string
153
+ name: string
154
+ primitive: Scene3DPrimitive
155
+ /** Transform parent. Absent = a root object. Cycles are rejected. */
156
+ parentId?: string
157
+ /** Intrinsic size in meters BEFORE `scale` (width/height/depth). */
158
+ dimensions: Vec3
159
+ position: Vec3
160
+ /** Euler XYZ, radians. */
161
+ rotation: Vec3
162
+ scale: Vec3
163
+ /** `#rgb`, `#rgba`, `#rrggbb` or `#rrggbbaa`. */
164
+ color: string
165
+ keyframes?: Scene3DObjectKeyframe[]
166
+ }
167
+
168
+ export interface Scene3DCamera {
169
+ position: Vec3
170
+ target: Vec3
171
+ focalLengthMm: number
172
+ /** Full-frame 36mm by default; together with focal length it fixes the FOV. */
173
+ sensorWidthMm: number
174
+ keyframes?: Scene3DCameraKeyframe[]
175
+ }
176
+
177
+ export interface Scene3DLighting {
178
+ ambientIntensity: number
179
+ keyIntensity: number
180
+ keyPosition: Vec3
181
+ }
182
+
183
+ export interface Scene3DReference {
184
+ id: string
185
+ /** HTTP(S) only — the BACKEND additionally applies `safeUrlSchema` and the
186
+ * platform's per-model reference limits before anything is fetched. */
187
+ url: string
188
+ kind: Scene3DReferenceKind
189
+ role: Scene3DReferenceRole
190
+ /** Scopes the reference to one object instead of the whole scene. */
191
+ objectId?: string
192
+ /** Window inside a video reference, in seconds. */
193
+ startSeconds?: number
194
+ endSeconds?: number
195
+ }
196
+
197
+ export interface Scene3DPlan {
198
+ planType: typeof SCENE3D_PLAN_TYPE
199
+ schemaVersion: typeof SCENE3D_SCHEMA_VERSION
200
+ /** UUID. Changes on EVERY accepted edit. */
201
+ revisionId: string
202
+ /** The revision this one was derived from; absent on a first generation. */
203
+ parentRevisionId?: string
204
+ width: number
205
+ height: number
206
+ fps: number
207
+ durationInFrames: number
208
+ backgroundColor: string
209
+ camera: Scene3DCamera
210
+ objects: Scene3DObject[]
211
+ lighting: Scene3DLighting
212
+ references?: Scene3DReference[]
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Primitive schemas
217
+ // ---------------------------------------------------------------------------
218
+
219
+ /** Tuple form, matching `backend/src/lib/plan-schemas.ts`'s `vec3Schema`.
220
+ * `z.number()` already rejects NaN and ±Infinity in zod 4, so "finite triple"
221
+ * needs no extra check — only the magnitude bound below. */
222
+ function boundedVec3(max: number, label: string) {
223
+ return z.tuple([
224
+ z.number().min(-max).max(max),
225
+ z.number().min(-max).max(max),
226
+ z.number().min(-max).max(max),
227
+ ]).describe(label)
228
+ }
229
+
230
+ export const vec3Schema = boundedVec3(SCENE3D_LIMITS.maxCoordinate, "position triple (meters)")
231
+
232
+ export const sizeVec3Schema = z.tuple([
233
+ z.number().min(SCENE3D_LIMITS.minSize).max(SCENE3D_LIMITS.maxSize),
234
+ z.number().min(SCENE3D_LIMITS.minSize).max(SCENE3D_LIMITS.maxSize),
235
+ z.number().min(SCENE3D_LIMITS.minSize).max(SCENE3D_LIMITS.maxSize),
236
+ ])
237
+
238
+ export const scaleVec3Schema = z.tuple([
239
+ z.number().min(SCENE3D_LIMITS.minScale).max(SCENE3D_LIMITS.maxScale),
240
+ z.number().min(SCENE3D_LIMITS.minScale).max(SCENE3D_LIMITS.maxScale),
241
+ z.number().min(SCENE3D_LIMITS.minScale).max(SCENE3D_LIMITS.maxScale),
242
+ ])
243
+
244
+ /** Euler radians. Bounded well past ±2π so multi-turn spins stay expressible
245
+ * while a runaway value still cannot reach the renderer. */
246
+ export const rotationVec3Schema = boundedVec3(1000, "euler XYZ (radians)")
247
+
248
+ /**
249
+ * OPAQUE hex only — 3 or 6 digits.
250
+ *
251
+ * Not a style preference: the renderer builds every colour with
252
+ * `new THREE.Color(hex)`, which understands `#abc` and `#aabbcc` and nothing
253
+ * else. A 4- or 8-digit value (the CSS `#rgba` / `#rrggbbaa` spelling) is not
254
+ * refused there — it warns and falls back to WHITE, so an alpha the contract
255
+ * accepted would silently repaint an object in the export. Transparency is not
256
+ * part of the v1 material model; when it becomes one it will be its own field,
257
+ * not a suffix on this string.
258
+ */
259
+ const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/
260
+
261
+ export const scene3DColorSchema = z
262
+ .string()
263
+ .regex(HEX_COLOR, "color must be an opaque hex string such as #4f8ef7 (3 or 6 digits; alpha is not supported)")
264
+
265
+ /** Renderer-safe identifier: it keys React elements and object maps. */
266
+ const SCENE3D_ID = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/
267
+
268
+ export const scene3DIdSchema = z
269
+ .string()
270
+ .min(1)
271
+ .max(SCENE3D_LIMITS.maxIdLength)
272
+ .regex(SCENE3D_ID, "id must start alphanumeric and contain only letters, digits, '_', '-' or '.'")
273
+
274
+ /** HTTP(S) only. This is the STRUCTURAL half of URL safety; the backend adds
275
+ * `safeUrlSchema` (SSRF host rules) on top before anything is fetched. */
276
+ export function isScene3DHttpUrl(value: string): boolean {
277
+ try {
278
+ const parsed = new URL(value)
279
+ return parsed.protocol === "http:" || parsed.protocol === "https:"
280
+ } catch {
281
+ return false
282
+ }
283
+ }
284
+
285
+ export const scene3DUrlSchema = z
286
+ .string()
287
+ .min(1)
288
+ .max(SCENE3D_LIMITS.maxUrlLength)
289
+ .refine(isScene3DHttpUrl, "url must be an http(s) URL")
290
+
291
+ export const scene3DEasingSchema = z.enum(["linear", "easeInOut"])
292
+
293
+ export const scene3DPrimitiveSchema = z.enum([
294
+ "box",
295
+ "sphere",
296
+ "cylinder",
297
+ "cone",
298
+ "plane",
299
+ "capsule",
300
+ "group",
301
+ ])
302
+
303
+ const frameSchema = z.number().int().min(0).max(SCENE3D_LIMITS.maxDurationInFrames)
304
+
305
+ export const scene3DObjectKeyframeSchema = z
306
+ .object({
307
+ frame: frameSchema,
308
+ position: vec3Schema.optional(),
309
+ rotation: rotationVec3Schema.optional(),
310
+ scale: scaleVec3Schema.optional(),
311
+ easing: scene3DEasingSchema.optional(),
312
+ })
313
+ .strict()
314
+
315
+ export const scene3DCameraKeyframeSchema = z
316
+ .object({
317
+ frame: frameSchema,
318
+ position: vec3Schema.optional(),
319
+ target: vec3Schema.optional(),
320
+ focalLengthMm: z
321
+ .number()
322
+ .min(SCENE3D_LIMITS.minFocalLengthMm)
323
+ .max(SCENE3D_LIMITS.maxFocalLengthMm)
324
+ .optional(),
325
+ easing: scene3DEasingSchema.optional(),
326
+ })
327
+ .strict()
328
+
329
+ export const scene3DObjectSchema = z
330
+ .object({
331
+ id: scene3DIdSchema,
332
+ name: z.string().min(1).max(SCENE3D_LIMITS.maxNameLength),
333
+ primitive: scene3DPrimitiveSchema,
334
+ parentId: scene3DIdSchema.optional(),
335
+ dimensions: sizeVec3Schema,
336
+ position: vec3Schema,
337
+ rotation: rotationVec3Schema,
338
+ scale: scaleVec3Schema,
339
+ color: scene3DColorSchema,
340
+ keyframes: z.array(scene3DObjectKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
341
+ })
342
+ .strict()
343
+
344
+ export const scene3DCameraSchema = z
345
+ .object({
346
+ position: vec3Schema,
347
+ target: vec3Schema,
348
+ focalLengthMm: z
349
+ .number()
350
+ .min(SCENE3D_LIMITS.minFocalLengthMm)
351
+ .max(SCENE3D_LIMITS.maxFocalLengthMm),
352
+ sensorWidthMm: z
353
+ .number()
354
+ .min(SCENE3D_LIMITS.minSensorWidthMm)
355
+ .max(SCENE3D_LIMITS.maxSensorWidthMm)
356
+ .default(SCENE3D_LIMITS.defaultSensorWidthMm),
357
+ keyframes: z.array(scene3DCameraKeyframeSchema).max(SCENE3D_LIMITS.maxKeyframes).optional(),
358
+ })
359
+ .strict()
360
+
361
+ export const scene3DLightingSchema = z
362
+ .object({
363
+ ambientIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity),
364
+ keyIntensity: z.number().min(0).max(SCENE3D_LIMITS.maxIntensity),
365
+ keyPosition: vec3Schema,
366
+ })
367
+ .strict()
368
+
369
+ export const scene3DReferenceSchema = z
370
+ .object({
371
+ id: scene3DIdSchema,
372
+ url: scene3DUrlSchema,
373
+ kind: z.enum(["image", "video"]),
374
+ role: z.enum(["appearance", "layout", "motion"]),
375
+ objectId: scene3DIdSchema.optional(),
376
+ startSeconds: z.number().min(0).max(86_400).optional(),
377
+ endSeconds: z.number().min(0).max(86_400).optional(),
378
+ })
379
+ .strict()
380
+
381
+ // ---------------------------------------------------------------------------
382
+ // Semantic (cross-field) validation
383
+ // ---------------------------------------------------------------------------
384
+
385
+ interface SemanticIssue {
386
+ path: (string | number)[]
387
+ message: string
388
+ }
389
+
390
+ function checkKeyframeTrack(
391
+ frames: readonly { frame: number }[],
392
+ durationInFrames: number,
393
+ path: (string | number)[],
394
+ issues: SemanticIssue[],
395
+ ): void {
396
+ let previous = -1
397
+ frames.forEach((kf, index) => {
398
+ if (kf.frame > durationInFrames - 1) {
399
+ issues.push({
400
+ path: [...path, index, "frame"],
401
+ message: `frame ${kf.frame} is past the scene's last frame (${durationInFrames - 1})`,
402
+ })
403
+ }
404
+ if (kf.frame === previous) {
405
+ issues.push({ path: [...path, index, "frame"], message: `duplicate keyframe at frame ${kf.frame}` })
406
+ } else if (kf.frame < previous) {
407
+ issues.push({
408
+ path: [...path, index, "frame"],
409
+ message: `keyframes must be sorted by frame (${kf.frame} follows ${previous})`,
410
+ })
411
+ }
412
+ previous = kf.frame
413
+ })
414
+ }
415
+
416
+ /**
417
+ * Every rule that needs more than one field: duration, identity, hierarchy,
418
+ * reference resolution and keyframe tracks.
419
+ *
420
+ * Split out of the schema's `superRefine` so `applyScene3DEditOperations` can
421
+ * report the SAME sentences without re-parsing, and so a caller holding an
422
+ * already-parsed plan can re-check it cheaply.
423
+ */
424
+ export function scene3DPlanIssues(plan: Scene3DPlan): SemanticIssue[] {
425
+ const issues: SemanticIssue[] = []
426
+
427
+ const seconds = plan.durationInFrames / plan.fps
428
+ if (seconds > SCENE3D_LIMITS.maxDurationSeconds) {
429
+ issues.push({
430
+ path: ["durationInFrames"],
431
+ message: `scene is ${seconds.toFixed(2)}s; the limit is ${SCENE3D_LIMITS.maxDurationSeconds}s`,
432
+ })
433
+ }
434
+
435
+ const byId = new Map<string, Scene3DObject>()
436
+ plan.objects.forEach((object, index) => {
437
+ if (byId.has(object.id)) {
438
+ issues.push({ path: ["objects", index, "id"], message: `duplicate object id "${object.id}"` })
439
+ return
440
+ }
441
+ byId.set(object.id, object)
442
+ })
443
+
444
+ plan.objects.forEach((object, index) => {
445
+ if (object.parentId === undefined) return
446
+ if (object.parentId === object.id) {
447
+ issues.push({ path: ["objects", index, "parentId"], message: `object "${object.id}" cannot parent itself` })
448
+ return
449
+ }
450
+ if (!byId.has(object.parentId)) {
451
+ issues.push({
452
+ path: ["objects", index, "parentId"],
453
+ message: `object "${object.id}" references unknown parent "${object.parentId}"`,
454
+ })
455
+ return
456
+ }
457
+ // Walk to the root: a cycle repeats an id, and a legal chain is bounded.
458
+ const seen = new Set<string>([object.id])
459
+ let cursor: Scene3DObject | undefined = byId.get(object.parentId)
460
+ let depth = 1
461
+ while (cursor) {
462
+ if (seen.has(cursor.id)) {
463
+ issues.push({
464
+ path: ["objects", index, "parentId"],
465
+ message: `parent cycle through object "${cursor.id}"`,
466
+ })
467
+ break
468
+ }
469
+ seen.add(cursor.id)
470
+ depth += 1
471
+ if (depth > SCENE3D_LIMITS.maxHierarchyDepth) {
472
+ issues.push({
473
+ path: ["objects", index, "parentId"],
474
+ message: `hierarchy deeper than ${SCENE3D_LIMITS.maxHierarchyDepth} levels`,
475
+ })
476
+ break
477
+ }
478
+ cursor = cursor.parentId === undefined ? undefined : byId.get(cursor.parentId)
479
+ }
480
+ })
481
+
482
+ plan.objects.forEach((object, index) => {
483
+ if (object.keyframes && object.keyframes.length > 0) {
484
+ checkKeyframeTrack(object.keyframes, plan.durationInFrames, ["objects", index, "keyframes"], issues)
485
+ }
486
+ })
487
+ if (plan.camera.keyframes && plan.camera.keyframes.length > 0) {
488
+ checkKeyframeTrack(plan.camera.keyframes, plan.durationInFrames, ["camera", "keyframes"], issues)
489
+ }
490
+
491
+ const referenceIds = new Set<string>()
492
+ ;(plan.references ?? []).forEach((reference, index) => {
493
+ if (referenceIds.has(reference.id)) {
494
+ issues.push({ path: ["references", index, "id"], message: `duplicate reference id "${reference.id}"` })
495
+ }
496
+ referenceIds.add(reference.id)
497
+ if (reference.objectId !== undefined && !byId.has(reference.objectId)) {
498
+ issues.push({
499
+ path: ["references", index, "objectId"],
500
+ message: `reference "${reference.id}" points at unknown object "${reference.objectId}"`,
501
+ })
502
+ }
503
+ if (
504
+ reference.startSeconds !== undefined &&
505
+ reference.endSeconds !== undefined &&
506
+ reference.endSeconds <= reference.startSeconds
507
+ ) {
508
+ issues.push({
509
+ path: ["references", index, "endSeconds"],
510
+ message: `reference "${reference.id}" ends at or before it starts`,
511
+ })
512
+ }
513
+ if (reference.kind === "image" && (reference.startSeconds !== undefined || reference.endSeconds !== undefined)) {
514
+ issues.push({
515
+ path: ["references", index, "startSeconds"],
516
+ message: `reference "${reference.id}" is an image; a time window applies to video only`,
517
+ })
518
+ }
519
+ })
520
+
521
+ return issues
522
+ }
523
+
524
+ /**
525
+ * THE plan validator. Structure first (zod), then the cross-field rules — a
526
+ * consumer that parses with this cannot be handed a cycle, a dangling parent,
527
+ * an out-of-range keyframe or a 90-second "one-minute-max" scene.
528
+ */
529
+ export const scene3DPlanSchema = z
530
+ .object({
531
+ planType: z.literal(SCENE3D_PLAN_TYPE),
532
+ schemaVersion: z.literal(SCENE3D_SCHEMA_VERSION),
533
+ revisionId: z.uuid(),
534
+ parentRevisionId: z.uuid().optional(),
535
+ width: z.number().int().min(SCENE3D_LIMITS.minDimensionPx).max(SCENE3D_LIMITS.maxDimensionPx),
536
+ height: z.number().int().min(SCENE3D_LIMITS.minDimensionPx).max(SCENE3D_LIMITS.maxDimensionPx),
537
+ fps: z.number().int().min(SCENE3D_LIMITS.minFps).max(SCENE3D_LIMITS.maxFps),
538
+ durationInFrames: z
539
+ .number()
540
+ .int()
541
+ .min(SCENE3D_LIMITS.minDurationInFrames)
542
+ .max(SCENE3D_LIMITS.maxDurationInFrames),
543
+ backgroundColor: scene3DColorSchema,
544
+ camera: scene3DCameraSchema,
545
+ objects: z.array(scene3DObjectSchema).min(SCENE3D_LIMITS.minObjects).max(SCENE3D_LIMITS.maxObjects),
546
+ lighting: scene3DLightingSchema,
547
+ references: z.array(scene3DReferenceSchema).max(SCENE3D_LIMITS.maxReferences).optional(),
548
+ })
549
+ .strict()
550
+ .superRefine((plan, ctx) => {
551
+ for (const issue of scene3DPlanIssues(plan as Scene3DPlan)) {
552
+ ctx.addIssue({ code: "custom", path: issue.path, message: issue.message })
553
+ }
554
+ })
555
+
556
+ /** Order-insensitive deep equality over the JSON subset a plan is made of. */
557
+ export function scene3DDeepEqual(a: unknown, b: unknown): boolean {
558
+ if (a === b) return true
559
+ if (typeof a !== typeof b) return false
560
+ if (a === null || b === null || typeof a !== "object") return false
561
+ if (Array.isArray(a) !== Array.isArray(b)) return false
562
+ if (Array.isArray(a) && Array.isArray(b)) {
563
+ if (a.length !== b.length) return false
564
+ return a.every((item, i) => scene3DDeepEqual(item, b[i]))
565
+ }
566
+ const aObj = a as Record<string, unknown>
567
+ const bObj = b as Record<string, unknown>
568
+ const aKeys = Object.keys(aObj)
569
+ const bKeys = Object.keys(bObj)
570
+ if (aKeys.length !== bKeys.length) return false
571
+ return aKeys.every((key) => key in bObj && scene3DDeepEqual(aObj[key], bObj[key]))
572
+ }
573
+
574
+ /** RFC-4122 v4 id, from the platform CSPRNG where there is one. Browser,
575
+ * Node 18+ and the Remotion renderer all expose `globalThis.crypto`. */
576
+ export function newScene3DRevisionId(): string {
577
+ const webCrypto = (globalThis as { crypto?: Crypto }).crypto
578
+ if (webCrypto && typeof webCrypto.randomUUID === "function") return webCrypto.randomUUID()
579
+ const bytes = new Uint8Array(16)
580
+ if (webCrypto && typeof webCrypto.getRandomValues === "function") {
581
+ webCrypto.getRandomValues(bytes)
582
+ } else {
583
+ for (let i = 0; i < bytes.length; i++) bytes[i] = Math.floor(Math.random() * 256)
584
+ }
585
+ bytes[6] = (bytes[6] & 0x0f) | 0x40
586
+ bytes[8] = (bytes[8] & 0x3f) | 0x80
587
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
588
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
589
+ }
590
+
591
+ /** Narrowing helper for callers holding `unknown` (job output, workflow JSON). */
592
+ export function isScene3DPlan(value: unknown): value is Scene3DPlan {
593
+ return scene3DPlanSchema.safeParse(value).success
594
+ }
595
+
596
+ // ---------------------------------------------------------------------------
597
+ // Job / route wire shapes
598
+ // ---------------------------------------------------------------------------
599
+
600
+ /** What a finished `generate-3d-scene` / `edit-3d-scene` job carries in
601
+ * `output_data`. The canvas, the SDK and the DAG output extractor all read
602
+ * THIS shape — `scenePlan` is also the node's stored plan field. */
603
+ export interface Scene3DJobOutput {
604
+ scenePlan: Scene3DPlan
605
+ /** One paragraph naming what changed. Absent on a first generation. */
606
+ changeSummary?: string
607
+ }
608
+
609
+ /** The node data field a Scene3D plan is stored under, on both nodes.
610
+ * `COMPOSER_PLAN_MAP` (model-constants.ts) must agree with this. */
611
+ export const SCENE3D_PLAN_FIELD = "scenePlan"
612
+
613
+ /** The two canvas node types that produce a Scene3D plan. */
614
+ export const SCENE3D_GENERATE_NODE_TYPE = "generate-3d-scene"
615
+ export const SCENE3D_EDIT_NODE_TYPE = "edit-3d-scene"