@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/dist/index.cjs +549 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +731 -2
- package/dist/index.d.ts +731 -2
- package/dist/index.js +513 -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/index.ts +6 -0
- 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/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
|
+
})
|
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. */
|
package/src/model-constants.ts
CHANGED
|
@@ -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[] = [
|
|
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
|