@nodaro/shared 2.27.0 → 3.1.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 +1776 -159
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2921 -73
- package/dist/index.d.ts +2921 -73
- package/dist/index.js +1665 -160
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/presentation-utils.test.ts +97 -0
- package/src/__tests__/scene3d-authoring-engine.test.ts +104 -0
- package/src/__tests__/scene3d-camera-track.test.ts +235 -0
- package/src/__tests__/scene3d-v2-edit.test.ts +83 -0
- package/src/__tests__/scene3d-v2-fixtures.ts +232 -0
- package/src/__tests__/scene3d-v2-resources.test.ts +239 -0
- package/src/__tests__/scene3d-v2.test.ts +742 -0
- package/src/__tests__/scene3d.test.ts +6 -6
- package/src/index.ts +9 -0
- package/src/model-constants.ts +10 -0
- package/src/node-mappable-fields.ts +1 -0
- package/src/presentation-utils.ts +54 -2
- package/src/pro-3d-render.ts +466 -0
- package/src/producer-types.ts +5 -0
- package/src/scene3d-authoring-engine.ts +215 -0
- package/src/scene3d-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +10 -10
- package/src/scene3d-v2-edit.ts +156 -0
- package/src/scene3d-v2-plan.ts +694 -0
- package/src/scene3d-v2-resources.ts +382 -0
- package/src/scene3d-v2.ts +666 -0
- package/src/scene3d.ts +39 -13
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Scene3D v2 PLAN: whole-manifest schema, every cross-field rule, and the
|
|
3
|
+
* V1|V2 union the rest of the platform reads.
|
|
4
|
+
*
|
|
5
|
+
* `scene3d-v2.ts` says what a v2 entity, asset, shot or override looks like on
|
|
6
|
+
* its own. Nothing there can catch the failures that actually reach a renderer,
|
|
7
|
+
* because every one of them is a relationship:
|
|
8
|
+
*
|
|
9
|
+
* - an entity whose GLB is not in `assets`, or two entities claiming the same
|
|
10
|
+
* exported root node;
|
|
11
|
+
* - a parent chain that loops, or nests deeper than the transform walk allows;
|
|
12
|
+
* - shots with a one-frame gap, so some frame belongs to no shot at all;
|
|
13
|
+
* - a colour override naming a material role its entity never declared — the
|
|
14
|
+
* bug where recolouring a person also repaints its chair;
|
|
15
|
+
* - two overrides driving one channel, or one driving a locked entity;
|
|
16
|
+
* - declared asset bytes over the download budget.
|
|
17
|
+
*
|
|
18
|
+
* All of it runs in `scene3DPlanV2Issues`, which the schema calls from a
|
|
19
|
+
* `superRefine` and a caller holding an already-parsed plan can call directly —
|
|
20
|
+
* the same split v1 uses, so both versions report failures identically.
|
|
21
|
+
*/
|
|
22
|
+
import { z } from "zod"
|
|
23
|
+
import {
|
|
24
|
+
SCENE3D_ASSET_ROLE_KINDS,
|
|
25
|
+
SCENE3D_DEFAULT_ENTITY_CAPABILITIES,
|
|
26
|
+
SCENE3D_ENTITY_CAPABILITIES,
|
|
27
|
+
SCENE3D_PRIMITIVE_MATERIAL_ROLE,
|
|
28
|
+
SCENE3D_RENDERER_ASSET_KINDS,
|
|
29
|
+
SCENE3D_SCHEMA_VERSION_V2,
|
|
30
|
+
SCENE3D_SUPPORTED_SCHEMA_VERSIONS,
|
|
31
|
+
SCENE3D_V2_ENGINES,
|
|
32
|
+
SCENE3D_V2_LIMITS,
|
|
33
|
+
SCENE3D_V2_OVERRIDE_OPERATION_VERSION,
|
|
34
|
+
scene3DAssetIdSchema,
|
|
35
|
+
scene3DAssetRefSchema,
|
|
36
|
+
scene3DClayLightingSchema,
|
|
37
|
+
scene3DEntityV2Schema,
|
|
38
|
+
scene3DOverrideSchema,
|
|
39
|
+
scene3DProvenanceSchema,
|
|
40
|
+
scene3DShotSchema,
|
|
41
|
+
type Scene3DAssetRef,
|
|
42
|
+
type Scene3DEntityCapability,
|
|
43
|
+
type Scene3DEntityV2,
|
|
44
|
+
type Scene3DKnownEngine,
|
|
45
|
+
type Scene3DPlanV2,
|
|
46
|
+
type Scene3DShot,
|
|
47
|
+
type Scene3DSupportedSchemaVersion,
|
|
48
|
+
} from "./scene3d-v2.js"
|
|
49
|
+
import {
|
|
50
|
+
SCENE3D_PLAN_TYPE,
|
|
51
|
+
scene3DColorSchema,
|
|
52
|
+
scene3DPlanV1Issues,
|
|
53
|
+
scene3DPlanV1ObjectSchema,
|
|
54
|
+
scene3DReferenceSchema,
|
|
55
|
+
type Scene3DPlanV1,
|
|
56
|
+
type Scene3DSemanticIssue,
|
|
57
|
+
} from "./scene3d.js"
|
|
58
|
+
|
|
59
|
+
/** THE plan type. Narrow with `isScene3DPlanV1` / `isScene3DPlanV2` before
|
|
60
|
+
* reading version-specific fields. */
|
|
61
|
+
export type Scene3DPlan = Scene3DPlanV1 | Scene3DPlanV2
|
|
62
|
+
|
|
63
|
+
export interface Scene3DJobOutputV2 {
|
|
64
|
+
scenePlan: Scene3DPlanV2
|
|
65
|
+
changeSummary?: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Job output when either version may come back. */
|
|
69
|
+
export interface Scene3DJobOutputAny {
|
|
70
|
+
scenePlan: Scene3DPlan
|
|
71
|
+
changeSummary?: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Semantic (cross-field) validation
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
type Issue = Scene3DSemanticIssue
|
|
79
|
+
|
|
80
|
+
function entityCapabilities(entity: Scene3DEntityV2): readonly Scene3DEntityCapability[] {
|
|
81
|
+
return entity.capabilities ?? SCENE3D_DEFAULT_ENTITY_CAPABILITIES
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An overlay is accepted iff the entity advertises the capability AND has not
|
|
85
|
+
* frozen it. One rule, checked in exactly one place. */
|
|
86
|
+
export function scene3DEntityAcceptsOverlay(
|
|
87
|
+
entity: Scene3DEntityV2,
|
|
88
|
+
capability: Scene3DEntityCapability,
|
|
89
|
+
): boolean {
|
|
90
|
+
return entityCapabilities(entity).includes(capability) && !(entity.locks ?? []).includes(capability)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function checkEntities(plan: Scene3DPlanV2, byId: Map<string, Scene3DEntityV2>, issues: Issue[]): void {
|
|
94
|
+
const rootNodeOwners = new Map<string, string>()
|
|
95
|
+
|
|
96
|
+
plan.objects.forEach((entity, index) => {
|
|
97
|
+
const at = (...rest: (string | number)[]) => ["objects", index, ...rest]
|
|
98
|
+
|
|
99
|
+
if (entity.visual.kind !== "asset") {
|
|
100
|
+
// A group/primitive with no transform has no defined place in the world;
|
|
101
|
+
// an asset entity gets its transform from its GLB node instead.
|
|
102
|
+
for (const field of ["position", "rotation", "scale"] as const) {
|
|
103
|
+
if (entity[field] === undefined) {
|
|
104
|
+
issues.push({
|
|
105
|
+
path: at(field),
|
|
106
|
+
message: `entity "${entity.id}" is a ${entity.visual.kind} and must declare ${field}`,
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (entity.visual.kind === "asset") {
|
|
113
|
+
const owner = rootNodeOwners.get(entity.visual.rootNodeId)
|
|
114
|
+
if (owner !== undefined) {
|
|
115
|
+
issues.push({
|
|
116
|
+
path: at("visual", "rootNodeId"),
|
|
117
|
+
message: `root node "${entity.visual.rootNodeId}" is already the root of entity "${owner}"`,
|
|
118
|
+
})
|
|
119
|
+
} else {
|
|
120
|
+
rootNodeOwners.set(entity.visual.rootNodeId, entity.id)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const animation = entity.visual.animation
|
|
124
|
+
if (animation) {
|
|
125
|
+
if (animation.endFrameExclusive <= animation.startFrame) {
|
|
126
|
+
issues.push({
|
|
127
|
+
path: at("visual", "animation", "endFrameExclusive"),
|
|
128
|
+
message: `entity "${entity.id}" animation ends at or before it starts`,
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
if (animation.endFrameExclusive > plan.durationInFrames) {
|
|
132
|
+
issues.push({
|
|
133
|
+
path: at("visual", "animation", "endFrameExclusive"),
|
|
134
|
+
message: `entity "${entity.id}" animation runs past the scene (${plan.durationInFrames} frames)`,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else if (entity.materialBindings && entity.materialBindings.length > 0) {
|
|
139
|
+
issues.push({
|
|
140
|
+
path: at("materialBindings"),
|
|
141
|
+
message: `entity "${entity.id}" is a ${entity.visual.kind}; material bindings name materials in an asset root`,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const roles = new Set<string>()
|
|
146
|
+
;(entity.materialBindings ?? []).forEach((binding, bindingIndex) => {
|
|
147
|
+
if (roles.has(binding.role)) {
|
|
148
|
+
issues.push({
|
|
149
|
+
path: at("materialBindings", bindingIndex, "role"),
|
|
150
|
+
message: `entity "${entity.id}" binds material role "${binding.role}" twice`,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
roles.add(binding.role)
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const anchorNames = new Set<string>()
|
|
157
|
+
;(entity.anchors ?? []).forEach((anchor, anchorIndex) => {
|
|
158
|
+
if (anchorNames.has(anchor.name)) {
|
|
159
|
+
issues.push({
|
|
160
|
+
path: at("anchors", anchorIndex, "name"),
|
|
161
|
+
message: `entity "${entity.id}" declares anchor "${anchor.name}" twice`,
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
anchorNames.add(anchor.name)
|
|
165
|
+
})
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// Hierarchy: resolvable, acyclic and bounded.
|
|
169
|
+
plan.objects.forEach((entity, index) => {
|
|
170
|
+
if (entity.parentId === undefined) return
|
|
171
|
+
if (entity.parentId === entity.id) {
|
|
172
|
+
issues.push({ path: ["objects", index, "parentId"], message: `entity "${entity.id}" cannot parent itself` })
|
|
173
|
+
return
|
|
174
|
+
}
|
|
175
|
+
if (!byId.has(entity.parentId)) {
|
|
176
|
+
issues.push({
|
|
177
|
+
path: ["objects", index, "parentId"],
|
|
178
|
+
message: `entity "${entity.id}" references unknown parent "${entity.parentId}"`,
|
|
179
|
+
})
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
const seen = new Set<string>([entity.id])
|
|
183
|
+
let cursor: Scene3DEntityV2 | undefined = byId.get(entity.parentId)
|
|
184
|
+
let depth = 1
|
|
185
|
+
while (cursor) {
|
|
186
|
+
if (seen.has(cursor.id)) {
|
|
187
|
+
issues.push({ path: ["objects", index, "parentId"], message: `parent cycle through entity "${cursor.id}"` })
|
|
188
|
+
break
|
|
189
|
+
}
|
|
190
|
+
seen.add(cursor.id)
|
|
191
|
+
depth += 1
|
|
192
|
+
if (depth > SCENE3D_V2_LIMITS.maxHierarchyDepth) {
|
|
193
|
+
issues.push({
|
|
194
|
+
path: ["objects", index, "parentId"],
|
|
195
|
+
message: `hierarchy deeper than ${SCENE3D_V2_LIMITS.maxHierarchyDepth} levels`,
|
|
196
|
+
})
|
|
197
|
+
break
|
|
198
|
+
}
|
|
199
|
+
cursor = cursor.parentId === undefined ? undefined : byId.get(cursor.parentId)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function checkAssets(
|
|
205
|
+
plan: Scene3DPlanV2,
|
|
206
|
+
assetsById: Map<string, Scene3DAssetRef>,
|
|
207
|
+
issues: Issue[],
|
|
208
|
+
): void {
|
|
209
|
+
let rendererBytes = 0
|
|
210
|
+
let sourceCount = 0
|
|
211
|
+
|
|
212
|
+
plan.assets.forEach((asset, index) => {
|
|
213
|
+
const at = (...rest: (string | number)[]) => ["assets", index, ...rest]
|
|
214
|
+
|
|
215
|
+
const expectedKind = SCENE3D_ASSET_ROLE_KINDS[asset.role]
|
|
216
|
+
if (asset.kind !== expectedKind) {
|
|
217
|
+
issues.push({
|
|
218
|
+
path: at("kind"),
|
|
219
|
+
message: `asset "${asset.assetId}" has role "${asset.role}", which requires kind "${expectedKind}" (got "${asset.kind}")`,
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
if (asset.kind === "camera-track-json" && asset.byteLength > SCENE3D_V2_LIMITS.maxCameraTrackBytes) {
|
|
223
|
+
issues.push({
|
|
224
|
+
path: at("byteLength"),
|
|
225
|
+
message: `camera track "${asset.assetId}" is ${asset.byteLength} bytes; the limit is ${SCENE3D_V2_LIMITS.maxCameraTrackBytes}`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
if (SCENE3D_RENDERER_ASSET_KINDS.includes(asset.kind)) {
|
|
229
|
+
rendererBytes += asset.byteLength
|
|
230
|
+
if (asset.byteLength > SCENE3D_V2_LIMITS.maxRendererAssetBytes) {
|
|
231
|
+
issues.push({
|
|
232
|
+
path: at("byteLength"),
|
|
233
|
+
message: `asset "${asset.assetId}" is ${asset.byteLength} bytes; a downloaded scene asset may not exceed ${SCENE3D_V2_LIMITS.maxRendererAssetBytes}`,
|
|
234
|
+
})
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (asset.kind === "blend-source") {
|
|
238
|
+
sourceCount += 1
|
|
239
|
+
if (sourceCount > 1) {
|
|
240
|
+
issues.push({ path: at("kind"), message: "a revision may retain at most one blend-source asset" })
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
if (rendererBytes > SCENE3D_V2_LIMITS.maxRendererAssetBytes) {
|
|
246
|
+
issues.push({
|
|
247
|
+
path: ["assets"],
|
|
248
|
+
message: `downloaded scene assets total ${rendererBytes} bytes; the limit is ${SCENE3D_V2_LIMITS.maxRendererAssetBytes}`,
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const track = assetsById.get(plan.cameraTrackAssetId)
|
|
253
|
+
if (!track) {
|
|
254
|
+
issues.push({
|
|
255
|
+
path: ["cameraTrackAssetId"],
|
|
256
|
+
message: `cameraTrackAssetId "${plan.cameraTrackAssetId}" is not in assets`,
|
|
257
|
+
})
|
|
258
|
+
} else if (track.kind !== "camera-track-json") {
|
|
259
|
+
issues.push({
|
|
260
|
+
path: ["cameraTrackAssetId"],
|
|
261
|
+
message: `cameraTrackAssetId "${plan.cameraTrackAssetId}" is kind "${track.kind}"; a camera track must be camera-track-json`,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Every GLB must be reachable from an entity: an unreferenced one is dead
|
|
266
|
+
// weight the renderer would download for nothing.
|
|
267
|
+
const referencedGlbs = new Set<string>()
|
|
268
|
+
for (const entity of plan.objects) {
|
|
269
|
+
if (entity.visual.kind === "asset") referencedGlbs.add(entity.visual.assetId)
|
|
270
|
+
}
|
|
271
|
+
plan.assets.forEach((asset, index) => {
|
|
272
|
+
if (asset.kind === "glb" && !referencedGlbs.has(asset.assetId)) {
|
|
273
|
+
issues.push({
|
|
274
|
+
path: ["assets", index, "assetId"],
|
|
275
|
+
message: `glb asset "${asset.assetId}" is not referenced by any entity`,
|
|
276
|
+
})
|
|
277
|
+
}
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
// ...and every entity asset reference must resolve to a GLB.
|
|
281
|
+
plan.objects.forEach((entity, index) => {
|
|
282
|
+
if (entity.visual.kind !== "asset") return
|
|
283
|
+
const asset = assetsById.get(entity.visual.assetId)
|
|
284
|
+
if (!asset) {
|
|
285
|
+
issues.push({
|
|
286
|
+
path: ["objects", index, "visual", "assetId"],
|
|
287
|
+
message: `entity "${entity.id}" references unknown asset "${entity.visual.assetId}"`,
|
|
288
|
+
})
|
|
289
|
+
} else if (asset.kind !== "glb") {
|
|
290
|
+
issues.push({
|
|
291
|
+
path: ["objects", index, "visual", "assetId"],
|
|
292
|
+
message: `entity "${entity.id}" references asset "${asset.assetId}" of kind "${asset.kind}"; geometry must be glb`,
|
|
293
|
+
})
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
if (plan.provenance.sourceArtifactId !== undefined) {
|
|
298
|
+
const source = assetsById.get(plan.provenance.sourceArtifactId)
|
|
299
|
+
if (!source) {
|
|
300
|
+
issues.push({
|
|
301
|
+
path: ["provenance", "sourceArtifactId"],
|
|
302
|
+
message: `sourceArtifactId "${plan.provenance.sourceArtifactId}" is not in assets`,
|
|
303
|
+
})
|
|
304
|
+
} else if (source.kind !== "blend-source") {
|
|
305
|
+
issues.push({
|
|
306
|
+
path: ["provenance", "sourceArtifactId"],
|
|
307
|
+
message: `sourceArtifactId "${source.assetId}" is kind "${source.kind}"; a retained source must be blend-source`,
|
|
308
|
+
})
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function checkShots(plan: Scene3DPlanV2, byId: Map<string, Scene3DEntityV2>, issues: Issue[]): void {
|
|
314
|
+
const seenIds = new Set<string>()
|
|
315
|
+
let expectedStart = 0
|
|
316
|
+
|
|
317
|
+
plan.shots.forEach((shot, index) => {
|
|
318
|
+
const at = (...rest: (string | number)[]) => ["shots", index, ...rest]
|
|
319
|
+
|
|
320
|
+
if (seenIds.has(shot.id)) {
|
|
321
|
+
issues.push({ path: at("id"), message: `duplicate shot id "${shot.id}"` })
|
|
322
|
+
}
|
|
323
|
+
seenIds.add(shot.id)
|
|
324
|
+
|
|
325
|
+
if (shot.endFrameExclusive <= shot.startFrame) {
|
|
326
|
+
issues.push({
|
|
327
|
+
path: at("endFrameExclusive"),
|
|
328
|
+
message: `shot "${shot.id}" ends at or before it starts (${shot.startFrame}..${shot.endFrameExclusive})`,
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
if (shot.startFrame !== expectedStart) {
|
|
332
|
+
issues.push({
|
|
333
|
+
path: at("startFrame"),
|
|
334
|
+
message:
|
|
335
|
+
index === 0
|
|
336
|
+
? `shots must start at frame 0 (got ${shot.startFrame})`
|
|
337
|
+
: `shot "${shot.id}" starts at ${shot.startFrame}; the previous shot ends at ${expectedStart} — every frame belongs to exactly one shot`,
|
|
338
|
+
})
|
|
339
|
+
}
|
|
340
|
+
expectedStart = Math.max(expectedStart, shot.endFrameExclusive)
|
|
341
|
+
|
|
342
|
+
for (const field of ["subjectEntityIds", "foregroundEntityIds"] as const) {
|
|
343
|
+
;(shot[field] ?? []).forEach((entityId, entityIndex) => {
|
|
344
|
+
if (!byId.has(entityId)) {
|
|
345
|
+
issues.push({
|
|
346
|
+
path: at(field, entityIndex),
|
|
347
|
+
message: `shot "${shot.id}" references unknown entity "${entityId}"`,
|
|
348
|
+
})
|
|
349
|
+
}
|
|
350
|
+
})
|
|
351
|
+
}
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const last = plan.shots[plan.shots.length - 1]
|
|
355
|
+
if (last && last.endFrameExclusive !== plan.durationInFrames) {
|
|
356
|
+
issues.push({
|
|
357
|
+
path: ["shots", plan.shots.length - 1, "endFrameExclusive"],
|
|
358
|
+
message: `shots end at frame ${last.endFrameExclusive}; the scene is ${plan.durationInFrames} frames and must be covered completely`,
|
|
359
|
+
})
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function checkOverrides(
|
|
364
|
+
plan: Scene3DPlanV2,
|
|
365
|
+
byId: Map<string, Scene3DEntityV2>,
|
|
366
|
+
shotIds: Set<string>,
|
|
367
|
+
issues: Issue[],
|
|
368
|
+
): void {
|
|
369
|
+
const overrideIds = new Set<string>()
|
|
370
|
+
const transformTargets = new Set<string>()
|
|
371
|
+
const visibilityTargets = new Set<string>()
|
|
372
|
+
const colorTargets = new Set<string>()
|
|
373
|
+
const offsetTargets = new Set<string>()
|
|
374
|
+
|
|
375
|
+
;(plan.overrides ?? []).forEach((override, index) => {
|
|
376
|
+
const at = (...rest: (string | number)[]) => ["overrides", index, ...rest]
|
|
377
|
+
|
|
378
|
+
if (overrideIds.has(override.id)) {
|
|
379
|
+
issues.push({ path: at("id"), message: `duplicate override id "${override.id}"` })
|
|
380
|
+
}
|
|
381
|
+
overrideIds.add(override.id)
|
|
382
|
+
|
|
383
|
+
if (override.operationVersion > SCENE3D_V2_OVERRIDE_OPERATION_VERSION) {
|
|
384
|
+
issues.push({
|
|
385
|
+
path: at("operationVersion"),
|
|
386
|
+
message: `override "${override.id}" uses operation version ${override.operationVersion}; this reader understands up to ${SCENE3D_V2_OVERRIDE_OPERATION_VERSION}`,
|
|
387
|
+
})
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (override.kind === "camera-shot-offset") {
|
|
391
|
+
if (!shotIds.has(override.shotId)) {
|
|
392
|
+
issues.push({ path: at("shotId"), message: `override "${override.id}" targets unknown shot "${override.shotId}"` })
|
|
393
|
+
} else if (offsetTargets.has(override.shotId)) {
|
|
394
|
+
issues.push({
|
|
395
|
+
path: at("shotId"),
|
|
396
|
+
message: `shot "${override.shotId}" already has a camera offset; one owner per channel`,
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
offsetTargets.add(override.shotId)
|
|
400
|
+
if (override.positionOffset === undefined && override.targetOffset === undefined) {
|
|
401
|
+
issues.push({ path: at(), message: `override "${override.id}" offsets nothing` })
|
|
402
|
+
}
|
|
403
|
+
return
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const entity = byId.get(override.entityId)
|
|
407
|
+
if (!entity) {
|
|
408
|
+
issues.push({ path: at("entityId"), message: `override "${override.id}" targets unknown entity "${override.entityId}"` })
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (override.kind === "entity-transform") {
|
|
413
|
+
if (transformTargets.has(override.entityId)) {
|
|
414
|
+
issues.push({
|
|
415
|
+
path: at("entityId"),
|
|
416
|
+
message: `entity "${override.entityId}" already has a transform override; one owner per channel`,
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
transformTargets.add(override.entityId)
|
|
420
|
+
if (!scene3DEntityAcceptsOverlay(entity, "transform")) {
|
|
421
|
+
issues.push({
|
|
422
|
+
path: at("entityId"),
|
|
423
|
+
message: `entity "${override.entityId}" does not accept a transform overlay (locked or not advertised)`,
|
|
424
|
+
})
|
|
425
|
+
}
|
|
426
|
+
if (override.position === undefined && override.rotation === undefined && override.scale === undefined) {
|
|
427
|
+
issues.push({ path: at(), message: `override "${override.id}" changes nothing` })
|
|
428
|
+
}
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (override.kind === "entity-visibility") {
|
|
433
|
+
if (visibilityTargets.has(override.entityId)) {
|
|
434
|
+
issues.push({
|
|
435
|
+
path: at("entityId"),
|
|
436
|
+
message: `entity "${override.entityId}" already has a visibility override; one owner per channel`,
|
|
437
|
+
})
|
|
438
|
+
}
|
|
439
|
+
visibilityTargets.add(override.entityId)
|
|
440
|
+
if (!scene3DEntityAcceptsOverlay(entity, "visibility")) {
|
|
441
|
+
issues.push({
|
|
442
|
+
path: at("entityId"),
|
|
443
|
+
message: `entity "${override.entityId}" does not accept a visibility overlay (locked or not advertised)`,
|
|
444
|
+
})
|
|
445
|
+
}
|
|
446
|
+
return
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// entity-color
|
|
450
|
+
const key = `${override.entityId}\u0000${override.materialRole}`
|
|
451
|
+
if (colorTargets.has(key)) {
|
|
452
|
+
issues.push({
|
|
453
|
+
path: at("materialRole"),
|
|
454
|
+
message: `entity "${override.entityId}" already recolours material role "${override.materialRole}"`,
|
|
455
|
+
})
|
|
456
|
+
}
|
|
457
|
+
colorTargets.add(key)
|
|
458
|
+
if (!scene3DEntityAcceptsOverlay(entity, "color")) {
|
|
459
|
+
issues.push({
|
|
460
|
+
path: at("entityId"),
|
|
461
|
+
message: `entity "${override.entityId}" does not accept a colour overlay (locked or not advertised)`,
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
if (entity.visual.kind === "group") {
|
|
465
|
+
issues.push({
|
|
466
|
+
path: at("materialRole"),
|
|
467
|
+
message: `entity "${override.entityId}" is a group and has no geometry to recolour`,
|
|
468
|
+
})
|
|
469
|
+
} else if (entity.visual.kind === "primitive") {
|
|
470
|
+
if (override.materialRole !== SCENE3D_PRIMITIVE_MATERIAL_ROLE) {
|
|
471
|
+
issues.push({
|
|
472
|
+
path: at("materialRole"),
|
|
473
|
+
message: `entity "${override.entityId}" is a primitive; its only material role is "${SCENE3D_PRIMITIVE_MATERIAL_ROLE}"`,
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
} else if (!(entity.materialBindings ?? []).some((binding) => binding.role === override.materialRole)) {
|
|
477
|
+
issues.push({
|
|
478
|
+
path: at("materialRole"),
|
|
479
|
+
message: `entity "${override.entityId}" declares no material role "${override.materialRole}"; a binding may only name materials in that entity's asset root`,
|
|
480
|
+
})
|
|
481
|
+
}
|
|
482
|
+
})
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Every v2 rule that needs more than one field: timing, identity, hierarchy,
|
|
487
|
+
* asset resolution and budgets, shot coverage, overlay ownership and locks.
|
|
488
|
+
*
|
|
489
|
+
* Split out of the schema's `superRefine` (exactly as v1 does) so a caller
|
|
490
|
+
* holding an already-parsed plan can re-check it without re-parsing.
|
|
491
|
+
*/
|
|
492
|
+
export function scene3DPlanV2Issues(plan: Scene3DPlanV2): Issue[] {
|
|
493
|
+
const issues: Issue[] = []
|
|
494
|
+
|
|
495
|
+
const seconds = plan.durationInFrames / plan.fps
|
|
496
|
+
if (seconds > SCENE3D_V2_LIMITS.maxDurationSeconds) {
|
|
497
|
+
issues.push({
|
|
498
|
+
path: ["durationInFrames"],
|
|
499
|
+
message: `scene is ${seconds.toFixed(2)}s; the limit is ${SCENE3D_V2_LIMITS.maxDurationSeconds}s`,
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
for (const axis of ["width", "height"] as const) {
|
|
503
|
+
if (plan[axis] % 2 !== 0) {
|
|
504
|
+
issues.push({ path: [axis], message: `${axis} must be an even number of pixels (got ${plan[axis]})` })
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const byId = new Map<string, Scene3DEntityV2>()
|
|
509
|
+
plan.objects.forEach((entity, index) => {
|
|
510
|
+
if (byId.has(entity.id)) {
|
|
511
|
+
issues.push({ path: ["objects", index, "id"], message: `duplicate entity id "${entity.id}"` })
|
|
512
|
+
return
|
|
513
|
+
}
|
|
514
|
+
byId.set(entity.id, entity)
|
|
515
|
+
})
|
|
516
|
+
|
|
517
|
+
const assetsById = new Map<string, Scene3DAssetRef>()
|
|
518
|
+
plan.assets.forEach((asset, index) => {
|
|
519
|
+
if (assetsById.has(asset.assetId)) {
|
|
520
|
+
issues.push({ path: ["assets", index, "assetId"], message: `duplicate asset id "${asset.assetId}"` })
|
|
521
|
+
return
|
|
522
|
+
}
|
|
523
|
+
assetsById.set(asset.assetId, asset)
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
checkEntities(plan, byId, issues)
|
|
527
|
+
checkAssets(plan, assetsById, issues)
|
|
528
|
+
checkShots(plan, byId, issues)
|
|
529
|
+
checkOverrides(plan, byId, new Set(plan.shots.map((shot) => shot.id)), issues)
|
|
530
|
+
|
|
531
|
+
const referenceIds = new Set<string>()
|
|
532
|
+
;(plan.references ?? []).forEach((reference, index) => {
|
|
533
|
+
if (referenceIds.has(reference.id)) {
|
|
534
|
+
issues.push({ path: ["references", index, "id"], message: `duplicate reference id "${reference.id}"` })
|
|
535
|
+
}
|
|
536
|
+
referenceIds.add(reference.id)
|
|
537
|
+
if (reference.objectId !== undefined && !byId.has(reference.objectId)) {
|
|
538
|
+
issues.push({
|
|
539
|
+
path: ["references", index, "objectId"],
|
|
540
|
+
message: `reference "${reference.id}" points at unknown entity "${reference.objectId}"`,
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
if (
|
|
544
|
+
reference.startSeconds !== undefined &&
|
|
545
|
+
reference.endSeconds !== undefined &&
|
|
546
|
+
reference.endSeconds <= reference.startSeconds
|
|
547
|
+
) {
|
|
548
|
+
issues.push({
|
|
549
|
+
path: ["references", index, "endSeconds"],
|
|
550
|
+
message: `reference "${reference.id}" ends at or before it starts`,
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
if (reference.kind === "image" && (reference.startSeconds !== undefined || reference.endSeconds !== undefined)) {
|
|
554
|
+
issues.push({
|
|
555
|
+
path: ["references", index, "startSeconds"],
|
|
556
|
+
message: `reference "${reference.id}" is an image; a time window applies to video only`,
|
|
557
|
+
})
|
|
558
|
+
}
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
return issues
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* The v2 object shape WITHOUT the cross-field pass. Exported so
|
|
566
|
+
* `scene3DAnyPlanSchema` can discriminate on `schemaVersion`; parse with
|
|
567
|
+
* `scene3DPlanV2Schema`.
|
|
568
|
+
*
|
|
569
|
+
* Deliberately free of `.default()`: `parse(x)` must deep-equal `x`, or a
|
|
570
|
+
* producer hashing raw JSON and a consumer hashing parsed output would compute
|
|
571
|
+
* different content hashes for the same revision.
|
|
572
|
+
*/
|
|
573
|
+
export const scene3DPlanV2ObjectSchema = z
|
|
574
|
+
.object({
|
|
575
|
+
planType: z.literal(SCENE3D_PLAN_TYPE),
|
|
576
|
+
schemaVersion: z.literal(SCENE3D_SCHEMA_VERSION_V2),
|
|
577
|
+
revisionId: z.uuid(),
|
|
578
|
+
parentRevisionId: z.uuid().optional(),
|
|
579
|
+
width: z.number().int().min(SCENE3D_V2_LIMITS.minDimensionPx).max(SCENE3D_V2_LIMITS.maxDimensionPx),
|
|
580
|
+
height: z.number().int().min(SCENE3D_V2_LIMITS.minDimensionPx).max(SCENE3D_V2_LIMITS.maxDimensionPx),
|
|
581
|
+
fps: z.number().int().min(SCENE3D_V2_LIMITS.minFps).max(SCENE3D_V2_LIMITS.maxFps),
|
|
582
|
+
durationInFrames: z
|
|
583
|
+
.number()
|
|
584
|
+
.int()
|
|
585
|
+
.min(SCENE3D_V2_LIMITS.minDurationInFrames)
|
|
586
|
+
.max(SCENE3D_V2_LIMITS.maxDurationInFrames),
|
|
587
|
+
units: z.literal("meters"),
|
|
588
|
+
upAxis: z.literal("Y"),
|
|
589
|
+
handedness: z.literal("right"),
|
|
590
|
+
objects: z
|
|
591
|
+
.array(scene3DEntityV2Schema)
|
|
592
|
+
.min(SCENE3D_V2_LIMITS.minEntities)
|
|
593
|
+
.max(SCENE3D_V2_LIMITS.maxEntities),
|
|
594
|
+
assets: z.array(scene3DAssetRefSchema).min(1).max(SCENE3D_V2_LIMITS.maxAssets),
|
|
595
|
+
cameraTrackAssetId: scene3DAssetIdSchema,
|
|
596
|
+
shots: z.array(scene3DShotSchema).min(1).max(SCENE3D_V2_LIMITS.maxShots),
|
|
597
|
+
lighting: scene3DClayLightingSchema,
|
|
598
|
+
backgroundColor: scene3DColorSchema,
|
|
599
|
+
references: z.array(scene3DReferenceSchema).max(SCENE3D_V2_LIMITS.maxReferences).optional(),
|
|
600
|
+
overrides: z.array(scene3DOverrideSchema).max(SCENE3D_V2_LIMITS.maxOverrides).optional(),
|
|
601
|
+
provenance: scene3DProvenanceSchema,
|
|
602
|
+
})
|
|
603
|
+
.strict()
|
|
604
|
+
|
|
605
|
+
/** THE v2 plan validator: structure first, then the cross-field rules. */
|
|
606
|
+
export const scene3DPlanV2Schema = scene3DPlanV2ObjectSchema.superRefine((plan, ctx) => {
|
|
607
|
+
for (const issue of scene3DPlanV2Issues(plan as Scene3DPlanV2)) {
|
|
608
|
+
ctx.addIssue({ code: "custom", path: issue.path, message: issue.message })
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Either version, discriminated on `schemaVersion` — so an unknown version
|
|
614
|
+
* reports "Invalid discriminator value. Expected '1' | '2'" instead of a pile
|
|
615
|
+
* of unknown-key errors from whichever branch failed last.
|
|
616
|
+
*/
|
|
617
|
+
export const scene3DAnyPlanSchema = z
|
|
618
|
+
.discriminatedUnion("schemaVersion", [scene3DPlanV1ObjectSchema, scene3DPlanV2ObjectSchema])
|
|
619
|
+
.superRefine((plan, ctx) => {
|
|
620
|
+
const issues =
|
|
621
|
+
plan.schemaVersion === SCENE3D_SCHEMA_VERSION_V2
|
|
622
|
+
? scene3DPlanV2Issues(plan as Scene3DPlanV2)
|
|
623
|
+
: scene3DPlanV1Issues(plan as Scene3DPlanV1)
|
|
624
|
+
for (const issue of issues) {
|
|
625
|
+
ctx.addIssue({ code: "custom", path: issue.path, message: issue.message })
|
|
626
|
+
}
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
/** Zod for a client's `acceptedSceneSchemaVersions`. */
|
|
630
|
+
export const scene3DAcceptedSchemaVersionsSchema = z
|
|
631
|
+
.array(z.union([z.literal(1), z.literal(2)]))
|
|
632
|
+
.min(1)
|
|
633
|
+
.max(SCENE3D_SUPPORTED_SCHEMA_VERSIONS.length)
|
|
634
|
+
// ---------------------------------------------------------------------------
|
|
635
|
+
// Narrowing and version negotiation
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
|
|
638
|
+
export function isScene3DPlanV2(value: unknown): value is Scene3DPlanV2 {
|
|
639
|
+
return scene3DPlanV2Schema.safeParse(value).success
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** Accepts EITHER version. `isScene3DPlanV1` (in `scene3d.ts`) is the v1-only
|
|
643
|
+
* form; narrow with one of them before reading version-specific fields. */
|
|
644
|
+
export function isScene3DPlan(value: unknown): value is Scene3DPlan {
|
|
645
|
+
return scene3DAnyPlanSchema.safeParse(value).success
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* The schema version a value CLAIMS, without validating the rest of it.
|
|
650
|
+
*
|
|
651
|
+
* Returns the number even when this package cannot handle it, so an SDK
|
|
652
|
+
* consumer can say "this scene is v3, upgrade to render it" instead of "invalid
|
|
653
|
+
* plan". `null` means it is not a Scene3D plan at all.
|
|
654
|
+
*/
|
|
655
|
+
export function scene3DPlanSchemaVersion(value: unknown): number | null {
|
|
656
|
+
if (typeof value !== "object" || value === null) return null
|
|
657
|
+
const record = value as { planType?: unknown; schemaVersion?: unknown }
|
|
658
|
+
if (record.planType !== SCENE3D_PLAN_TYPE) return null
|
|
659
|
+
if (typeof record.schemaVersion !== "number" || !Number.isInteger(record.schemaVersion)) return null
|
|
660
|
+
return record.schemaVersion
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
export function isScene3DSchemaVersionSupported(version: number): version is Scene3DSupportedSchemaVersion {
|
|
664
|
+
return (SCENE3D_SUPPORTED_SCHEMA_VERSIONS as readonly number[]).includes(version)
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
export function isKnownScene3DEngine(engine: string): engine is Scene3DKnownEngine {
|
|
668
|
+
return (SCENE3D_V2_ENGINES as readonly string[]).includes(engine)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// ---------------------------------------------------------------------------
|
|
672
|
+
// Shot lookup
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The shot owning `frame`, or `-1`. Ranges are half-open and contiguous, so
|
|
677
|
+
* this is total over `[0, durationInFrames)` on a validated plan — and it is
|
|
678
|
+
* the ONLY place a renderer decides which side of a cut a frame is on.
|
|
679
|
+
*/
|
|
680
|
+
export function scene3DShotIndexForFrame(shots: readonly Scene3DShot[], frame: number): number {
|
|
681
|
+
for (let index = 0; index < shots.length; index++) {
|
|
682
|
+
const shot = shots[index]
|
|
683
|
+
if (frame >= shot.startFrame && frame < shot.endFrameExclusive) return index
|
|
684
|
+
}
|
|
685
|
+
return -1
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export function scene3DShotForFrame(
|
|
689
|
+
shots: readonly Scene3DShot[],
|
|
690
|
+
frame: number,
|
|
691
|
+
): Scene3DShot | undefined {
|
|
692
|
+
const index = scene3DShotIndexForFrame(shots, frame)
|
|
693
|
+
return index === -1 ? undefined : shots[index]
|
|
694
|
+
}
|