@nodaro/shared 2.26.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.
- package/dist/index.cjs +1920 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2594 -2
- package/dist/index.d.ts +2594 -2
- package/dist/index.js +1803 -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-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 +482 -0
- package/src/index.ts +12 -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-camera-track.ts +369 -0
- package/src/scene3d-edit.ts +311 -0
- 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 +641 -0
|
@@ -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
|
+
}
|