@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/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"
|