@nodaro/shared 2.27.0 → 3.0.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.
@@ -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
+ }