@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/dist/index.cjs +599 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +786 -308
- package/dist/index.d.ts +786 -308
- package/dist/index.js +560 -5
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/llm-models.test.ts +4 -2
- package/src/__tests__/node-preset-extract.test.ts +11 -0
- package/src/__tests__/scene3d.test.ts +482 -0
- package/src/__tests__/studio-transient.test.ts +205 -0
- package/src/index.ts +12 -8
- package/src/llm-models.ts +10 -0
- package/src/model-constants.ts +5 -0
- package/src/node-mappable-fields.ts +2 -0
- package/src/node-preset-extract.ts +8 -2
- package/src/scene3d-edit.ts +311 -0
- package/src/scene3d.ts +615 -0
- package/src/studio-transient.ts +124 -0
- package/src/studio-production-wire.ts +0 -344
package/package.json
CHANGED
|
@@ -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
|
|
401
|
-
expect(Object.keys(LLM_FEATURE_DEFAULTS)).toHaveLength(
|
|
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
|
+
})
|