@forgeax/engine-import 0.1.2
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/LICENSE +202 -0
- package/README.md +226 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts +2 -0
- package/dist/__tests__/decode-image-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/import-contract-migration.test.d.ts +2 -0
- package/dist/__tests__/import-contract-migration.test.d.ts.map +1 -0
- package/dist/__tests__/import-dependencies.test.d.ts +2 -0
- package/dist/__tests__/import-dependencies.test.d.ts.map +1 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts +2 -0
- package/dist/__tests__/import-finalizer-digest-retry.test.d.ts.map +1 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/import-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/import.unit.test.d.ts +2 -0
- package/dist/__tests__/import.unit.test.d.ts.map +1 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts +2 -0
- package/dist/__tests__/material-import-product.unit.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-contract.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin-morph-v3.test.d.ts.map +1 -0
- package/dist/__tests__/mesh-bin.test.d.ts +2 -0
- package/dist/__tests__/mesh-bin.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-output-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-production-producers.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack-staged-snapshot.unit.test.d.ts.map +1 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts +2 -0
- package/dist/__tests__/scriptable-pack.integration.test.d.ts.map +1 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts +2 -0
- package/dist/__tests__/source-overrides-import-context.unit.test.d.ts.map +1 -0
- package/dist/import-product.d.ts +36 -0
- package/dist/import-product.d.ts.map +1 -0
- package/dist/import-runner.d.ts +133 -0
- package/dist/import-runner.d.ts.map +1 -0
- package/dist/importer-registry.d.ts +40 -0
- package/dist/importer-registry.d.ts.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +1607 -0
- package/dist/index.mjs.map +1 -0
- package/dist/mesh-bin.d.ts +19 -0
- package/dist/mesh-bin.d.ts.map +1 -0
- package/dist/scriptable-pack-output-producers.d.ts +6 -0
- package/dist/scriptable-pack-output-producers.d.ts.map +1 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts +22 -0
- package/dist/scriptable-pack-staged-snapshot.d.ts.map +1 -0
- package/dist/scriptable-pack.d.ts +153 -0
- package/dist/scriptable-pack.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/__tests__/decode-image-owner.test-d.ts +53 -0
- package/src/__tests__/import-contract-migration.test.ts +190 -0
- package/src/__tests__/import-dependencies.test.ts +65 -0
- package/src/__tests__/import-finalizer-digest-retry.test.ts +113 -0
- package/src/__tests__/import-local-artifacts.test.ts +100 -0
- package/src/__tests__/import.unit.test.ts +429 -0
- package/src/__tests__/material-import-product.unit.test.ts +56 -0
- package/src/__tests__/mesh-bin-contract.test.ts +95 -0
- package/src/__tests__/mesh-bin-morph-v3.test.ts +44 -0
- package/src/__tests__/mesh-bin.test.ts +102 -0
- package/src/__tests__/scriptable-pack-output-producers.unit.test.ts +190 -0
- package/src/__tests__/scriptable-pack-production-producers.unit.test.ts +429 -0
- package/src/__tests__/scriptable-pack-staged-snapshot.unit.test.ts +258 -0
- package/src/__tests__/scriptable-pack.integration.test.ts +435 -0
- package/src/__tests__/source-overrides-import-context.unit.test.ts +92 -0
- package/src/import-product.ts +195 -0
- package/src/import-runner.ts +720 -0
- package/src/importer-registry.ts +80 -0
- package/src/index.ts +103 -0
- package/src/mesh-bin.ts +229 -0
- package/src/scriptable-pack-output-producers.ts +466 -0
- package/src/scriptable-pack-staged-snapshot.ts +161 -0
- package/src/scriptable-pack.ts +615 -0
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { componentSchema } from '@forgeax/engine-ecs/internal';
|
|
2
|
+
import { AssetGuid } from '@forgeax/engine-pack/guid';
|
|
3
|
+
import { externalizeSceneAsset } from '@forgeax/engine-scene';
|
|
4
|
+
import type {
|
|
5
|
+
AnimationGraph,
|
|
6
|
+
Asset,
|
|
7
|
+
AssetGuid as AssetGuidType,
|
|
8
|
+
AssetRef,
|
|
9
|
+
AudioClipAsset,
|
|
10
|
+
EquirectAsset,
|
|
11
|
+
FontAsset,
|
|
12
|
+
ImportedArtifactBody,
|
|
13
|
+
MaterialAsset,
|
|
14
|
+
MaterialTextureReference,
|
|
15
|
+
MaterialTextureValue,
|
|
16
|
+
MeshAsset,
|
|
17
|
+
ParticleEffectAsset,
|
|
18
|
+
RenderPipelineAsset,
|
|
19
|
+
Result,
|
|
20
|
+
SamplerAsset,
|
|
21
|
+
SceneAsset,
|
|
22
|
+
SkeletonAsset,
|
|
23
|
+
SkinAsset,
|
|
24
|
+
TextureAsset,
|
|
25
|
+
TilesetAsset,
|
|
26
|
+
VideoAsset,
|
|
27
|
+
} from '@forgeax/engine-types';
|
|
28
|
+
import { err, ImportError, MATERIAL_TEXTURE_SLOTS, ok } from '@forgeax/engine-types';
|
|
29
|
+
import { sha256 } from '@noble/hashes/sha2.js';
|
|
30
|
+
import { bytesToHex } from '@noble/hashes/utils.js';
|
|
31
|
+
import { packMeshBinV4 } from './mesh-bin.js';
|
|
32
|
+
import {
|
|
33
|
+
type AssetOutputInput,
|
|
34
|
+
type AssetOutputProducer,
|
|
35
|
+
AssetOutputProducerRegistry,
|
|
36
|
+
type AssetOutputProduct,
|
|
37
|
+
} from './scriptable-pack.js';
|
|
38
|
+
|
|
39
|
+
function producerError(input: AssetOutputInput, reason: unknown): ImportError {
|
|
40
|
+
return new ImportError({
|
|
41
|
+
code: 'import-internal-error',
|
|
42
|
+
expected: `ScriptablePack ${input.asset.kind} output ${input.sourceKey} to satisfy its domain producer contract`,
|
|
43
|
+
hint: 'fix the generated Asset payload and rebuild the ScriptablePack',
|
|
44
|
+
detail: { reason: reason instanceof Error ? reason.message : String(reason) },
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function formatGuid(value: AssetGuidType | string): string {
|
|
49
|
+
if (typeof value === 'string') {
|
|
50
|
+
const parsed = AssetGuid.parse(value);
|
|
51
|
+
if (!parsed.ok) throw parsed.error;
|
|
52
|
+
return AssetGuid.format(parsed.value);
|
|
53
|
+
}
|
|
54
|
+
return AssetGuid.format(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function canonical(value: unknown): string {
|
|
58
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
|
|
59
|
+
if (value !== null && typeof value === 'object') {
|
|
60
|
+
return `{${Object.entries(value as Record<string, unknown>)
|
|
61
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
62
|
+
.map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`)
|
|
63
|
+
.join(',')}}`;
|
|
64
|
+
}
|
|
65
|
+
return JSON.stringify(value) ?? 'null';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function particleProgramArtifact(effect: ParticleEffectAsset): {
|
|
69
|
+
readonly program: ParticleEffectAsset['program'];
|
|
70
|
+
readonly bytes: Uint8Array;
|
|
71
|
+
readonly fingerprint: string;
|
|
72
|
+
} {
|
|
73
|
+
const artifactProgram = { format: effect.program.format, emitters: effect.program.emitters };
|
|
74
|
+
const bytes = new TextEncoder().encode(canonical(artifactProgram));
|
|
75
|
+
const fingerprint = `sha256:${bytesToHex(sha256(bytes))}`;
|
|
76
|
+
return {
|
|
77
|
+
program: { ...effect.program, fingerprint },
|
|
78
|
+
bytes,
|
|
79
|
+
fingerprint,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function materialProduct(input: AssetOutputInput): AssetOutputProduct {
|
|
84
|
+
if (input.asset.kind !== 'material') throw new TypeError('expected MaterialAsset');
|
|
85
|
+
const material = input.asset as MaterialAsset;
|
|
86
|
+
const refs: AssetRef[] = [];
|
|
87
|
+
const addRef = (
|
|
88
|
+
guid: AssetGuidType | string,
|
|
89
|
+
sourceField: NonNullable<AssetRef['sourceField']>,
|
|
90
|
+
): number => {
|
|
91
|
+
refs.push({ guid: formatGuid(guid), sourceField });
|
|
92
|
+
return refs.length - 1;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const addMaterialRef = (
|
|
96
|
+
value: MaterialTextureReference,
|
|
97
|
+
sourceField: NonNullable<AssetRef['sourceField']>,
|
|
98
|
+
): number => {
|
|
99
|
+
if (typeof value === 'number') {
|
|
100
|
+
throw new TypeError('material texture references must be GUIDs, not runtime handles');
|
|
101
|
+
}
|
|
102
|
+
return addRef(value, sourceField);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const textureFields =
|
|
106
|
+
material.parameters === undefined
|
|
107
|
+
? new Set<string>(MATERIAL_TEXTURE_SLOTS)
|
|
108
|
+
: new Set(
|
|
109
|
+
material.parameters
|
|
110
|
+
.filter((parameter) => parameter.type === 'texture')
|
|
111
|
+
.map((parameter) => parameter.name),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const parent =
|
|
115
|
+
material.parent === undefined ? undefined : addRef(material.parent, { fieldName: 'parent' });
|
|
116
|
+
const values: Record<string, unknown> = {};
|
|
117
|
+
for (const fieldName of Object.keys(material.values ?? {}).sort()) {
|
|
118
|
+
const value = material.values?.[fieldName];
|
|
119
|
+
if (typeof value === 'string' && textureFields.has(fieldName)) {
|
|
120
|
+
values[fieldName] = addMaterialRef(value, { componentName: '<material>', fieldName });
|
|
121
|
+
} else if (
|
|
122
|
+
value !== null &&
|
|
123
|
+
typeof value === 'object' &&
|
|
124
|
+
!Array.isArray(value) &&
|
|
125
|
+
'texture' in value
|
|
126
|
+
) {
|
|
127
|
+
const texture = value as MaterialTextureValue;
|
|
128
|
+
values[fieldName] = {
|
|
129
|
+
...texture,
|
|
130
|
+
texture: addMaterialRef(texture.texture, { componentName: '<material>', fieldName }),
|
|
131
|
+
...(texture.sampler === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: {
|
|
134
|
+
sampler: addMaterialRef(texture.sampler, {
|
|
135
|
+
componentName: '<material>',
|
|
136
|
+
fieldName: `${fieldName}.sampler`,
|
|
137
|
+
}),
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
} else {
|
|
141
|
+
values[fieldName] = value;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const { parent: _parent, values: _values, ...materialShape } = material;
|
|
146
|
+
return {
|
|
147
|
+
payload: {
|
|
148
|
+
...materialShape,
|
|
149
|
+
...(parent === undefined ? {} : { parent }),
|
|
150
|
+
...(material.values === undefined ? {} : { values }),
|
|
151
|
+
},
|
|
152
|
+
refs,
|
|
153
|
+
artifacts: {},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const materialAssetOutputProducer: AssetOutputProducer = {
|
|
158
|
+
kind: 'material',
|
|
159
|
+
version: 'material-pack/1',
|
|
160
|
+
produce(input): Result<AssetOutputProduct, ImportError> {
|
|
161
|
+
try {
|
|
162
|
+
return ok(materialProduct(input));
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return err(producerError(input, error));
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function meshProduct(input: AssetOutputInput): AssetOutputProduct {
|
|
170
|
+
if (input.asset.kind !== 'mesh') throw new TypeError('expected MeshAsset');
|
|
171
|
+
const mesh = input.asset as MeshAsset;
|
|
172
|
+
const refs: AssetRef[] = [];
|
|
173
|
+
for (let slotIndex = 0; slotIndex < mesh.materialSlots.length; slotIndex++) {
|
|
174
|
+
const defaultMaterial = mesh.materialSlots[slotIndex]?.defaultMaterial;
|
|
175
|
+
if (defaultMaterial === undefined) continue;
|
|
176
|
+
refs.push({
|
|
177
|
+
guid: formatGuid(defaultMaterial),
|
|
178
|
+
sourceField: { fieldName: 'materialSlots', arrayIndex: slotIndex },
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
payload: mesh,
|
|
183
|
+
refs,
|
|
184
|
+
artifacts: {
|
|
185
|
+
body: {
|
|
186
|
+
mediaType: 'application/x-forgeax-mesh',
|
|
187
|
+
assetCodec: { name: 'mesh-binary', version: '4' },
|
|
188
|
+
bytes: (() => {
|
|
189
|
+
const packed = packMeshBinV4(
|
|
190
|
+
mesh,
|
|
191
|
+
input.sourceKey,
|
|
192
|
+
refs.map((reference) => reference.guid),
|
|
193
|
+
);
|
|
194
|
+
if (!packed.ok) {
|
|
195
|
+
throw packed.error;
|
|
196
|
+
}
|
|
197
|
+
return packed.value;
|
|
198
|
+
})(),
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export const meshAssetOutputProducer: AssetOutputProducer = {
|
|
205
|
+
kind: 'mesh',
|
|
206
|
+
version: 'mesh-binary/4',
|
|
207
|
+
produce(input): Result<AssetOutputProduct, ImportError> {
|
|
208
|
+
try {
|
|
209
|
+
return ok(meshProduct(input));
|
|
210
|
+
} catch (error) {
|
|
211
|
+
return err(producerError(input, error));
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function sceneProduct(input: AssetOutputInput): AssetOutputProduct {
|
|
217
|
+
if (input.asset.kind !== 'scene') throw new TypeError('expected SceneAsset');
|
|
218
|
+
const externalized = externalizeSceneAsset(input.asset as SceneAsset, (componentName) => {
|
|
219
|
+
const component = input.components?.get(componentName);
|
|
220
|
+
return component === undefined ? undefined : componentSchema(component);
|
|
221
|
+
});
|
|
222
|
+
if (!externalized.ok) throw new TypeError(`scene field ${externalized.error.field} is invalid`);
|
|
223
|
+
return {
|
|
224
|
+
payload: { kind: 'scene', ...externalized.value.payload },
|
|
225
|
+
refs: externalized.value.refs,
|
|
226
|
+
artifacts: {},
|
|
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
|
+
const ordinaryAssetOutputProducer: AssetOutputProducer = {
|
|
416
|
+
kind: 'texture',
|
|
417
|
+
version: 'ordinary-pod/1',
|
|
418
|
+
produce(input) {
|
|
419
|
+
try {
|
|
420
|
+
return ok(ordinaryPodProduct(input));
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return err(producerError(input, error));
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
function createOrdinaryAssetOutputProducer(kind: Asset['kind']): AssetOutputProducer {
|
|
428
|
+
return { ...ordinaryAssetOutputProducer, kind };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
export const sceneAssetOutputProducer: AssetOutputProducer = {
|
|
432
|
+
kind: 'scene',
|
|
433
|
+
version: 'scene-pack/2',
|
|
434
|
+
produce(input): Result<AssetOutputProduct, ImportError> {
|
|
435
|
+
try {
|
|
436
|
+
return ok(sceneProduct(input));
|
|
437
|
+
} catch (error) {
|
|
438
|
+
return err(producerError(input, error));
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
export function createStandardAssetOutputProducerRegistry(): AssetOutputProducerRegistry {
|
|
444
|
+
const registry = new AssetOutputProducerRegistry();
|
|
445
|
+
registry.register(materialAssetOutputProducer);
|
|
446
|
+
registry.register(meshAssetOutputProducer);
|
|
447
|
+
registry.register(sceneAssetOutputProducer);
|
|
448
|
+
for (const kind of [
|
|
449
|
+
'texture',
|
|
450
|
+
'equirect',
|
|
451
|
+
'sampler',
|
|
452
|
+
'font',
|
|
453
|
+
'render-pipeline',
|
|
454
|
+
'tileset',
|
|
455
|
+
'video',
|
|
456
|
+
'skeleton',
|
|
457
|
+
'skin',
|
|
458
|
+
'animation-clip',
|
|
459
|
+
'animation-graph',
|
|
460
|
+
'audio',
|
|
461
|
+
'particle-effect',
|
|
462
|
+
] as const) {
|
|
463
|
+
registry.register(createOrdinaryAssetOutputProducer(kind));
|
|
464
|
+
}
|
|
465
|
+
return registry;
|
|
466
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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
|
+
/** A prebuilt authority only. Local source ownership always wins. */
|
|
30
|
+
readonly fallback?: ScriptablePackAssetSnapshotSource;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stable(value: unknown): string {
|
|
34
|
+
if (ArrayBuffer.isView(value)) {
|
|
35
|
+
return `${value.constructor.name}:${JSON.stringify(Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength)))}`;
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(value)) return `[${value.map(stable).join(',')}]`;
|
|
38
|
+
if (value !== null && typeof value === 'object') {
|
|
39
|
+
const record = value as Record<string, unknown>;
|
|
40
|
+
return `{${Object.keys(record)
|
|
41
|
+
.sort()
|
|
42
|
+
.map((key) => `${JSON.stringify(key)}:${stable(record[key])}`)
|
|
43
|
+
.join(',')}}`;
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify(value) ?? 'null';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function assetDigest(asset: Asset): Promise<string> {
|
|
49
|
+
const bytes = new TextEncoder().encode(stable(asset));
|
|
50
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
|
|
51
|
+
return `sha256:${Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cycleError(stack: readonly string[], owner: string, guid: string): ScriptablePackError {
|
|
55
|
+
const cycleStart = stack.indexOf(owner);
|
|
56
|
+
const cycle = [...stack.slice(cycleStart), owner];
|
|
57
|
+
return {
|
|
58
|
+
code: 'pack-source-build-cycle',
|
|
59
|
+
expected: 'an acyclic graph of ScriptablePack content dependencies',
|
|
60
|
+
actual: cycle.join(' -> '),
|
|
61
|
+
hint: 'break one content read edge or convert it to a reference-only dependency',
|
|
62
|
+
retryable: false,
|
|
63
|
+
recoveryActions: ['inspect-content-dependency-graph', 'edit-source'],
|
|
64
|
+
detail: { sourcePath: owner, sourceKey: guid, incomingRefs: cycle },
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function missingOutputError(owner: string, guid: string): ScriptablePackError {
|
|
69
|
+
return {
|
|
70
|
+
code: 'pack-source-output-invalid',
|
|
71
|
+
expected: `owner ${owner} to stage every declared output including ${guid}`,
|
|
72
|
+
hint: 'return one output for every GUID declared by the staged owner',
|
|
73
|
+
detail: { missingGuids: [guid], unexpectedSourceKeys: [], kindMismatches: [] },
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* One-build authority for current-generation ScriptablePack content reads.
|
|
79
|
+
* Local owners are built lazily, memoized, and never resolved through an old Catalog/DDC generation.
|
|
80
|
+
*/
|
|
81
|
+
export function createScriptablePackStagedAssetSnapshotSource(
|
|
82
|
+
options: ScriptablePackStagedSnapshotOptions,
|
|
83
|
+
): ScriptablePackAssetSnapshotSource {
|
|
84
|
+
const ownerByGuid = new Map<string, ScriptablePackStagedOwner>();
|
|
85
|
+
for (const owner of options.owners) {
|
|
86
|
+
if (owner.id.trim().length === 0) throw new TypeError('staged owner id must be non-empty');
|
|
87
|
+
for (const guid of owner.guids) {
|
|
88
|
+
const key = AssetGuid.format(guid).toLowerCase();
|
|
89
|
+
const existing = ownerByGuid.get(key);
|
|
90
|
+
if (existing !== undefined) {
|
|
91
|
+
throw new TypeError(`staged GUID ${key} is owned by both ${existing.id} and ${owner.id}`);
|
|
92
|
+
}
|
|
93
|
+
ownerByGuid.set(key, owner);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const snapshots = new Map<string, ScriptablePackAssetSnapshot>();
|
|
98
|
+
const builds = new Map<string, Promise<Result<void, ScriptablePackSnapshotError>>>();
|
|
99
|
+
|
|
100
|
+
const sourceFor = (stack: readonly string[]): ScriptablePackAssetSnapshotSource => ({
|
|
101
|
+
async readByGuid(guid) {
|
|
102
|
+
const key = AssetGuid.format(guid).toLowerCase();
|
|
103
|
+
const cached = snapshots.get(key);
|
|
104
|
+
if (cached !== undefined) return ok(structuredClone(cached));
|
|
105
|
+
const owner = ownerByGuid.get(key);
|
|
106
|
+
if (owner === undefined) {
|
|
107
|
+
if (options.fallback !== undefined) return options.fallback.readByGuid(guid);
|
|
108
|
+
return err(
|
|
109
|
+
new AssetError({
|
|
110
|
+
code: 'asset-not-imported',
|
|
111
|
+
expected: 'a staged local owner or explicit prebuilt fallback for the requested GUID',
|
|
112
|
+
hint: 'declare the local ScriptablePack output or configure a prebuilt asset authority',
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (stack.includes(owner.id)) return err(cycleError(stack, owner.id, key));
|
|
117
|
+
|
|
118
|
+
let building = builds.get(owner.id);
|
|
119
|
+
if (building === undefined) {
|
|
120
|
+
building = (async () => {
|
|
121
|
+
const built = await owner.build(sourceFor([...stack, owner.id]));
|
|
122
|
+
if (!built.ok) {
|
|
123
|
+
builds.delete(owner.id);
|
|
124
|
+
return built;
|
|
125
|
+
}
|
|
126
|
+
const next = new Map<string, ScriptablePackAssetSnapshot>();
|
|
127
|
+
for (const output of built.value) {
|
|
128
|
+
const outputGuid = AssetGuid.format(output.guid).toLowerCase();
|
|
129
|
+
if (ownerByGuid.get(outputGuid) !== owner) {
|
|
130
|
+
builds.delete(owner.id);
|
|
131
|
+
return err(missingOutputError(owner.id, outputGuid));
|
|
132
|
+
}
|
|
133
|
+
next.set(outputGuid, {
|
|
134
|
+
asset: structuredClone(output.asset),
|
|
135
|
+
generation: options.generation,
|
|
136
|
+
digest: output.digest ?? (await assetDigest(output.asset)),
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
for (const declared of owner.guids) {
|
|
140
|
+
const declaredGuid = AssetGuid.format(declared).toLowerCase();
|
|
141
|
+
if (!next.has(declaredGuid)) {
|
|
142
|
+
builds.delete(owner.id);
|
|
143
|
+
return err(missingOutputError(owner.id, declaredGuid));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
for (const [outputGuid, snapshot] of next) snapshots.set(outputGuid, snapshot);
|
|
147
|
+
return ok(undefined);
|
|
148
|
+
})();
|
|
149
|
+
builds.set(owner.id, building);
|
|
150
|
+
}
|
|
151
|
+
const built = await building;
|
|
152
|
+
if (!built.ok) return built;
|
|
153
|
+
const staged = snapshots.get(key);
|
|
154
|
+
return staged === undefined
|
|
155
|
+
? err(missingOutputError(owner.id, key))
|
|
156
|
+
: ok(structuredClone(staged));
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return sourceFor([]);
|
|
161
|
+
}
|