@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,382 @@
1
+ /**
2
+ * Scene3D v2 resource admission and revision identity.
3
+ *
4
+ * Two gates and one hash, all of which have to agree across the builder, the
5
+ * platform route and the renderer — so they live in one published place instead
6
+ * of being re-derived three times.
7
+ *
8
+ * **Pre-allocation gate** (`scene3DV2AdmissionIssues`). Everything checkable
9
+ * from the manifest alone, BEFORE a byte is fetched or a decoder is handed
10
+ * anything: declared asset sizes, counts, timeline length, hierarchy depth, and
11
+ * the decoded size of the manifest itself. A limit enforced after the download
12
+ * is not a limit.
13
+ *
14
+ * **Post-decode gate** (`scene3DV2NormalizationIssues`). What only the actual
15
+ * bytes can answer: real length versus declared, digest versus declared,
16
+ * triangles, mesh nodes, node depth, image dimensions. A 2 MiB GLB can decode
17
+ * to a hundred million triangles, so compression never waives a geometry
18
+ * budget.
19
+ *
20
+ * **Content hash.** The canonical form of a revision, which is what makes
21
+ * "these two revisions are the same scene" a decidable question — for
22
+ * content-addressed caching, and for asserting after a rebuild that the entities
23
+ * the user locked really did come back unchanged.
24
+ */
25
+ import {
26
+ SCENE3D_RENDERER_ASSET_KINDS,
27
+ SCENE3D_V2_LIMITS,
28
+ scene3DJsonByteLength,
29
+ scene3DZodIssues,
30
+ type Scene3DAssetKind,
31
+ type Scene3DEntityV2,
32
+ type Scene3DParseResult,
33
+ type Scene3DPlanV2,
34
+ } from "./scene3d-v2.js"
35
+ import { scene3DPlanV2Schema } from "./scene3d-v2-plan.js"
36
+ import type { Scene3DSemanticIssue } from "./scene3d.js"
37
+
38
+ type Issue = Scene3DSemanticIssue
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Declared usage (pre-allocation)
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface Scene3DV2ResourceUsage {
45
+ entities: number
46
+ assets: number
47
+ shots: number
48
+ overrides: number
49
+ references: number
50
+ frames: number
51
+ durationSeconds: number
52
+ /** Deepest entity parent chain, 1 for a flat scene. */
53
+ hierarchyDepth: number
54
+ /** Declared bytes of everything the browser downloads. */
55
+ rendererAssetBytes: number
56
+ /** Declared bytes of the camera sidecar. */
57
+ cameraTrackBytes: number
58
+ /** Declared bytes of the retained native source, which the browser never sees. */
59
+ blendSourceBytes: number
60
+ }
61
+
62
+ /** Deepest parent chain, counting the entity itself. Bounded by the entity
63
+ * count even on a cyclic plan, so it is safe to call before validation. */
64
+ export function scene3DV2HierarchyDepth(entities: readonly Scene3DEntityV2[]): number {
65
+ const byId = new Map(entities.map((entity) => [entity.id, entity]))
66
+ let deepest = 0
67
+ for (const entity of entities) {
68
+ let depth = 1
69
+ let cursor = entity
70
+ const seen = new Set<string>([entity.id])
71
+ while (cursor.parentId !== undefined) {
72
+ const parent = byId.get(cursor.parentId)
73
+ if (!parent || seen.has(parent.id)) break
74
+ seen.add(parent.id)
75
+ cursor = parent
76
+ depth += 1
77
+ }
78
+ if (depth > deepest) deepest = depth
79
+ }
80
+ return deepest
81
+ }
82
+
83
+ /** What this manifest CLAIMS it will cost. Also the shape a capabilities or
84
+ * quote surface displays — it is derived, never authored. */
85
+ export function scene3DV2ResourceUsage(plan: Scene3DPlanV2): Scene3DV2ResourceUsage {
86
+ let rendererAssetBytes = 0
87
+ let cameraTrackBytes = 0
88
+ let blendSourceBytes = 0
89
+ for (const asset of plan.assets) {
90
+ if (SCENE3D_RENDERER_ASSET_KINDS.includes(asset.kind)) rendererAssetBytes += asset.byteLength
91
+ if (asset.kind === "camera-track-json") cameraTrackBytes += asset.byteLength
92
+ if (asset.kind === "blend-source") blendSourceBytes += asset.byteLength
93
+ }
94
+ return {
95
+ entities: plan.objects.length,
96
+ assets: plan.assets.length,
97
+ shots: plan.shots.length,
98
+ overrides: plan.overrides?.length ?? 0,
99
+ references: plan.references?.length ?? 0,
100
+ frames: plan.durationInFrames,
101
+ durationSeconds: plan.durationInFrames / plan.fps,
102
+ hierarchyDepth: scene3DV2HierarchyDepth(plan.objects),
103
+ rendererAssetBytes,
104
+ cameraTrackBytes,
105
+ blendSourceBytes,
106
+ }
107
+ }
108
+
109
+ /**
110
+ * The pre-allocation gate. `manifestBytes` is the DECODED size of the manifest
111
+ * as it arrived — pass it when admitting a downloaded manifest, omit it when
112
+ * the plan is already in memory.
113
+ *
114
+ * Most of these are also enforced by `scene3DPlanV2Schema`; this function is
115
+ * what a caller runs when it wants the budget answer without re-parsing, and
116
+ * what makes the ceilings quotable in one place by capabilities and docs.
117
+ */
118
+ export function scene3DV2AdmissionIssues(plan: Scene3DPlanV2, manifestBytes?: number): Issue[] {
119
+ const issues: Issue[] = []
120
+ const usage = scene3DV2ResourceUsage(plan)
121
+ const limits = SCENE3D_V2_LIMITS
122
+
123
+ if (manifestBytes !== undefined && manifestBytes > limits.maxManifestBytes) {
124
+ issues.push({
125
+ path: [],
126
+ message: `manifest is ${manifestBytes} bytes; the limit is ${limits.maxManifestBytes}`,
127
+ })
128
+ }
129
+ if (usage.frames > limits.maxDurationInFrames) {
130
+ issues.push({
131
+ path: ["durationInFrames"],
132
+ message: `scene is ${usage.frames} frames; the limit is ${limits.maxDurationInFrames}`,
133
+ })
134
+ }
135
+ if (usage.durationSeconds > limits.maxDurationSeconds) {
136
+ issues.push({
137
+ path: ["durationInFrames"],
138
+ message: `scene is ${usage.durationSeconds.toFixed(2)}s; the limit is ${limits.maxDurationSeconds}s`,
139
+ })
140
+ }
141
+ if (usage.entities > limits.maxEntities) {
142
+ issues.push({ path: ["objects"], message: `${usage.entities} entities; the limit is ${limits.maxEntities}` })
143
+ }
144
+ if (usage.shots > limits.maxShots) {
145
+ issues.push({ path: ["shots"], message: `${usage.shots} shots; the limit is ${limits.maxShots}` })
146
+ }
147
+ if (usage.hierarchyDepth > limits.maxHierarchyDepth) {
148
+ issues.push({
149
+ path: ["objects"],
150
+ message: `hierarchy is ${usage.hierarchyDepth} deep; the limit is ${limits.maxHierarchyDepth}`,
151
+ })
152
+ }
153
+ if (usage.rendererAssetBytes > limits.maxRendererAssetBytes) {
154
+ issues.push({
155
+ path: ["assets"],
156
+ message: `downloaded scene assets total ${usage.rendererAssetBytes} bytes; the limit is ${limits.maxRendererAssetBytes}`,
157
+ })
158
+ }
159
+ if (usage.cameraTrackBytes > limits.maxCameraTrackBytes) {
160
+ issues.push({
161
+ path: ["assets"],
162
+ message: `camera track data totals ${usage.cameraTrackBytes} bytes; the limit is ${limits.maxCameraTrackBytes}`,
163
+ })
164
+ }
165
+ return issues
166
+ }
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // Post-decode normalization
170
+ // ---------------------------------------------------------------------------
171
+
172
+ /**
173
+ * What the normalizer measured on the ACTUAL bytes of one asset. Optional
174
+ * fields are "not applicable to this kind" — a poster has image dimensions and
175
+ * no triangles; a GLB is the other way round.
176
+ */
177
+ export interface Scene3DNormalizedAssetStats {
178
+ assetId: string
179
+ kind: Scene3DAssetKind
180
+ /** Decoded length, after any transport compression. */
181
+ byteLength: number
182
+ /** Digest of the decoded bytes, lowercase hex, when computed. */
183
+ sha256?: string
184
+ meshNodes?: number
185
+ triangles?: number
186
+ /** Deepest node chain inside the asset's own scene graph. */
187
+ maxNodeDepth?: number
188
+ imageWidth?: number
189
+ imageHeight?: number
190
+ }
191
+
192
+ /**
193
+ * The post-decode gate: does what arrived match what the manifest promised, and
194
+ * does the resolved geometry fit the budget?
195
+ *
196
+ * Mesh nodes and triangles are summed ACROSS assets — the ceiling is on the
197
+ * scene the renderer assembles, not on any single file.
198
+ */
199
+ export function scene3DV2NormalizationIssues(
200
+ plan: Scene3DPlanV2,
201
+ stats: readonly Scene3DNormalizedAssetStats[],
202
+ ): Issue[] {
203
+ const issues: Issue[] = []
204
+ const limits = SCENE3D_V2_LIMITS
205
+ const declared = new Map(plan.assets.map((asset) => [asset.assetId, asset]))
206
+
207
+ let meshNodes = 0
208
+ let triangles = 0
209
+ let rendererBytes = 0
210
+
211
+ stats.forEach((stat, index) => {
212
+ const at = (...rest: (string | number)[]) => [index, ...rest]
213
+ const asset = declared.get(stat.assetId)
214
+ if (!asset) {
215
+ issues.push({ path: at("assetId"), message: `asset "${stat.assetId}" is not declared in the manifest` })
216
+ return
217
+ }
218
+ if (stat.kind !== asset.kind) {
219
+ issues.push({
220
+ path: at("kind"),
221
+ message: `asset "${stat.assetId}" decoded as "${stat.kind}" but the manifest declares "${asset.kind}"`,
222
+ })
223
+ }
224
+ if (stat.byteLength !== asset.byteLength) {
225
+ issues.push({
226
+ path: at("byteLength"),
227
+ message: `asset "${stat.assetId}" is ${stat.byteLength} bytes; the manifest declares ${asset.byteLength}`,
228
+ })
229
+ }
230
+ if (stat.sha256 !== undefined && stat.sha256 !== asset.sha256) {
231
+ issues.push({
232
+ path: at("sha256"),
233
+ message: `asset "${stat.assetId}" digest does not match the manifest`,
234
+ })
235
+ }
236
+
237
+ if (SCENE3D_RENDERER_ASSET_KINDS.includes(stat.kind)) rendererBytes += stat.byteLength
238
+ if (stat.kind === "camera-track-json" && stat.byteLength > limits.maxCameraTrackBytes) {
239
+ issues.push({
240
+ path: at("byteLength"),
241
+ message: `camera track "${stat.assetId}" decoded to ${stat.byteLength} bytes; the limit is ${limits.maxCameraTrackBytes}`,
242
+ })
243
+ }
244
+
245
+ meshNodes += stat.meshNodes ?? 0
246
+ triangles += stat.triangles ?? 0
247
+
248
+ if (stat.maxNodeDepth !== undefined && stat.maxNodeDepth > limits.maxHierarchyDepth) {
249
+ issues.push({
250
+ path: at("maxNodeDepth"),
251
+ message: `asset "${stat.assetId}" nests ${stat.maxNodeDepth} levels; the limit is ${limits.maxHierarchyDepth}`,
252
+ })
253
+ }
254
+
255
+ for (const [field, value] of [
256
+ ["imageWidth", stat.imageWidth],
257
+ ["imageHeight", stat.imageHeight],
258
+ ] as const) {
259
+ if (value === undefined) continue
260
+ if (!Number.isInteger(value) || value < limits.minPosterDimensionPx || value > limits.maxPosterDimensionPx) {
261
+ issues.push({
262
+ path: at(field),
263
+ message: `asset "${stat.assetId}" ${field} is ${value}; it must be an integer between ${limits.minPosterDimensionPx} and ${limits.maxPosterDimensionPx}`,
264
+ })
265
+ }
266
+ }
267
+ })
268
+
269
+ if (meshNodes > limits.maxMeshNodes) {
270
+ issues.push({ path: [], message: `resolved assets contain ${meshNodes} mesh nodes; the limit is ${limits.maxMeshNodes}` })
271
+ }
272
+ if (triangles > limits.maxTriangles) {
273
+ issues.push({ path: [], message: `resolved assets contain ${triangles} triangles; the limit is ${limits.maxTriangles}` })
274
+ }
275
+ if (rendererBytes > limits.maxRendererAssetBytes) {
276
+ issues.push({
277
+ path: [],
278
+ message: `downloaded scene assets decoded to ${rendererBytes} bytes; the limit is ${limits.maxRendererAssetBytes}`,
279
+ })
280
+ }
281
+
282
+ return issues
283
+ }
284
+
285
+ // ---------------------------------------------------------------------------
286
+ // Manifest admission
287
+ // ---------------------------------------------------------------------------
288
+
289
+ /** Size-gate on the bytes, then parse, then the full v2 schema. Refuses an
290
+ * oversized manifest before `JSON.parse` allocates it. */
291
+ export function parseScene3DPlanV2Json(text: string): Scene3DParseResult<Scene3DPlanV2> {
292
+ const bytes = scene3DJsonByteLength(text)
293
+ if (bytes > SCENE3D_V2_LIMITS.maxManifestBytes) {
294
+ return {
295
+ ok: false,
296
+ issues: [{ path: [], message: `manifest is ${bytes} bytes; the limit is ${SCENE3D_V2_LIMITS.maxManifestBytes}` }],
297
+ }
298
+ }
299
+ let decoded: unknown
300
+ try {
301
+ decoded = JSON.parse(text)
302
+ } catch {
303
+ return { ok: false, issues: [{ path: [], message: "manifest is not valid JSON" }] }
304
+ }
305
+ const parsed = scene3DPlanV2Schema.safeParse(decoded)
306
+ if (!parsed.success) return { ok: false, issues: scene3DZodIssues(parsed.error) }
307
+ return { ok: true, value: parsed.data as Scene3DPlanV2 }
308
+ }
309
+
310
+ // ---------------------------------------------------------------------------
311
+ // Canonical form and content hash
312
+ // ---------------------------------------------------------------------------
313
+
314
+ /**
315
+ * Fields excluded from the canonical form.
316
+ *
317
+ * `revisionId`/`parentRevisionId` are IDENTITY, not content: two revisions with
318
+ * the same scene must hash the same, or a content-addressed cache never hits
319
+ * and "did the rebuild preserve the locked entities?" cannot be answered by
320
+ * comparing hashes. `provenance.contentHash` is excluded because a value cannot
321
+ * contain its own hash.
322
+ *
323
+ * Everything else is in — entities, anchors, material bindings, overrides, asset
324
+ * digests, shots, lighting, provenance versions.
325
+ */
326
+ export const SCENE3D_V2_CONTENT_HASH_EXCLUDED = ["revisionId", "parentRevisionId"] as const
327
+
328
+ function canonicalize(value: unknown): string {
329
+ if (value === null) return "null"
330
+ if (typeof value === "number") {
331
+ if (!Number.isFinite(value)) throw new Error("cannot canonicalize a non-finite number")
332
+ // -0 and 0 are the same scene; JSON.stringify disagrees.
333
+ return JSON.stringify(value === 0 ? 0 : value)
334
+ }
335
+ if (typeof value === "string" || typeof value === "boolean") return JSON.stringify(value)
336
+ if (Array.isArray(value)) return `[${value.map((item) => canonicalize(item)).join(",")}]`
337
+ if (typeof value === "object") {
338
+ const record = value as Record<string, unknown>
339
+ const parts: string[] = []
340
+ for (const key of Object.keys(record).sort()) {
341
+ const entry = record[key]
342
+ if (entry === undefined) continue
343
+ parts.push(`${JSON.stringify(key)}:${canonicalize(entry)}`)
344
+ }
345
+ return `{${parts.join(",")}}`
346
+ }
347
+ throw new Error(`cannot canonicalize ${typeof value}`)
348
+ }
349
+
350
+ /**
351
+ * The exact bytes a revision's content hash is computed over: recursively
352
+ * key-sorted JSON with the identity fields removed. Key order in the input
353
+ * cannot change the result, so a manifest that survives a round-trip through a
354
+ * database or a re-serialization still hashes the same.
355
+ */
356
+ export function canonicalScene3DPlanV2Json(plan: Scene3DPlanV2): string {
357
+ const { revisionId: _revisionId, parentRevisionId: _parentRevisionId, provenance, ...rest } = plan
358
+ const { contentHash: _contentHash, ...provenanceRest } = provenance
359
+ return canonicalize({ ...rest, provenance: provenanceRest })
360
+ }
361
+
362
+ function toHex(buffer: ArrayBuffer): string {
363
+ return Array.from(new Uint8Array(buffer), (byte) => byte.toString(16).padStart(2, "0")).join("")
364
+ }
365
+
366
+ /**
367
+ * SHA-256 of the canonical form, lowercase hex — the value that belongs in
368
+ * `provenance.contentHash`. Uses WebCrypto, which the browser, Node 18+ and the
369
+ * Remotion renderer all expose, so producer and consumer compute it the same
370
+ * way.
371
+ */
372
+ export async function computeScene3DPlanV2ContentHash(plan: Scene3DPlanV2): Promise<string> {
373
+ const subtle = (globalThis as { crypto?: Crypto }).crypto?.subtle
374
+ if (!subtle) throw new Error("WebCrypto SubtleCrypto is required to hash a Scene3D revision")
375
+ const bytes = new TextEncoder().encode(canonicalScene3DPlanV2Json(plan))
376
+ return toHex(await subtle.digest("SHA-256", bytes))
377
+ }
378
+
379
+ /** Does the manifest's declared `provenance.contentHash` match its content? */
380
+ export async function verifyScene3DPlanV2ContentHash(plan: Scene3DPlanV2): Promise<boolean> {
381
+ return (await computeScene3DPlanV2ContentHash(plan)) === plan.provenance.contentHash
382
+ }