@nodaro/shared 2.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nodaro/shared",
3
- "version": "2.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "Shared types, model catalog, wire contracts, and structural vocabularies for the Nodaro platform and SDK.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -395,10 +395,12 @@ describe("LLM_FEATURE_DEFAULTS", () => {
395
395
  "image-critic",
396
396
  "pick-best-llm",
397
397
  "workflow-copilot",
398
+ // Scene3D previz authoring — generate-3d-scene AND edit-3d-scene share it.
399
+ "3d-scene",
398
400
  ]
399
401
 
400
- it("has entries for all 17 features", () => {
401
- expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(17)
402
+ it("has entries for all 18 features", () => {
403
+ expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(18)
402
404
  for (const feature of ALL_FEATURES) {
403
405
  expect(LLM_FEATURE_DEFAULTS).toHaveProperty(feature)
404
406
  }
@@ -223,3 +223,14 @@ describe("presetApplyClearKeys", () => {
223
223
  expect(PRESET_APPLY_CLEAR_KEYS).not.toContain("promptSuffix")
224
224
  })
225
225
  })
226
+
227
+ describe("Scene3D presets", () => {
228
+ it("excludes and clears revision history and object bindings while retaining the brief", () => {
229
+ const state = { scenePrompt: "A dolly shot", scenePlan: { revisionId: "r1" }, sceneHistory: [{ context: { prompt: "old private brief", references: [{ url: "https://example.com/old.png" }] } }], scenePendingPlan: {}, sceneJobBaseRevisionId: "r1", expectedRevisionId: "r1", lockedObjectIds: ["old-object"], selectedObjectIds: ["old-object"], referenceRoles: { source: "layout" }, referenceObjectIds: { source: "old-object" }, changeSummary: "Old edit" }
230
+ expect(extractPresetData(state)).toEqual({ scenePrompt: "A dolly shot" })
231
+ for (const key of Object.keys(state).filter((key) => key !== "scenePrompt")) {
232
+ expect(presetApplyClearKeys({ scenePrompt: "New scene" })).toContain(key)
233
+ }
234
+ expect(presetApplyClearKeys({ scenePrompt: "New scene" })).toContain("promptPrefix")
235
+ })
236
+ })
@@ -0,0 +1,482 @@
1
+ /**
2
+ * Scene3D contract tests.
3
+ *
4
+ * The rules here are the ones a downstream consumer is entitled to assume:
5
+ * a parsed plan has no cycles, no dangling parents, no out-of-range or
6
+ * unsorted keyframes and no scene longer than the ceiling; an edit produces a
7
+ * NEW revision without touching the input; a stale revision and a locked
8
+ * object are refused rather than silently applied.
9
+ */
10
+ import { describe, it, expect } from "vitest"
11
+ import {
12
+ SCENE3D_LIMITS,
13
+ scene3DColorSchema,
14
+ SCENE3D_PLAN_TYPE,
15
+ SCENE3D_SCHEMA_VERSION,
16
+ applyScene3DEditOperations,
17
+ isScene3DPlan,
18
+ newScene3DRevisionId,
19
+ scene3DDeepEqual,
20
+ scene3DEditOperationSchema,
21
+ scene3DPlanSchema,
22
+ summarizeScene3DOperations,
23
+ type Scene3DObject,
24
+ type Scene3DPlan,
25
+ } from "../index.js"
26
+
27
+ const REV_A = "11111111-2222-4333-8444-555555555555"
28
+ const REV_B = "66666666-7777-4888-8999-aaaaaaaaaaaa"
29
+
30
+ function object(id: string, over: Partial<Scene3DObject> = {}): Scene3DObject {
31
+ return {
32
+ id,
33
+ name: id,
34
+ primitive: "box",
35
+ dimensions: [1, 1, 1],
36
+ position: [0, 0, 0],
37
+ rotation: [0, 0, 0],
38
+ scale: [1, 1, 1],
39
+ color: "#8899aa",
40
+ ...over,
41
+ }
42
+ }
43
+
44
+ function plan(over: Partial<Scene3DPlan> = {}): Scene3DPlan {
45
+ return {
46
+ planType: SCENE3D_PLAN_TYPE,
47
+ schemaVersion: SCENE3D_SCHEMA_VERSION,
48
+ revisionId: REV_A,
49
+ width: 1280,
50
+ height: 720,
51
+ fps: 24,
52
+ durationInFrames: 96,
53
+ backgroundColor: "#101014",
54
+ camera: { position: [0, 2, 6], target: [0, 0, 0], focalLengthMm: 35, sensorWidthMm: 36 },
55
+ objects: [object("ground", { primitive: "plane", dimensions: [10, 0.01, 10] })],
56
+ lighting: { ambientIntensity: 0.4, keyIntensity: 1.2, keyPosition: [4, 6, 4] },
57
+ ...over,
58
+ }
59
+ }
60
+
61
+ describe("scene3DPlanSchema — structure", () => {
62
+ it("accepts a minimal well-formed plan", () => {
63
+ expect(scene3DPlanSchema.safeParse(plan()).success).toBe(true)
64
+ expect(isScene3DPlan(plan())).toBe(true)
65
+ })
66
+
67
+ it("defaults sensorWidthMm to full frame", () => {
68
+ const camera = { position: [0, 2, 6], target: [0, 0, 0], focalLengthMm: 35 }
69
+ const parsed = scene3DPlanSchema.parse({ ...plan(), camera })
70
+ expect(parsed.camera.sensorWidthMm).toBe(SCENE3D_LIMITS.defaultSensorWidthMm)
71
+ })
72
+
73
+ it("rejects an unknown top-level field rather than silently dropping it", () => {
74
+ expect(scene3DPlanSchema.safeParse({ ...plan(), script: "rm -rf /" }).success).toBe(false)
75
+ })
76
+
77
+ it("rejects a non-finite coordinate", () => {
78
+ const broken = plan({ objects: [object("a", { position: [Number.NaN, 0, 0] })] })
79
+ expect(scene3DPlanSchema.safeParse(broken).success).toBe(false)
80
+ const infinite = plan({ objects: [object("a", { position: [Number.POSITIVE_INFINITY, 0, 0] })] })
81
+ expect(scene3DPlanSchema.safeParse(infinite).success).toBe(false)
82
+ })
83
+
84
+ it("rejects a scene longer than the duration ceiling", () => {
85
+ const tooLong = plan({ fps: 24, durationInFrames: 24 * (SCENE3D_LIMITS.maxDurationSeconds + 1) })
86
+ const result = scene3DPlanSchema.safeParse(tooLong)
87
+ expect(result.success).toBe(false)
88
+ expect(JSON.stringify(result.error?.issues)).toContain("the limit is 60s")
89
+ })
90
+
91
+ it("rejects duplicate object ids", () => {
92
+ const dup = plan({ objects: [object("a"), object("a")] })
93
+ const result = scene3DPlanSchema.safeParse(dup)
94
+ expect(result.success).toBe(false)
95
+ expect(JSON.stringify(result.error?.issues)).toContain("duplicate object id")
96
+ })
97
+
98
+ it("rejects a dangling parentId", () => {
99
+ const orphan = plan({ objects: [object("a", { parentId: "ghost" })] })
100
+ const result = scene3DPlanSchema.safeParse(orphan)
101
+ expect(result.success).toBe(false)
102
+ expect(JSON.stringify(result.error?.issues)).toContain("unknown parent")
103
+ })
104
+
105
+ it("rejects a self-parent", () => {
106
+ const self = plan({ objects: [object("a", { parentId: "a" })] })
107
+ expect(scene3DPlanSchema.safeParse(self).success).toBe(false)
108
+ })
109
+
110
+ it("rejects a two-object parent cycle", () => {
111
+ const cycle = plan({ objects: [object("a", { parentId: "b" }), object("b", { parentId: "a" })] })
112
+ const result = scene3DPlanSchema.safeParse(cycle)
113
+ expect(result.success).toBe(false)
114
+ expect(JSON.stringify(result.error?.issues)).toContain("parent cycle")
115
+ })
116
+
117
+ it("accepts a legal parent chain", () => {
118
+ const chain = plan({
119
+ objects: [object("root"), object("mid", { parentId: "root" }), object("leaf", { parentId: "mid" })],
120
+ })
121
+ expect(scene3DPlanSchema.safeParse(chain).success).toBe(true)
122
+ })
123
+
124
+ it("rejects a hierarchy deeper than the ceiling", () => {
125
+ const objects = [object("n0")]
126
+ for (let i = 1; i <= SCENE3D_LIMITS.maxHierarchyDepth + 1; i++) {
127
+ objects.push(object(`n${i}`, { parentId: `n${i - 1}` }))
128
+ }
129
+ const result = scene3DPlanSchema.safeParse(plan({ objects }))
130
+ expect(result.success).toBe(false)
131
+ expect(JSON.stringify(result.error?.issues)).toContain("deeper than")
132
+ })
133
+
134
+ it("rejects unsorted, duplicated and out-of-range keyframes", () => {
135
+ const unsorted = plan({ objects: [object("a", { keyframes: [{ frame: 10 }, { frame: 2 }] })] })
136
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(unsorted).error?.issues)).toContain("sorted")
137
+
138
+ const duplicated = plan({ objects: [object("a", { keyframes: [{ frame: 4 }, { frame: 4 }] })] })
139
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(duplicated).error?.issues)).toContain("duplicate keyframe")
140
+
141
+ const past = plan({ durationInFrames: 10, objects: [object("a", { keyframes: [{ frame: 10 }] })] })
142
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(past).error?.issues)).toContain("past the scene's last frame")
143
+ })
144
+
145
+ it("applies the same keyframe rules to the camera track", () => {
146
+ const camera = {
147
+ position: [0, 2, 6] as [number, number, number],
148
+ target: [0, 0, 0] as [number, number, number],
149
+ focalLengthMm: 35,
150
+ sensorWidthMm: 36,
151
+ keyframes: [{ frame: 30 }, { frame: 5 }],
152
+ }
153
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(plan({ camera })).error?.issues)).toContain("sorted")
154
+ })
155
+
156
+ it("validates references: http(s) only, resolved objectId, sane window", () => {
157
+ const base = plan({ objects: [object("hero")] })
158
+ const good = {
159
+ ...base,
160
+ references: [
161
+ { id: "r1", url: "https://cdn.example.com/a.png", kind: "image", role: "appearance", objectId: "hero" },
162
+ { id: "r2", url: "https://cdn.example.com/b.mp4", kind: "video", role: "motion", startSeconds: 1, endSeconds: 3 },
163
+ ],
164
+ }
165
+ expect(scene3DPlanSchema.safeParse(good).success).toBe(true)
166
+
167
+ const badScheme = { ...base, references: [{ id: "r1", url: "file:///etc/passwd", kind: "image", role: "layout" }] }
168
+ expect(scene3DPlanSchema.safeParse(badScheme).success).toBe(false)
169
+
170
+ const ghostObject = {
171
+ ...base,
172
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout", objectId: "nope" }],
173
+ }
174
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(ghostObject).error?.issues)).toContain("unknown object")
175
+
176
+ const backwards = {
177
+ ...base,
178
+ references: [{ id: "r1", url: "https://x.test/a.mp4", kind: "video", role: "motion", startSeconds: 5, endSeconds: 2 }],
179
+ }
180
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(backwards).error?.issues)).toContain("before it starts")
181
+
182
+ const windowedImage = {
183
+ ...base,
184
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout", startSeconds: 1 }],
185
+ }
186
+ expect(scene3DPlanSchema.safeParse(windowedImage).success).toBe(false)
187
+ })
188
+
189
+ it("rejects duplicate reference ids", () => {
190
+ const dup = {
191
+ ...plan(),
192
+ references: [
193
+ { id: "r1", url: "https://x.test/a.png", kind: "image", role: "layout" },
194
+ { id: "r1", url: "https://x.test/b.png", kind: "image", role: "layout" },
195
+ ],
196
+ }
197
+ expect(JSON.stringify(scene3DPlanSchema.safeParse(dup).error?.issues)).toContain("duplicate reference id")
198
+ })
199
+
200
+ it("requires at least one object and caps the count", () => {
201
+ expect(scene3DPlanSchema.safeParse(plan({ objects: [] })).success).toBe(false)
202
+ const many = Array.from({ length: SCENE3D_LIMITS.maxObjects + 1 }, (_, i) => object(`o${i}`))
203
+ expect(scene3DPlanSchema.safeParse(plan({ objects: many })).success).toBe(false)
204
+ })
205
+
206
+ it("bounds the render size and fps", () => {
207
+ expect(scene3DPlanSchema.safeParse(plan({ width: 4096 })).success).toBe(false)
208
+ expect(scene3DPlanSchema.safeParse(plan({ fps: 120 })).success).toBe(false)
209
+ expect(scene3DPlanSchema.safeParse(plan({ fps: 24.5 })).success).toBe(false)
210
+ })
211
+ })
212
+
213
+ describe("newScene3DRevisionId", () => {
214
+ it("produces distinct v4 UUIDs the plan schema accepts", () => {
215
+ const a = newScene3DRevisionId()
216
+ const b = newScene3DRevisionId()
217
+ expect(a).not.toBe(b)
218
+ expect(scene3DPlanSchema.safeParse(plan({ revisionId: a })).success).toBe(true)
219
+ })
220
+ })
221
+
222
+ describe("scene3DEditOperationSchema", () => {
223
+ it("refuses to let an operation rename an object", () => {
224
+ const sneaky = { op: "set-object", objectId: "a", changes: { id: "b" } }
225
+ expect(scene3DEditOperationSchema.safeParse(sneaky).success).toBe(false)
226
+ })
227
+
228
+ it("accepts a null parentId as an explicit detach", () => {
229
+ const detach = { op: "set-object", objectId: "a", changes: { parentId: null } }
230
+ expect(scene3DEditOperationSchema.safeParse(detach).success).toBe(true)
231
+ })
232
+
233
+ it("rejects an unknown op", () => {
234
+ expect(scene3DEditOperationSchema.safeParse({ op: "eval", code: "1" }).success).toBe(false)
235
+ })
236
+ })
237
+
238
+ describe("applyScene3DEditOperations", () => {
239
+ const base = plan({ objects: [object("ground", { primitive: "plane" }), object("hero")] })
240
+
241
+ it("produces a new revision, records the parent, and never mutates the input", () => {
242
+ const snapshot = JSON.parse(JSON.stringify(base))
243
+ const result = applyScene3DEditOperations(base, [
244
+ { op: "set-object", objectId: "hero", changes: { position: [1, 0, 2] } },
245
+ ])
246
+ expect(result.ok).toBe(true)
247
+ if (!result.ok) return
248
+ expect(result.plan.revisionId).not.toBe(base.revisionId)
249
+ expect(result.plan.parentRevisionId).toBe(base.revisionId)
250
+ expect(result.plan.objects.find((o) => o.id === "hero")?.position).toEqual([1, 0, 2])
251
+ expect(base).toEqual(snapshot)
252
+ expect(result.changedObjectIds).toEqual(["hero"])
253
+ expect(result.changeSummary).toContain("hero")
254
+ })
255
+
256
+ it("refuses a stale expectedRevisionId before applying anything", () => {
257
+ const result = applyScene3DEditOperations(
258
+ base,
259
+ [{ op: "remove-object", objectId: "hero" }],
260
+ { expectedRevisionId: REV_B },
261
+ )
262
+ expect(result.ok).toBe(false)
263
+ if (result.ok) return
264
+ expect(result.code).toBe("stale_revision")
265
+ })
266
+
267
+ it("accepts a matching expectedRevisionId", () => {
268
+ const result = applyScene3DEditOperations(base, [{ op: "set-background", color: "#000000" }], {
269
+ expectedRevisionId: REV_A,
270
+ })
271
+ expect(result.ok).toBe(true)
272
+ if (!result.ok) return
273
+ expect(result.plan.backgroundColor).toBe("#000000")
274
+ })
275
+
276
+ it("reports the failing operation index", () => {
277
+ const result = applyScene3DEditOperations(base, [
278
+ { op: "set-background", color: "#111111" },
279
+ { op: "remove-object", objectId: "ghost" },
280
+ ])
281
+ expect(result.ok).toBe(false)
282
+ if (result.ok) return
283
+ expect(result.code).toBe("unknown_object")
284
+ expect(result.operationIndex).toBe(1)
285
+ })
286
+
287
+ it("rejects a duplicate add", () => {
288
+ const result = applyScene3DEditOperations(base, [{ op: "add-object", object: object("hero") }])
289
+ expect(result.ok).toBe(false)
290
+ if (result.ok) return
291
+ expect(result.code).toBe("duplicate_object")
292
+ })
293
+
294
+ it("refuses to modify a locked object", () => {
295
+ const result = applyScene3DEditOperations(
296
+ base,
297
+ [{ op: "set-object", objectId: "hero", changes: { color: "#ff0000" } }],
298
+ { lockedObjectIds: ["hero"] },
299
+ )
300
+ expect(result.ok).toBe(false)
301
+ if (result.ok) return
302
+ expect(result.code).toBe("locked_object")
303
+ expect(result.message).toContain("cannot be modified")
304
+ })
305
+
306
+ it("refuses to remove a locked object", () => {
307
+ const result = applyScene3DEditOperations(base, [{ op: "remove-object", objectId: "hero" }], {
308
+ lockedObjectIds: ["hero"],
309
+ })
310
+ expect(result.ok).toBe(false)
311
+ if (result.ok) return
312
+ expect(result.message).toContain("cannot be removed")
313
+ })
314
+
315
+ it("catches a remove-then-re-add that would smuggle a change past the lock", () => {
316
+ const result = applyScene3DEditOperations(
317
+ base,
318
+ [
319
+ { op: "remove-object", objectId: "hero" },
320
+ { op: "add-object", object: object("hero", { color: "#00ff00" }) },
321
+ ],
322
+ { lockedObjectIds: ["hero"] },
323
+ )
324
+ expect(result.ok).toBe(false)
325
+ if (result.ok) return
326
+ expect(result.code).toBe("locked_object")
327
+ })
328
+
329
+ it("allows an unrelated edit while an object is locked", () => {
330
+ const result = applyScene3DEditOperations(
331
+ base,
332
+ [{ op: "set-object", objectId: "ground", changes: { color: "#223344" } }],
333
+ { lockedObjectIds: ["hero"] },
334
+ )
335
+ expect(result.ok).toBe(true)
336
+ })
337
+
338
+ it("refuses to orphan a child by removing its parent", () => {
339
+ const nested = plan({ objects: [object("root"), object("child", { parentId: "root" })] })
340
+ const result = applyScene3DEditOperations(nested, [{ op: "remove-object", objectId: "root" }])
341
+ expect(result.ok).toBe(false)
342
+ if (result.ok) return
343
+ expect(result.code).toBe("invalid_plan")
344
+ expect(result.message).toContain("unknown parent")
345
+ })
346
+
347
+ it("refuses to remove an object a reference points at", () => {
348
+ const referenced: Scene3DPlan = {
349
+ ...plan({ objects: [object("hero"), object("prop")] }),
350
+ references: [{ id: "r1", url: "https://x.test/a.png", kind: "image", role: "appearance", objectId: "prop" }],
351
+ }
352
+ const result = applyScene3DEditOperations(referenced, [{ op: "remove-object", objectId: "prop" }])
353
+ expect(result.ok).toBe(false)
354
+ if (result.ok) return
355
+ expect(result.code).toBe("invalid_plan")
356
+ })
357
+
358
+ it("removing a parent is fine once the child is detached in the same list", () => {
359
+ const nested = plan({ objects: [object("root"), object("child", { parentId: "root" })] })
360
+ const result = applyScene3DEditOperations(nested, [
361
+ { op: "set-object", objectId: "child", changes: { parentId: null } },
362
+ { op: "remove-object", objectId: "root" },
363
+ ])
364
+ expect(result.ok).toBe(true)
365
+ if (!result.ok) return
366
+ expect(result.plan.objects).toHaveLength(1)
367
+ expect(result.plan.objects[0].parentId).toBeUndefined()
368
+ })
369
+
370
+ it("rejects an operation list that would leave the scene invalid", () => {
371
+ const result = applyScene3DEditOperations(base, [
372
+ { op: "remove-object", objectId: "hero" },
373
+ { op: "remove-object", objectId: "ground" },
374
+ ])
375
+ expect(result.ok).toBe(false)
376
+ if (result.ok) return
377
+ expect(result.code).toBe("invalid_plan")
378
+ })
379
+
380
+ it("rejects an invalid input plan without applying anything", () => {
381
+ const result = applyScene3DEditOperations({ ...base, objects: [] } as Scene3DPlan, [
382
+ { op: "set-background", color: "#000000" },
383
+ ])
384
+ expect(result.ok).toBe(false)
385
+ if (result.ok) return
386
+ expect(result.code).toBe("invalid_plan")
387
+ })
388
+
389
+ it("caps the operation list", () => {
390
+ const many = Array.from({ length: SCENE3D_LIMITS.maxOperations + 1 }, () => ({
391
+ op: "set-background" as const,
392
+ color: "#000000",
393
+ }))
394
+ const result = applyScene3DEditOperations(base, many)
395
+ expect(result.ok).toBe(false)
396
+ if (result.ok) return
397
+ expect(result.code).toBe("invalid_operations")
398
+ })
399
+
400
+ it("rejects a malformed operation without a partial apply", () => {
401
+ const result = applyScene3DEditOperations(base, [{ op: "set-object", objectId: "hero", changes: { color: "red" } }])
402
+ expect(result.ok).toBe(false)
403
+ if (result.ok) return
404
+ expect(result.code).toBe("invalid_operations")
405
+ expect(result.operationIndex).toBe(0)
406
+ })
407
+
408
+ it("honours a pinned revisionId for deterministic replay", () => {
409
+ const result = applyScene3DEditOperations(base, [{ op: "set-background", color: "#0a0a0a" }], {
410
+ revisionId: REV_B,
411
+ })
412
+ expect(result.ok).toBe(true)
413
+ if (!result.ok) return
414
+ expect(result.plan.revisionId).toBe(REV_B)
415
+ })
416
+
417
+ it("edits camera and lighting without touching objects", () => {
418
+ const result = applyScene3DEditOperations(base, [
419
+ { op: "set-camera", changes: { focalLengthMm: 85, target: [0, 1, 0] } },
420
+ { op: "set-lighting", changes: { keyIntensity: 2 } },
421
+ ])
422
+ expect(result.ok).toBe(true)
423
+ if (!result.ok) return
424
+ expect(result.plan.camera.focalLengthMm).toBe(85)
425
+ expect(result.plan.camera.position).toEqual(base.camera.position)
426
+ expect(result.plan.lighting.keyIntensity).toBe(2)
427
+ expect(result.plan.objects).toEqual(base.objects)
428
+ })
429
+ })
430
+
431
+ describe("summarizeScene3DOperations", () => {
432
+ it("names each change", () => {
433
+ const summary = summarizeScene3DOperations([
434
+ { op: "add-object", object: object("lamp", { primitive: "cone" }) },
435
+ { op: "remove-object", objectId: "ground" },
436
+ { op: "set-background", color: "#123456" },
437
+ ])
438
+ expect(summary).toContain("Added cone")
439
+ expect(summary).toContain("Removed \"ground\"")
440
+ expect(summary).toContain("#123456")
441
+ })
442
+ })
443
+
444
+ describe("scene3DDeepEqual", () => {
445
+ it("ignores key order but not values", () => {
446
+ expect(scene3DDeepEqual({ a: 1, b: [1, 2] }, { b: [1, 2], a: 1 })).toBe(true)
447
+ expect(scene3DDeepEqual({ a: 1 }, { a: 1, b: 2 })).toBe(false)
448
+ expect(scene3DDeepEqual([1, 2], [2, 1])).toBe(false)
449
+ })
450
+ })
451
+
452
+ describe("colors are OPAQUE hex only", () => {
453
+ // `new THREE.Color(hex)` understands #abc and #aabbcc and nothing else. It
454
+ // does not REFUSE a 4- or 8-digit value — it warns and falls back to WHITE,
455
+ // so an alpha the contract accepted would silently repaint an object in the
456
+ // export while the plan looked perfectly valid.
457
+ it("accepts 3- and 6-digit values", () => {
458
+ expect(scene3DColorSchema.safeParse("#abc").success).toBe(true)
459
+ expect(scene3DColorSchema.safeParse("#4f8ef7").success).toBe(true)
460
+ expect(scene3DColorSchema.safeParse("#ABCDEF").success).toBe(true)
461
+ })
462
+
463
+ it("rejects the CSS alpha spellings", () => {
464
+ expect(scene3DColorSchema.safeParse("#abcd").success).toBe(false)
465
+ expect(scene3DColorSchema.safeParse("#aabbccdd").success).toBe(false)
466
+ })
467
+
468
+ it("rejects them everywhere a color appears, not just at the top level", () => {
469
+ expect(scene3DPlanSchema.safeParse(plan({ backgroundColor: "#aabbccdd" })).success).toBe(false)
470
+ expect(
471
+ scene3DPlanSchema.safeParse(plan({ objects: [object("a", { color: "#abcd" })] })).success,
472
+ ).toBe(false)
473
+ })
474
+ })
475
+
476
+ describe("SCENE3D_LIMITS.minDurationSeconds", () => {
477
+ it("is the frozen v1 floor of one second", () => {
478
+ // Quoted by the route Zod and by the canvas path, so neither can accept a
479
+ // scene shorter than the SDK/MCP surface and the public docs promise.
480
+ expect(SCENE3D_LIMITS.minDurationSeconds).toBe(1)
481
+ })
482
+ })
package/src/index.ts CHANGED
@@ -1052,6 +1052,12 @@ export {
1052
1052
  export { ENTITY_NODE_KINDS } from "./entity-node-fields.js"
1053
1053
  export type { EntityNodeKind } from "./entity-node-fields.js"
1054
1054
 
1055
+ // --- Scene3D previsualization (v1): the frozen wire contract shared by the
1056
+ // authoring LLM jobs, the Three.js/Remotion renderer, the canvas and the
1057
+ // SDK/MCP surface. Structure only — no prompts, no pricing. ---
1058
+ export * from "./scene3d.js"
1059
+ export * from "./scene3d-edit.js"
1060
+
1055
1061
  // --- transient studio keys — the public share read strips them ---
1056
1062
  export {
1057
1063
  STUDIO_TRANSIENT_KEYS,
package/src/llm-models.ts CHANGED
@@ -642,6 +642,12 @@ export type LlmFeature =
642
642
  | "motion-graphics-lottie"
643
643
  | "lottie-overlay"
644
644
  | "3d-title"
645
+ // Scene3D previz authoring — generate-3d-scene AND edit-3d-scene share one
646
+ // feature: both send the model a scene (the edit sends the WHOLE plan in) and
647
+ // both get back structured geometry, so the token profile is the same shape.
648
+ // The deterministic edit lane never reaches an LLM and bills the separate
649
+ // zero-cost `3d-scene-ops` identifier instead.
650
+ | "3d-scene"
645
651
  | "image-to-text"
646
652
  | "describe-to-picker"
647
653
  | "qa-check"
@@ -672,6 +678,7 @@ export const LLM_FEATURE_DEFAULTS: Record<LlmFeature, string> = {
672
678
  "motion-graphics-lottie": "claude-sonnet-4.6",
673
679
  "lottie-overlay": "claude-sonnet-4.6",
674
680
  "3d-title": "claude-sonnet-4.6",
681
+ "3d-scene": "claude-sonnet-4.6",
675
682
  "image-to-text": "claude-sonnet-4.6",
676
683
  "describe-to-picker": "claude-opus-5",
677
684
  "qa-check": "gemini-3.6-flash",
@@ -849,6 +856,9 @@ export const LLM_ROUTE_DEFAULTS: Record<string, LlmRouteDefaults> = {
849
856
  "motion-graphics": { temperature: 0.3, maxTokens: 2048, structuredOutput: true },
850
857
  "motion-graphics-lottie": { temperature: 0.3, maxTokens: 8192, structuredOutput: true },
851
858
  "3d-title": { temperature: 0.4, maxTokens: 3072, structuredOutput: true },
859
+ // A 100-object plan with keyframe tracks is the biggest structured payload
860
+ // any composer feature emits — 3072 (3d-title's cap) truncates it mid-array.
861
+ "3d-scene": { temperature: 0.3, maxTokens: 8192, structuredOutput: true },
852
862
  }
853
863
 
854
864
  /** Route defaults for a feature; `{}` for an unknown one. */
@@ -2632,6 +2632,11 @@ export const COMPOSER_PLAN_MAP: Readonly<Record<string, { planType: string; plan
2632
2632
  "3d-title": { planType: "3d-title", planField: "titlePlan" },
2633
2633
  "motion-graphics": { planType: "motion-graphics", planField: "motionPlan" },
2634
2634
  "composite": { planType: "composite", planField: "compositePlan" },
2635
+ // Scene3D previz (v1) — both the generator and the editor emit the SAME
2636
+ // validated `Scene3DPlan` revision on their `composition` handle, so
2637
+ // render-video routes either one to the `3d-scene` renderer unchanged.
2638
+ "generate-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2639
+ "edit-3d-scene": { planType: "3d-scene", planField: "scenePlan" },
2635
2640
  }
2636
2641
 
2637
2642
  /** Every composer plan-field name, derived from COMPOSER_PLAN_MAP (single source
@@ -44,6 +44,8 @@ export const NODE_MAPPABLE_FIELDS: Readonly<Record<string, readonly string[]>> =
44
44
  "after-effects": ["effectPrompt"],
45
45
  "lottie-overlay": ["overlayPrompt"],
46
46
  "3d-title": ["titlePrompt"],
47
+ "generate-3d-scene": ["scenePrompt"],
48
+ "edit-3d-scene": ["editPrompt"],
47
49
  "motion-graphics": ["motionPrompt"],
48
50
  "generate-script": ["styleGuide"],
49
51
  "speech-to-video": ["prompt", "negativePrompt"],
@@ -25,13 +25,19 @@ import { PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY } from "./prompt-affixes.js"
25
25
  * blob (~tens of KB) + a stale url, re-injecting them on every apply and
26
26
  * bloating the preset row. These keys are in the capture-exclusion set below.
27
27
  */
28
- export const PRESET_APPLY_CLEAR_KEYS: readonly string[] = [...COMPOSER_PLAN_FIELDS, "lottieUrl"]
28
+ export const PRESET_APPLY_CLEAR_KEYS: readonly string[] = [
29
+ ...COMPOSER_PLAN_FIELDS, "lottieUrl",
30
+ // Scene revision history contains old plans and their reference/prompt context.
31
+ // Preserve it in workflows, never capture or resurrect it through a preset.
32
+ "sceneHistory", "scenePendingPlan", "sceneJobBaseRevisionId", "expectedRevisionId",
33
+ "changeSummary", "selectedObjectIds", "lockedObjectIds", "referenceObjectIds", "referenceRoles",
34
+ ]
29
35
 
30
36
  /**
31
37
  * The three keys that carry a preset's PROMPT CONTENT: the prompt itself plus the pre/post text
32
38
  * wrapped around it at run time. A preset "owns prompt content" iff its data defines any of them.
33
39
  */
34
- const PROMPT_CONTENT_KEYS: readonly string[] = ["prompt", PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
40
+ const PROMPT_CONTENT_KEYS: readonly string[] = ["prompt", "scenePrompt", "editPrompt", PROMPT_PREFIX_KEY, PROMPT_SUFFIX_KEY]
35
41
 
36
42
  /**
37
43
  * The keys applying THIS preset must clear on the node: always `PRESET_APPLY_CLEAR_KEYS`, plus