@forgeax/engine-import 0.0.0-dev.8d955ade1c79

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.
Files changed (118) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +227 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
  5. package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
  6. package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
  7. package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
  8. package/dist/__tests__/import-dependencies.test.d.ts +2 -0
  9. package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
  10. package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
  11. package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
  12. package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
  13. package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
  14. package/dist/__tests__/import.unit.test.d.ts +2 -0
  15. package/dist/__tests__/import.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
  17. package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
  18. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
  19. package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
  20. package/dist/__tests__/mesh-bin.test.d.ts +2 -0
  21. package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
  22. package/dist/__tests__/owner-chain.integration.test.d.ts +2 -0
  23. package/dist/__tests__/owner-chain.integration.test.d.ts.map +1 -0
  24. package/dist/__tests__/scriptable-pack-host.unit.test.d.ts +2 -0
  25. package/dist/__tests__/scriptable-pack-host.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
  27. package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/scriptable-pack-product.contract.test.d.ts +2 -0
  29. package/dist/__tests__/scriptable-pack-product.contract.test.d.ts.map +1 -0
  30. package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
  31. package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
  33. package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
  35. package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
  37. package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/source-package-publication.integration.test.d.ts +2 -0
  39. package/dist/__tests__/source-package-publication.integration.test.d.ts.map +1 -0
  40. package/dist/browser.d.ts +5 -0
  41. package/dist/browser.d.ts.map +1 -0
  42. package/dist/browser.mjs +782 -0
  43. package/dist/browser.mjs.map +1 -0
  44. package/dist/build-production.d.ts +48 -0
  45. package/dist/build-production.d.ts.map +1 -0
  46. package/dist/catalog-importer-policy.d.ts +13 -0
  47. package/dist/catalog-importer-policy.d.ts.map +1 -0
  48. package/dist/catalog-inventory.d.ts +4 -0
  49. package/dist/catalog-inventory.d.ts.map +1 -0
  50. package/dist/import-product.d.ts +27 -0
  51. package/dist/import-product.d.ts.map +1 -0
  52. package/dist/import-runner.d.ts +136 -0
  53. package/dist/import-runner.d.ts.map +1 -0
  54. package/dist/importer-registry.d.ts +48 -0
  55. package/dist/importer-registry.d.ts.map +1 -0
  56. package/dist/index.d.ts +19 -0
  57. package/dist/index.d.ts.map +1 -0
  58. package/dist/index.mjs +2849 -0
  59. package/dist/index.mjs.map +1 -0
  60. package/dist/mesh-bin.d.ts +19 -0
  61. package/dist/mesh-bin.d.ts.map +1 -0
  62. package/dist/mesh-bin.mjs +182 -0
  63. package/dist/mesh-bin.mjs.map +1 -0
  64. package/dist/pack-projection.d.ts +5 -0
  65. package/dist/pack-projection.d.ts.map +1 -0
  66. package/dist/scriptable-pack-host.d.ts +72 -0
  67. package/dist/scriptable-pack-host.d.ts.map +1 -0
  68. package/dist/scriptable-pack-output-producers.d.ts +7 -0
  69. package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
  70. package/dist/scriptable-pack-staged-snapshot.d.ts +21 -0
  71. package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
  72. package/dist/scriptable-pack.d.ts +141 -0
  73. package/dist/scriptable-pack.d.ts.map +1 -0
  74. package/dist/scriptable-source-package.d.ts +14 -0
  75. package/dist/scriptable-source-package.d.ts.map +1 -0
  76. package/dist/source-package-errors.d.ts +52 -0
  77. package/dist/source-package-errors.d.ts.map +1 -0
  78. package/dist/source-package-publication.d.ts +63 -0
  79. package/dist/source-package-publication.d.ts.map +1 -0
  80. package/dist/source-package.d.ts +60 -0
  81. package/dist/source-package.d.ts.map +1 -0
  82. package/package.json +69 -0
  83. package/src/__tests__/decode-image-owner.test-d.ts +53 -0
  84. package/src/__tests__/import-contract-migration.test.ts +223 -0
  85. package/src/__tests__/import-dependencies.test.ts +65 -0
  86. package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
  87. package/src/__tests__/import-local-artifacts.test.ts +102 -0
  88. package/src/__tests__/import.unit.test.ts +429 -0
  89. package/src/__tests__/mesh-bin-contract.test.ts +95 -0
  90. package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
  91. package/src/__tests__/mesh-bin.test.ts +102 -0
  92. package/src/__tests__/owner-chain.integration.test.ts +37 -0
  93. package/src/__tests__/scriptable-pack-host.unit.test.ts +86 -0
  94. package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
  95. package/src/__tests__/scriptable-pack-product.contract.test.ts +47 -0
  96. package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +488 -0
  97. package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +290 -0
  98. package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
  99. package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
  100. package/src/__tests__/source-package-publication.integration.test.ts +75 -0
  101. package/src/browser.ts +14 -0
  102. package/src/build-production.ts +493 -0
  103. package/src/catalog-importer-policy.ts +58 -0
  104. package/src/catalog-inventory.ts +24 -0
  105. package/src/import-product.ts +163 -0
  106. package/src/import-runner.ts +749 -0
  107. package/src/importer-registry.ts +103 -0
  108. package/src/index.ts +175 -0
  109. package/src/mesh-bin.ts +229 -0
  110. package/src/pack-projection.ts +21 -0
  111. package/src/scriptable-pack-host.ts +544 -0
  112. package/src/scriptable-pack-output-producers.ts +456 -0
  113. package/src/scriptable-pack-staged-snapshot.ts +172 -0
  114. package/src/scriptable-pack.ts +626 -0
  115. package/src/scriptable-source-package.ts +88 -0
  116. package/src/source-package-errors.ts +201 -0
  117. package/src/source-package-publication.ts +326 -0
  118. package/src/source-package.ts +187 -0
@@ -0,0 +1,456 @@
1
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
2
+ import type { ScriptablePackSceneComponent } from '@forgeax/engine-pack/source';
3
+ import { externalizeSceneAsset } from '@forgeax/engine-scene';
4
+ import type {
5
+ AnimationGraph,
6
+ AssetGuid as AssetGuidType,
7
+ AssetRef,
8
+ AudioClipAsset,
9
+ EquirectAsset,
10
+ FontAsset,
11
+ ImportedArtifactBody,
12
+ MaterialAsset,
13
+ MaterialTextureReference,
14
+ MaterialTextureValue,
15
+ MeshAsset,
16
+ ParticleEffectAsset,
17
+ RenderPipelineAsset,
18
+ SamplerAsset,
19
+ SceneAsset,
20
+ SkeletonAsset,
21
+ SkinAsset,
22
+ TextureAsset,
23
+ TilesetAsset,
24
+ VideoAsset,
25
+ } from '@forgeax/engine-types';
26
+ import { err, ImportError, MATERIAL_TEXTURE_SLOTS, ok } from '@forgeax/engine-types';
27
+ import { sha256 } from '@noble/hashes/sha2.js';
28
+ import { bytesToHex } from '@noble/hashes/utils.js';
29
+ import { packMeshBinV4 } from './mesh-bin.js';
30
+ import {
31
+ type AssetOutputInput,
32
+ type AssetOutputProducer,
33
+ AssetOutputProducerRegistry,
34
+ type AssetOutputProduct,
35
+ } from './scriptable-pack.js';
36
+
37
+ function producerError(input: AssetOutputInput, reason: unknown): ImportError {
38
+ return new ImportError({
39
+ code: 'import-internal-error',
40
+ expected: `ScriptablePack ${input.asset.kind} output ${input.sourceKey} to satisfy its domain producer contract`,
41
+ hint: 'fix the generated Asset payload and rebuild the ScriptablePack',
42
+ detail: { reason: reason instanceof Error ? reason.message : String(reason) },
43
+ });
44
+ }
45
+
46
+ function formatGuid(value: AssetGuidType | string): string {
47
+ if (typeof value === 'string') {
48
+ const parsed = AssetGuid.parse(value);
49
+ if (!parsed.ok) throw parsed.error;
50
+ return AssetGuid.format(parsed.value);
51
+ }
52
+ return AssetGuid.format(value);
53
+ }
54
+
55
+ function canonical(value: unknown): string {
56
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
57
+ if (value !== null && typeof value === 'object') {
58
+ return `{${Object.entries(value as Record<string, unknown>)
59
+ .sort(([left], [right]) => left.localeCompare(right))
60
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
61
+ .join(',')}}`;
62
+ }
63
+ return JSON.stringify(value) ?? 'null';
64
+ }
65
+
66
+ function particleProgramArtifact(effect: ParticleEffectAsset): {
67
+ readonly program: ParticleEffectAsset['program'];
68
+ readonly bytes: Uint8Array;
69
+ readonly fingerprint: string;
70
+ } {
71
+ const artifactProgram = { format: effect.program.format, emitters: effect.program.emitters };
72
+ const bytes = new TextEncoder().encode(canonical(artifactProgram));
73
+ const fingerprint = `sha256:${bytesToHex(sha256(bytes))}`;
74
+ return {
75
+ program: { ...effect.program, fingerprint },
76
+ bytes,
77
+ fingerprint,
78
+ };
79
+ }
80
+
81
+ function materialProduct(input: AssetOutputInput): AssetOutputProduct {
82
+ if (input.asset.kind !== 'material') throw new TypeError('expected MaterialAsset');
83
+ const material = input.asset as MaterialAsset;
84
+ const refs: AssetRef[] = [];
85
+ const addRef = (
86
+ guid: AssetGuidType | string,
87
+ sourceField: NonNullable<AssetRef['sourceField']>,
88
+ ): number => {
89
+ refs.push({ guid: formatGuid(guid), sourceField });
90
+ return refs.length - 1;
91
+ };
92
+
93
+ const addMaterialRef = (
94
+ value: MaterialTextureReference,
95
+ sourceField: NonNullable<AssetRef['sourceField']>,
96
+ ): number => {
97
+ if (typeof value === 'number') {
98
+ throw new TypeError('material texture references must be GUIDs, not runtime handles');
99
+ }
100
+ return addRef(value, sourceField);
101
+ };
102
+
103
+ const textureFields =
104
+ material.parameters === undefined
105
+ ? new Set<string>(MATERIAL_TEXTURE_SLOTS)
106
+ : new Set(
107
+ material.parameters
108
+ .filter((parameter) => parameter.type === 'texture')
109
+ .map((parameter) => parameter.name),
110
+ );
111
+
112
+ const parent =
113
+ material.parent === undefined ? undefined : addRef(material.parent, { fieldName: 'parent' });
114
+ const values: Record<string, unknown> = {};
115
+ for (const fieldName of Object.keys(material.values ?? {}).sort()) {
116
+ const value = material.values?.[fieldName];
117
+ if (typeof value === 'string' && textureFields.has(fieldName)) {
118
+ values[fieldName] = addMaterialRef(value, { componentName: '<material>', fieldName });
119
+ } else if (
120
+ value !== null &&
121
+ typeof value === 'object' &&
122
+ !Array.isArray(value) &&
123
+ 'texture' in value
124
+ ) {
125
+ const texture = value as MaterialTextureValue;
126
+ values[fieldName] = {
127
+ ...texture,
128
+ texture: addMaterialRef(texture.texture, { componentName: '<material>', fieldName }),
129
+ ...(texture.sampler === undefined
130
+ ? {}
131
+ : {
132
+ sampler: addMaterialRef(texture.sampler, {
133
+ componentName: '<material>',
134
+ fieldName: `${fieldName}.sampler`,
135
+ }),
136
+ }),
137
+ };
138
+ } else {
139
+ values[fieldName] = value;
140
+ }
141
+ }
142
+
143
+ const { parent: _parent, values: _values, ...materialShape } = material;
144
+ return {
145
+ payload: {
146
+ ...materialShape,
147
+ ...(parent === undefined ? {} : { parent }),
148
+ ...(material.values === undefined ? {} : { values }),
149
+ },
150
+ refs,
151
+ artifacts: {},
152
+ };
153
+ }
154
+
155
+ function meshProduct(input: AssetOutputInput): AssetOutputProduct {
156
+ if (input.asset.kind !== 'mesh') throw new TypeError('expected MeshAsset');
157
+ const mesh = input.asset as MeshAsset;
158
+ const refs: AssetRef[] = [];
159
+ for (let slotIndex = 0; slotIndex < mesh.materialSlots.length; slotIndex++) {
160
+ const defaultMaterial = mesh.materialSlots[slotIndex]?.defaultMaterial;
161
+ if (defaultMaterial === undefined) continue;
162
+ refs.push({
163
+ guid: formatGuid(defaultMaterial),
164
+ sourceField: { fieldName: 'materialSlots', arrayIndex: slotIndex },
165
+ });
166
+ }
167
+ return {
168
+ payload: mesh,
169
+ refs,
170
+ artifacts: {
171
+ body: {
172
+ mediaType: 'application/x-forgeax-mesh',
173
+ assetCodec: { name: 'mesh-binary', version: '4' },
174
+ bytes: (() => {
175
+ const packed = packMeshBinV4(
176
+ mesh,
177
+ input.sourceKey,
178
+ refs.map((reference) => reference.guid),
179
+ );
180
+ if (!packed.ok) {
181
+ throw packed.error;
182
+ }
183
+ return packed.value;
184
+ })(),
185
+ },
186
+ },
187
+ };
188
+ }
189
+
190
+ function sceneProduct(
191
+ input: AssetOutputInput,
192
+ components: ReadonlyMap<string, Readonly<Record<string, string>>>,
193
+ ): AssetOutputProduct {
194
+ if (input.asset.kind !== 'scene') throw new TypeError('expected SceneAsset');
195
+ const externalized = externalizeSceneAsset(input.asset as SceneAsset, (componentName) => {
196
+ const schema = components.get(componentName);
197
+ if (schema === undefined) {
198
+ throw new TypeError(
199
+ `ScriptablePack scene component ${componentName} is missing from sceneComponents`,
200
+ );
201
+ }
202
+ return schema;
203
+ });
204
+ if (!externalized.ok) throw new TypeError(`scene field ${externalized.error.field} is invalid`);
205
+ return {
206
+ payload: { kind: 'scene', ...externalized.value.payload },
207
+ refs: externalized.value.refs,
208
+ artifacts: {},
209
+ };
210
+ }
211
+
212
+ function createSafeProducer(
213
+ kind: AssetOutputProducer['kind'],
214
+ version: string,
215
+ product: (input: AssetOutputInput) => AssetOutputProduct,
216
+ ): AssetOutputProducer {
217
+ return {
218
+ kind,
219
+ version,
220
+ produce(input) {
221
+ try {
222
+ return ok(product(input));
223
+ } catch (error) {
224
+ return err(producerError(input, error));
225
+ }
226
+ },
227
+ };
228
+ }
229
+
230
+ function jsonArtifact(value: unknown): ImportedArtifactBody {
231
+ return {
232
+ mediaType: 'application/json',
233
+ assetCodec: { name: 'forgeax-json', version: '1' },
234
+ bytes: new TextEncoder().encode(JSON.stringify(value)),
235
+ };
236
+ }
237
+
238
+ function ref(guid: AssetGuidType, fieldName: string, arrayIndex?: number): AssetRef {
239
+ return {
240
+ guid: formatGuid(guid),
241
+ sourceField: { fieldName, ...(arrayIndex === undefined ? {} : { arrayIndex }) },
242
+ };
243
+ }
244
+
245
+ function bytes(value: Uint8Array | Uint8ClampedArray): Uint8Array {
246
+ return Uint8Array.from(value);
247
+ }
248
+
249
+ function ordinaryPodProduct(input: AssetOutputInput): AssetOutputProduct {
250
+ const asset = input.asset;
251
+ switch (asset.kind) {
252
+ case 'texture': {
253
+ const texture = asset as TextureAsset;
254
+ return {
255
+ payload: texture,
256
+ refs: [],
257
+ artifacts: {
258
+ body: {
259
+ mediaType: 'image/raw',
260
+ assetCodec: { name: 'raw-image', version: '1' },
261
+ bytes: bytes(texture.data),
262
+ },
263
+ },
264
+ };
265
+ }
266
+ case 'equirect': {
267
+ const equirect = asset as EquirectAsset;
268
+ return {
269
+ payload: equirect,
270
+ refs: [],
271
+ artifacts: {
272
+ body: {
273
+ mediaType: 'image/raw',
274
+ assetCodec: { name: 'raw-image', version: '1' },
275
+ bytes: bytes(equirect.data),
276
+ },
277
+ },
278
+ };
279
+ }
280
+ case 'sampler': {
281
+ const sampler = asset as SamplerAsset;
282
+ return { payload: sampler, refs: [], artifacts: { body: jsonArtifact(sampler) } };
283
+ }
284
+ case 'font': {
285
+ const font = asset as FontAsset;
286
+ const atlas = ref(font.atlas, 'atlas');
287
+ const sampler = ref(font.sampler, 'sampler');
288
+ return {
289
+ payload: {
290
+ kind: font.kind,
291
+ glyphs: font.glyphs,
292
+ common: font.common,
293
+ atlasGuid: atlas.guid,
294
+ samplerGuid: sampler.guid,
295
+ },
296
+ refs: [atlas, sampler],
297
+ artifacts: { body: jsonArtifact(font) },
298
+ };
299
+ }
300
+ case 'render-pipeline': {
301
+ const pipeline = asset as RenderPipelineAsset;
302
+ return {
303
+ payload: pipeline,
304
+ refs: [],
305
+ artifacts: { body: jsonArtifact(pipeline) },
306
+ };
307
+ }
308
+ case 'tileset': {
309
+ const tileset = asset as TilesetAsset;
310
+ const refs = tileset.atlases.map((atlas, index) => {
311
+ const parsed = AssetGuid.parse(atlas);
312
+ if (!parsed.ok) throw parsed.error;
313
+ return ref(parsed.value, 'atlases', index);
314
+ });
315
+ return {
316
+ payload: { ...tileset, atlases: refs.map((_entry, index) => index) },
317
+ refs,
318
+ artifacts: { body: jsonArtifact(tileset) },
319
+ };
320
+ }
321
+ case 'video': {
322
+ const video = asset as VideoAsset;
323
+ try {
324
+ const url = new URL(video.url);
325
+ if (url.protocol !== 'http:' && url.protocol !== 'https:')
326
+ throw new Error('unsupported URL scheme');
327
+ } catch (error) {
328
+ throw new TypeError(
329
+ `video URL is invalid: ${error instanceof Error ? error.message : String(error)}`,
330
+ );
331
+ }
332
+ return { payload: video, refs: [], artifacts: {} };
333
+ }
334
+ case 'skeleton': {
335
+ const skeleton = asset as SkeletonAsset;
336
+ return {
337
+ payload: skeleton,
338
+ refs: [],
339
+ artifacts: { body: jsonArtifact(skeleton) },
340
+ };
341
+ }
342
+ case 'skin': {
343
+ const skin = asset as SkinAsset;
344
+ const skeletonGuid = AssetGuid.parse(skin.skeletonGuid);
345
+ if (!skeletonGuid.ok) throw skeletonGuid.error;
346
+ return {
347
+ payload: skin,
348
+ refs: [ref(skeletonGuid.value, 'skeletonGuid')],
349
+ artifacts: { body: jsonArtifact(skin) },
350
+ };
351
+ }
352
+ case 'animation-clip':
353
+ return {
354
+ payload: asset,
355
+ refs: [],
356
+ artifacts: { body: jsonArtifact(asset) },
357
+ };
358
+ case 'animation-graph': {
359
+ const graph = asset as AnimationGraph;
360
+ const refs: AssetRef[] = [];
361
+ const nodes = graph.nodes.map((node, index) => {
362
+ if (node.type !== 'clip') return node;
363
+ const parsed = AssetGuid.parse(node.clip);
364
+ if (!parsed.ok) throw parsed.error;
365
+ const referenceIndex = refs.push(ref(parsed.value, 'nodes', index)) - 1;
366
+ return { ...node, clip: referenceIndex };
367
+ });
368
+ return {
369
+ payload: { kind: graph.kind, root: graph.root, nodes },
370
+ refs,
371
+ artifacts: { body: jsonArtifact(graph) },
372
+ };
373
+ }
374
+ case 'audio': {
375
+ const audio = asset as AudioClipAsset;
376
+ return {
377
+ payload: {
378
+ kind: audio.kind,
379
+ sourceKey: audio.sourceKey,
380
+ mediaType: audio.mediaType,
381
+ bytes: audio.bytes.slice(),
382
+ },
383
+ refs: [],
384
+ artifacts: {
385
+ source: {
386
+ mediaType: audio.mediaType,
387
+ assetCodec: { name: 'browser-audio', version: '1' },
388
+ bytes: audio.bytes.slice(),
389
+ },
390
+ },
391
+ };
392
+ }
393
+ case 'particle-effect': {
394
+ const effect = asset as ParticleEffectAsset;
395
+ const cooked = particleProgramArtifact(effect);
396
+ return {
397
+ payload: { ...effect, programFingerprint: cooked.fingerprint, program: cooked.program },
398
+ refs: [],
399
+ artifacts: {
400
+ 'particle-effect/program.json': {
401
+ mediaType: 'application/json',
402
+ assetCodec: { name: 'forgeax-vfx-program', version: effect.program.format },
403
+ bytes: cooked.bytes,
404
+ },
405
+ },
406
+ };
407
+ }
408
+ case 'material':
409
+ case 'mesh':
410
+ case 'scene':
411
+ throw new TypeError(`ordinary producer received already-owned ${asset.kind} asset`);
412
+ }
413
+ }
414
+
415
+ export const materialAssetOutputProducer = createSafeProducer(
416
+ 'material',
417
+ 'material-pack/1',
418
+ materialProduct,
419
+ );
420
+ export const meshAssetOutputProducer = createSafeProducer('mesh', 'mesh-binary/4', meshProduct);
421
+
422
+ export function createSceneAssetOutputProducer(
423
+ sceneComponents: readonly ScriptablePackSceneComponent[] = [],
424
+ ): AssetOutputProducer {
425
+ const schemas = new Map(
426
+ sceneComponents.map((component) => [component.name, component.fields] as const),
427
+ );
428
+ return createSafeProducer('scene', 'scene-pack/3', (input) => sceneProduct(input, schemas));
429
+ }
430
+
431
+ export function createStandardAssetOutputProducerRegistry(
432
+ sceneComponents: readonly ScriptablePackSceneComponent[] = [],
433
+ ): AssetOutputProducerRegistry {
434
+ const registry = new AssetOutputProducerRegistry();
435
+ registry.register(materialAssetOutputProducer);
436
+ registry.register(meshAssetOutputProducer);
437
+ registry.register(createSceneAssetOutputProducer(sceneComponents));
438
+ for (const kind of [
439
+ 'texture',
440
+ 'equirect',
441
+ 'sampler',
442
+ 'font',
443
+ 'render-pipeline',
444
+ 'tileset',
445
+ 'video',
446
+ 'skeleton',
447
+ 'skin',
448
+ 'animation-clip',
449
+ 'animation-graph',
450
+ 'audio',
451
+ 'particle-effect',
452
+ ] as const) {
453
+ registry.register(createSafeProducer(kind, 'ordinary-pod/1', ordinaryPodProduct));
454
+ }
455
+ return registry;
456
+ }
@@ -0,0 +1,172 @@
1
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
2
+ import type { ScriptablePackError } from '@forgeax/engine-pack/source';
3
+ import type { Asset, AssetGuid as AssetGuidType, ImportError, Result } from '@forgeax/engine-types';
4
+ import { AssetError, err, ok } from '@forgeax/engine-types';
5
+ import type {
6
+ ScriptablePackAssetSnapshot,
7
+ ScriptablePackAssetSnapshotSource,
8
+ ScriptablePackDomainError,
9
+ ScriptablePackStagedOutput,
10
+ } from './scriptable-pack.js';
11
+
12
+ export type ScriptablePackSnapshotError =
13
+ | AssetError
14
+ | ImportError
15
+ | ScriptablePackError
16
+ | ScriptablePackDomainError;
17
+
18
+ export interface ScriptablePackStagedOwner {
19
+ readonly id: string;
20
+ readonly guids: readonly AssetGuidType[];
21
+ build(
22
+ source: ScriptablePackAssetSnapshotSource,
23
+ ): Promise<Result<readonly ScriptablePackStagedOutput[], ScriptablePackSnapshotError>>;
24
+ }
25
+
26
+ export interface ScriptablePackStagedSnapshotOptions {
27
+ readonly generation: number;
28
+ readonly owners: readonly ScriptablePackStagedOwner[];
29
+ readonly declaredExternalOutputs?: readonly ScriptablePackStagedOutput[];
30
+ }
31
+
32
+ function stable(value: unknown): string {
33
+ if (ArrayBuffer.isView(value)) {
34
+ return `${value.constructor.name}:${JSON.stringify(Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)))}`;
35
+ }
36
+ if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
37
+ if (value !== null && typeof value === 'object') {
38
+ const record = value as Record<string, unknown>;
39
+ return `{${Object.keys(record)
40
+ .sort()
41
+ .map((key) => `${JSON.stringify(key)}:${stable(record[key])}`)
42
+ .join(',')}}`;
43
+ }
44
+ return JSON.stringify(value) ?? 'null';
45
+ }
46
+
47
+ async function assetDigest(asset: Asset): Promise<string> {
48
+ const bytes = new TextEncoder().encode(stable(asset));
49
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
50
+ return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
51
+ }
52
+
53
+ function cycleError(stack: readonly string[], owner: string, guid: string): ScriptablePackError {
54
+ const cycleStart = stack.indexOf(owner);
55
+ const cycle = [...stack.slice(cycleStart), owner];
56
+ return {
57
+ code: 'pack-source-build-cycle',
58
+ expected: 'an acyclic graph of ScriptablePack content dependencies',
59
+ actual: cycle.join(' -> '),
60
+ hint: 'break one content read edge or convert it to a reference-only dependency',
61
+ retryable: false,
62
+ recoveryActions: ['inspect-content-dependency-graph', 'edit-source'],
63
+ detail: { sourcePath: owner, sourceKey: guid, incomingRefs: cycle },
64
+ };
65
+ }
66
+
67
+ function missingOutputError(owner: string, guid: string): ScriptablePackError {
68
+ return {
69
+ code: 'pack-source-output-invalid',
70
+ expected: `owner ${owner} to stage every declared output including ${guid}`,
71
+ hint: 'return one output for every GUID declared by the staged owner',
72
+ detail: { missingGuids: [guid], unexpectedSourceKeys: [], kindMismatches: [] },
73
+ };
74
+ }
75
+
76
+ /**
77
+ * One-build authority for current-generation ScriptablePack content reads.
78
+ * Local owners are built lazily, memoized, and never resolved through an old Catalog/DDC generation.
79
+ */
80
+ export function createScriptablePackStagedAssetSnapshotSource(
81
+ options: ScriptablePackStagedSnapshotOptions,
82
+ ): ScriptablePackAssetSnapshotSource {
83
+ const owners =
84
+ options.declaredExternalOutputs === undefined || options.declaredExternalOutputs.length === 0
85
+ ? options.owners
86
+ : [
87
+ {
88
+ id: '<declared-pack-external>',
89
+ guids: options.declaredExternalOutputs.map((output) => output.guid),
90
+ async build() {
91
+ return ok(options.declaredExternalOutputs ?? []);
92
+ },
93
+ } satisfies ScriptablePackStagedOwner,
94
+ ...options.owners,
95
+ ];
96
+ const ownerByGuid = new Map<string, ScriptablePackStagedOwner>();
97
+ for (const owner of owners) {
98
+ if (owner.id.trim().length === 0) throw new TypeError('staged owner id must be non-empty');
99
+ for (const guid of owner.guids) {
100
+ const key = AssetGuid.format(guid).toLowerCase();
101
+ const existing = ownerByGuid.get(key);
102
+ if (existing !== undefined) {
103
+ throw new TypeError(`staged GUID ${key} is owned by both ${existing.id} and ${owner.id}`);
104
+ }
105
+ ownerByGuid.set(key, owner);
106
+ }
107
+ }
108
+
109
+ const snapshots = new Map<string, ScriptablePackAssetSnapshot>();
110
+ const builds = new Map<string, Promise<Result<void, ScriptablePackSnapshotError>>>();
111
+
112
+ const sourceFor = (stack: readonly string[]): ScriptablePackAssetSnapshotSource => ({
113
+ async readByGuid(guid) {
114
+ const key = AssetGuid.format(guid).toLowerCase();
115
+ const cached = snapshots.get(key);
116
+ if (cached !== undefined) return ok(structuredClone(cached));
117
+ const owner = ownerByGuid.get(key);
118
+ if (owner === undefined) {
119
+ return err(
120
+ new AssetError({
121
+ code: 'asset-not-imported',
122
+ expected: 'a staged local owner for the requested GUID',
123
+ hint: 'declare the local ScriptablePack output before rebuilding the generation',
124
+ }),
125
+ );
126
+ }
127
+ if (stack.includes(owner.id)) return err(cycleError(stack, owner.id, key));
128
+
129
+ let building = builds.get(owner.id);
130
+ if (building === undefined) {
131
+ building = (async () => {
132
+ const built = await owner.build(sourceFor([...stack, owner.id]));
133
+ if (!built.ok) {
134
+ builds.delete(owner.id);
135
+ return built;
136
+ }
137
+ const next = new Map<string, ScriptablePackAssetSnapshot>();
138
+ for (const output of built.value) {
139
+ const outputGuid = AssetGuid.format(output.guid).toLowerCase();
140
+ if (ownerByGuid.get(outputGuid) !== owner) {
141
+ builds.delete(owner.id);
142
+ return err(missingOutputError(owner.id, outputGuid));
143
+ }
144
+ next.set(outputGuid, {
145
+ asset: structuredClone(output.asset),
146
+ generation: options.generation,
147
+ digest: output.digest ?? (await assetDigest(output.asset)),
148
+ });
149
+ }
150
+ for (const declared of owner.guids) {
151
+ const declaredGuid = AssetGuid.format(declared).toLowerCase();
152
+ if (!next.has(declaredGuid)) {
153
+ builds.delete(owner.id);
154
+ return err(missingOutputError(owner.id, declaredGuid));
155
+ }
156
+ }
157
+ for (const [outputGuid, snapshot] of next) snapshots.set(outputGuid, snapshot);
158
+ return ok(undefined);
159
+ })();
160
+ builds.set(owner.id, building);
161
+ }
162
+ const built = await building;
163
+ if (!built.ok) return built;
164
+ const staged = snapshots.get(key);
165
+ return staged === undefined
166
+ ? err(missingOutputError(owner.id, key))
167
+ : ok(structuredClone(staged));
168
+ },
169
+ });
170
+
171
+ return sourceFor([]);
172
+ }