@instruktlabs/kiln 1.2.1 → 1.2.3
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +86 -1
- package/README.md +18 -0
- package/dist/agent-run.mjs +6392 -1477
- package/dist/build.json +23 -23
- package/dist/cli.mjs +8453 -3407
- package/dist/evaluator-worker.mjs +16608 -12387
- package/dist/mcp-engine.mjs +6746 -1725
- package/dist/mcp-server.mjs +4 -4
- package/dist/viewer/app.js +203 -199
- package/dist/viewer/chat.html +209 -205
- package/dist/viewer/style.css +10 -0
- package/docs/atlas-provenance.md +23 -0
- package/docs/community-exporter.md +27 -0
- package/docs/export-profiles.md +20 -0
- package/docs/install.md +7 -6
- package/docs/material-library.md +9 -5
- package/docs/migration.md +16 -0
- package/docs/procedural-surfaces-v3.md +276 -0
- package/docs/projects-and-live-review.md +1 -1
- package/docs/rendering.md +41 -0
- package/docs/runtime.md +3 -3
- package/docs/sdk.md +55 -2
- package/docs/tools-operations.md +2 -2
- package/docs/tools.md +3 -3
- package/lib/asset-materials-node.d.ts +3 -3
- package/lib/asset-materials-node.js +29 -33
- package/lib/assets-node.d.ts +1 -1
- package/lib/assets-node.js +11 -4
- package/lib/assets.d.ts +4 -2
- package/lib/assets.js +61 -16
- package/lib/atlas-provenance.d.ts +86 -0
- package/lib/atlas-provenance.js +448 -0
- package/lib/character.d.ts +11 -2
- package/lib/character.js +90 -2
- package/lib/community-exporter.d.ts +3 -2
- package/lib/community-exporter.js +139 -3
- package/lib/discovery/helper-contracts.js +161 -7
- package/lib/discovery/helper-specs.js +49 -4
- package/lib/discovery/shapes.js +20 -7
- package/lib/engine-identity.d.ts +1 -1
- package/lib/engine-identity.js +1 -1
- package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
- package/lib/evaluator/authoring-diagnostic.js +12 -0
- package/lib/evaluator/protocol.d.ts +2 -2
- package/lib/evaluator/protocol.js +16 -6
- package/lib/evaluator/subprocess.js +2 -2
- package/lib/exporter-node.js +9 -1
- package/lib/geometry-budget.d.ts +7 -0
- package/lib/geometry-budget.js +8 -0
- package/lib/gltf-texture-binding.d.ts +6 -0
- package/lib/gltf-texture-binding.js +54 -0
- package/lib/material-authoring-node.d.ts +6 -0
- package/lib/material-authoring-node.js +49 -0
- package/lib/material-authoring-schema.d.ts +871 -0
- package/lib/material-authoring-schema.js +310 -0
- package/lib/material-export.d.ts +5 -0
- package/lib/material-export.js +21 -0
- package/lib/material-library-node.d.ts +34 -8
- package/lib/material-library-node.js +691 -11
- package/lib/material-library.d.ts +3704 -4
- package/lib/material-library.js +272 -0
- package/lib/mcp-core.d.ts +1 -1
- package/lib/pinned-material-map.d.ts +33 -0
- package/lib/pinned-material-map.js +1 -0
- package/lib/portable-material-runtime.d.ts +5 -0
- package/lib/portable-material-runtime.js +160 -0
- package/lib/portable-sampler-runtime.d.ts +4 -0
- package/lib/portable-sampler-runtime.js +26 -0
- package/lib/portable-sampler.d.ts +11 -0
- package/lib/portable-sampler.js +42 -0
- package/lib/primitives.d.ts +15 -3
- package/lib/primitives.js +24 -4
- package/lib/procedural-material-v2.d.ts +2 -0
- package/lib/procedural-material-v2.js +2 -1
- package/lib/procedural-material-v3.d.ts +72 -0
- package/lib/procedural-material-v3.js +162 -0
- package/lib/procedural-texture-v2-compiler.d.ts +31 -0
- package/lib/procedural-texture-v2-compiler.js +172 -0
- package/lib/procedural-texture-v3-contract.d.ts +125 -0
- package/lib/procedural-texture-v3-contract.js +333 -0
- package/lib/procedural-texture-v3.d.ts +12 -0
- package/lib/procedural-texture-v3.js +165 -0
- package/lib/procedural-texture.d.ts +9 -31
- package/lib/procedural-texture.js +43 -170
- package/lib/project-bundle-node.d.ts +1 -1
- package/lib/project-bundle-node.js +70 -13
- package/lib/project-bundle.d.ts +91 -3
- package/lib/project-bundle.js +48 -7
- package/lib/qa/atlas.d.ts +21 -0
- package/lib/qa/atlas.js +474 -0
- package/lib/qa/index.d.ts +1 -0
- package/lib/qa/index.js +1 -0
- package/lib/qa/material.js +2 -0
- package/lib/qa/skin-integrity.d.ts +18 -0
- package/lib/qa/skin-integrity.js +221 -0
- package/lib/render-service-host.js +4 -7
- package/lib/render.d.ts +3 -3
- package/lib/render.js +108 -16
- package/lib/skin-binding.d.ts +20 -0
- package/lib/skin-binding.js +144 -0
- package/lib/texture-atlas-contract.d.ts +97 -0
- package/lib/texture-atlas-contract.js +414 -0
- package/lib/texture-atlas-pixels.d.ts +73 -0
- package/lib/texture-atlas-pixels.js +290 -0
- package/lib/texture-atlas.d.ts +10 -0
- package/lib/texture-atlas.js +143 -0
- package/lib/texture-bake.d.ts +3 -0
- package/lib/texture-bake.js +66 -11
- package/lib/texture-resolver.d.ts +22 -0
- package/lib/texture-resolver.js +24 -0
- package/lib/tools/materials.js +8 -7
- package/lib/tools/registry.js +5 -16
- package/lib/tools/review.js +1 -1
- package/lib/uv-data.d.ts +32 -0
- package/lib/uv-data.js +172 -0
- package/lib/uv-shapes.js +3 -0
- package/lib/uv.d.ts +9 -3
- package/lib/uv.js +53 -82
- package/lib/views/camera.d.ts +3 -3
- package/lib/views/camera.js +32 -28
- package/lib/views/face-policy.d.ts +9 -0
- package/lib/views/face-policy.js +16 -0
- package/lib/views/glb.js +218 -24
- package/lib/views/pose.d.ts +7 -1
- package/lib/views/pose.js +63 -12
- package/lib/views/raster.d.ts +6 -2
- package/lib/views/raster.js +49 -37
- package/lib/views/scene-copy.d.ts +6 -0
- package/lib/views/scene-copy.js +73 -0
- package/lib/views/scene-raster.js +2 -1
- package/lib/views/triangle-coverage.d.ts +8 -0
- package/lib/views/triangle-coverage.js +58 -0
- package/lib/workspace-node.js +7 -3
- package/lib/workspace.d.ts +2 -2
- package/package.json +3 -1
- package/plugin.json +1 -1
- package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
- package/plugins/kiln-engine/README.md +3 -3
- package/plugins/kiln-engine/package-provenance.json +6 -6
- package/plugins/kiln-engine/plugin.json +1 -1
- package/plugins/kiln-engine/runtime.json +1 -1
- package/skills/kiln-author-asset/SKILL.md +1 -1
- package/skills/kiln-author-asset/references/camera-cli.md +13 -0
- package/skills/kiln-author-asset/references/geometry-recipes.md +134 -0
- package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
- package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
- package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
- package/src/asset-materials-node.ts +38 -45
- package/src/assets-node.ts +22 -5
- package/src/assets.ts +61 -20
- package/src/atlas-provenance.ts +522 -0
- package/src/character.ts +107 -2
- package/src/community-exporter.ts +143 -3
- package/src/discovery/helper-contracts.ts +188 -7
- package/src/discovery/helper-specs.ts +69 -4
- package/src/discovery/shapes.ts +25 -9
- package/src/engine-identity.ts +1 -1
- package/src/evaluator/authoring-diagnostic.ts +19 -1
- package/src/evaluator/protocol.ts +19 -9
- package/src/evaluator/subprocess.ts +2 -2
- package/src/exporter-node.ts +12 -1
- package/src/generated/mcp-manifest.json +3 -3
- package/src/geometry-budget.ts +8 -0
- package/src/gltf-texture-binding.ts +60 -0
- package/src/material-authoring-node.ts +68 -0
- package/src/material-authoring-schema.ts +343 -0
- package/src/material-export.ts +25 -0
- package/src/material-library-node.ts +840 -16
- package/src/material-library.ts +348 -5
- package/src/pinned-material-map.ts +43 -0
- package/src/portable-material-runtime.ts +200 -0
- package/src/portable-sampler-runtime.ts +40 -0
- package/src/portable-sampler.ts +54 -0
- package/src/primitives.ts +98 -4
- package/src/procedural-material-v2.ts +2 -1
- package/src/procedural-material-v3.ts +224 -0
- package/src/procedural-texture-v2-compiler.ts +234 -0
- package/src/procedural-texture-v3-contract.ts +437 -0
- package/src/procedural-texture-v3.ts +220 -0
- package/src/procedural-texture.ts +61 -229
- package/src/project-bundle-node.ts +106 -14
- package/src/project-bundle.ts +61 -18
- package/src/project-cli.ts +29 -17
- package/src/qa/atlas.ts +597 -0
- package/src/qa/index.ts +1 -0
- package/src/qa/material.ts +2 -0
- package/src/qa/skin-integrity.ts +291 -0
- package/src/render-service-host.ts +4 -6
- package/src/render.ts +133 -15
- package/src/skin-binding.ts +225 -0
- package/src/texture-atlas-contract.ts +558 -0
- package/src/texture-atlas-pixels.ts +456 -0
- package/src/texture-atlas.ts +185 -0
- package/src/texture-bake.ts +88 -10
- package/src/texture-resolver.ts +55 -0
- package/src/tools/materials.ts +15 -11
- package/src/tools/registry.ts +5 -15
- package/src/tools/review.ts +1 -1
- package/src/uv-data.ts +238 -0
- package/src/uv-shapes.ts +3 -0
- package/src/uv.ts +73 -128
- package/src/viewer/app.ts +37 -0
- package/src/viewer/live-panel.ts +57 -1
- package/src/viewer/material-preview-panel.ts +140 -0
- package/src/viewer/material-preview.ts +138 -0
- package/src/viewer/materials-panel.ts +34 -5
- package/src/viewer/navigation.ts +2 -0
- package/src/views/camera.ts +37 -40
- package/src/views/face-policy.ts +20 -0
- package/src/views/glb.ts +294 -17
- package/src/views/pose.ts +71 -13
- package/src/views/raster.ts +73 -46
- package/src/views/scene-copy.ts +86 -0
- package/src/views/scene-raster.ts +2 -1
- package/src/views/triangle-coverage.ts +78 -0
- package/src/workspace-node.ts +11 -3
- package/src/workspace.ts +2 -2
|
@@ -4,10 +4,18 @@ import { lstat, mkdir, readFile, readdir, realpath, rename, rm, writeFile } from
|
|
|
4
4
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import sharp from 'sharp';
|
|
6
6
|
import { DataTexture } from 'three';
|
|
7
|
-
import { assertMaterialJson, canonicalMaterialJson, MATERIAL_LIBRARY_LIMITS, MATERIAL_LIBRARY_SLOTS, MATERIAL_LIBRARY_USAGE, materialLibraryHashSchema, materialLibraryIdSchema, materialLibraryResourceId, materialManifestSchema, materialNormalDerivationSchema, materialPngDimensions, validateMaterialRecordShape, validateMaterialManifest, } from "./material-library.js";
|
|
7
|
+
import { assertMaterialJson, canonicalMaterialJson, MATERIAL_LIBRARY_LIMITS, MATERIAL_LIBRARY_SLOTS, MATERIAL_LIBRARY_USAGE, materialLibraryHashSchema, materialLibraryIdSchema, materialLibraryResourceId, materialManifestSchema, materialNormalDerivationSchema, materialPngDimensions, validateMaterialRecordShape, validateMaterialManifest, validateMaterialManifestAny, validateMaterialRecordShapeAny, materialManifestV2Schema, materialRecordDependencyPins, } from "./material-library.js";
|
|
8
|
+
import { canonicalizePortableSamplerV1 } from "./portable-sampler.js";
|
|
9
|
+
import { applyPortableSamplerV1 } from "./portable-sampler-runtime.js";
|
|
10
|
+
import { compileProceduralTextureSpecV3 } from "./procedural-texture-v3.js";
|
|
11
|
+
import { admitProceduralTextureSpecV3, PROCEDURAL_TEXTURE_V3_COMPILER_ID, } from "./procedural-texture-v3-contract.js";
|
|
12
|
+
import { admitTextureAtlasSpecV1, compileTextureAtlasSpecV1 } from "./texture-atlas-pixels.js";
|
|
13
|
+
import { TEXTURE_ATLAS_COMPILER_ID } from "./texture-atlas-contract.js";
|
|
8
14
|
import { compileProceduralTextureSpecV2, normalMapFromHeight } from "./procedural-texture.js";
|
|
15
|
+
import { canonicalizeProceduralTextureSpecV2, } from "./procedural-material-v2.js";
|
|
9
16
|
import { loadTexture } from "./textures.js";
|
|
10
17
|
import { DEFAULT_TEXTURE_RESOLVER } from "./texture-resolver.js";
|
|
18
|
+
import { PORTABLE_MATERIAL_V3_LIMITS } from "./procedural-material-v3.js";
|
|
11
19
|
const digest = (bytes) => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
12
20
|
const encoder = { name: 'sharp', version: sharp.versions.sharp };
|
|
13
21
|
function revisionHash(manifest) {
|
|
@@ -231,7 +239,7 @@ export function validateMaterialLibraryPayload(value) {
|
|
|
231
239
|
throw new Error('Material resource payload exceeds byte or pixel budget');
|
|
232
240
|
return payload;
|
|
233
241
|
}
|
|
234
|
-
|
|
242
|
+
async function createMaterialLibraryPayloadV1(records) {
|
|
235
243
|
const snapshots = records.map(snapshot);
|
|
236
244
|
for (const record of snapshots)
|
|
237
245
|
await verifyMaterialRecordV1(record);
|
|
@@ -246,7 +254,7 @@ export async function createMaterialLibraryPayload(records) {
|
|
|
246
254
|
})),
|
|
247
255
|
});
|
|
248
256
|
}
|
|
249
|
-
|
|
257
|
+
async function decodeMaterialLibraryPayloadV1(value) {
|
|
250
258
|
const payload = validateMaterialLibraryPayload(value);
|
|
251
259
|
const records = payload.records.map((wire) => ({
|
|
252
260
|
manifest: structuredClone(wire.manifest),
|
|
@@ -266,6 +274,7 @@ export async function createMaterialLibraryTextureResolver(records, fallback = D
|
|
|
266
274
|
for (const map of record.manifest.maps)
|
|
267
275
|
maps.set(materialLibraryResourceId(record.manifest, map.slot), { record, map });
|
|
268
276
|
return Object.freeze({
|
|
277
|
+
pinnedMaterialMaps: createPinnedMaterialMapPort(snapshots),
|
|
269
278
|
describeApprovedTexture(id) {
|
|
270
279
|
const entry = typeof id === 'string' ? maps.get(id) : undefined;
|
|
271
280
|
return entry
|
|
@@ -282,7 +291,7 @@ export async function createMaterialLibraryTextureResolver(records, fallback = D
|
|
|
282
291
|
name: id,
|
|
283
292
|
});
|
|
284
293
|
texture.userData['kilnMaterialResource'] = {
|
|
285
|
-
schemaVersion:
|
|
294
|
+
schemaVersion: entry.record.manifest.schemaVersion,
|
|
286
295
|
materialId: entry.record.manifest.materialId,
|
|
287
296
|
revisionId: entry.record.manifest.revisionId,
|
|
288
297
|
resourceId: id,
|
|
@@ -292,8 +301,59 @@ export async function createMaterialLibraryTextureResolver(records, fallback = D
|
|
|
292
301
|
? { physicalSizeMeters: { ...entry.record.manifest.physicalSizeMeters } }
|
|
293
302
|
: {}),
|
|
294
303
|
};
|
|
304
|
+
if ('sampler' in entry.map)
|
|
305
|
+
applyPortableSamplerV1(texture, entry.map.sampler);
|
|
306
|
+
if ('recipe' in entry.map && entry.map.recipe?.kind === 'atlas')
|
|
307
|
+
texture.userData['kilnAtlas'] = {
|
|
308
|
+
spec: structuredClone(entry.map.recipe.spec),
|
|
309
|
+
manifest: structuredClone(entry.map.recipe.manifest),
|
|
310
|
+
};
|
|
295
311
|
return texture;
|
|
296
312
|
},
|
|
313
|
+
async preparePortableTexture(id, context) {
|
|
314
|
+
context?.signal?.throwIfAborted();
|
|
315
|
+
const entry = typeof id === 'string' ? maps.get(id) : undefined;
|
|
316
|
+
if (!entry) {
|
|
317
|
+
if (!fallback.preparePortableTexture)
|
|
318
|
+
throw new Error('Portable texture preparation unavailable');
|
|
319
|
+
return fallback.preparePortableTexture(id, context);
|
|
320
|
+
}
|
|
321
|
+
const { record, map } = entry;
|
|
322
|
+
return {
|
|
323
|
+
descriptor: {
|
|
324
|
+
resourceId: id,
|
|
325
|
+
usage: map.usage,
|
|
326
|
+
allowedSlots: [map.slot],
|
|
327
|
+
colorSpace: map.colorSpace,
|
|
328
|
+
width: map.width,
|
|
329
|
+
height: map.height,
|
|
330
|
+
encodedByteLength: map.bytes,
|
|
331
|
+
imageSha256: map.sha256,
|
|
332
|
+
},
|
|
333
|
+
bytes: record.files[map.file].slice(),
|
|
334
|
+
userData: {
|
|
335
|
+
...('recipe' in map && map.recipe?.kind === 'atlas'
|
|
336
|
+
? {
|
|
337
|
+
kilnAtlas: {
|
|
338
|
+
spec: structuredClone(map.recipe.spec),
|
|
339
|
+
manifest: structuredClone(map.recipe.manifest),
|
|
340
|
+
},
|
|
341
|
+
}
|
|
342
|
+
: {}),
|
|
343
|
+
kilnMaterialResource: {
|
|
344
|
+
schemaVersion: record.manifest.schemaVersion,
|
|
345
|
+
materialId: record.manifest.materialId,
|
|
346
|
+
revisionId: record.manifest.revisionId,
|
|
347
|
+
resourceId: id,
|
|
348
|
+
map: structuredClone(map),
|
|
349
|
+
sources: structuredClone(record.manifest.sources),
|
|
350
|
+
...(record.manifest.physicalSizeMeters
|
|
351
|
+
? { physicalSizeMeters: structuredClone(record.manifest.physicalSizeMeters) }
|
|
352
|
+
: {}),
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
},
|
|
297
357
|
materialRecipe: (id, overrides) => fallback.materialRecipe(id, overrides),
|
|
298
358
|
});
|
|
299
359
|
}
|
|
@@ -333,20 +393,42 @@ export class FileMaterialLibrary {
|
|
|
333
393
|
throw new Error('Invalid material library file');
|
|
334
394
|
return new Uint8Array(await readFile(path));
|
|
335
395
|
}
|
|
336
|
-
async
|
|
396
|
+
async readManifest(materialId, revisionId) {
|
|
337
397
|
materialLibraryIdSchema.parse(materialId);
|
|
338
398
|
materialLibraryHashSchema.parse(revisionId);
|
|
339
399
|
const dir = await this.path(materialId, revisionId.slice(7));
|
|
340
|
-
const manifest =
|
|
400
|
+
const manifest = validateMaterialManifestIdentityAny(JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await this.file(dir, 'manifest.json', MATERIAL_LIBRARY_LIMITS.maxManifestBytes))));
|
|
341
401
|
if (manifest.materialId !== materialId || manifest.revisionId !== revisionId)
|
|
342
402
|
throw new Error('Material identity mismatch');
|
|
403
|
+
return manifest;
|
|
404
|
+
}
|
|
405
|
+
/** Host-only file materialization after metadata admission; pixel/recipe verification is deferred. */
|
|
406
|
+
async readRecordFiles(materialId, revisionId) {
|
|
407
|
+
const manifest = await this.readManifest(materialId, revisionId);
|
|
408
|
+
const dir = await this.path(materialId, revisionId.slice(7));
|
|
343
409
|
const files = {};
|
|
344
410
|
for (const map of manifest.maps)
|
|
345
411
|
files[map.file] = await this.file(dir, map.file, MATERIAL_LIBRARY_LIMITS.maxMapBytes);
|
|
346
412
|
const record = { manifest, files };
|
|
347
|
-
|
|
413
|
+
validateMaterialRecordShapeAny(record);
|
|
414
|
+
for (const map of manifest.maps)
|
|
415
|
+
if (digest(files[map.file]) !== map.sha256)
|
|
416
|
+
throw new Error('Material integrity hash mismatch');
|
|
348
417
|
return record;
|
|
349
418
|
}
|
|
419
|
+
async read(materialId, revisionId) {
|
|
420
|
+
const root = await this.readManifest(materialId, revisionId);
|
|
421
|
+
if (root.schemaVersion === 1) {
|
|
422
|
+
const record = (await this.readRecordFiles(materialId, revisionId));
|
|
423
|
+
await verifyMaterialRecordV1(record);
|
|
424
|
+
return record;
|
|
425
|
+
}
|
|
426
|
+
const closure = await resolveMaterialRecordClosure([root], {
|
|
427
|
+
readManifest: this.readManifest.bind(this),
|
|
428
|
+
read: this.readRecordFiles.bind(this),
|
|
429
|
+
});
|
|
430
|
+
return closure.find((record) => record.manifest.materialId === materialId && record.manifest.revisionId === revisionId);
|
|
431
|
+
}
|
|
350
432
|
async list() {
|
|
351
433
|
const manifests = [];
|
|
352
434
|
for (const material of await readdir(await this.path(), { withFileTypes: true })) {
|
|
@@ -362,12 +444,27 @@ export class FileMaterialLibrary {
|
|
|
362
444
|
}
|
|
363
445
|
return manifests.sort((a, b) => a.materialId.localeCompare(b.materialId) || a.revisionId.localeCompare(b.revisionId));
|
|
364
446
|
}
|
|
365
|
-
async import(records) {
|
|
447
|
+
async import(records, options) {
|
|
366
448
|
if (!Array.isArray(records) || !records.length || records.length > 100)
|
|
367
449
|
throw new Error('Material import requires 1..100 records');
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
450
|
+
let snapshots;
|
|
451
|
+
if (records.every((record) => record.manifest.schemaVersion === 1) && !options?.materials) {
|
|
452
|
+
snapshots = records.map(snapshot);
|
|
453
|
+
for (const record of snapshots)
|
|
454
|
+
await verifyMaterialRecordV1(record);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
const candidates = [...records, ...(options?.materials ?? [])];
|
|
458
|
+
if (new Set(candidates.map((record) => identity(record.manifest))).size > 1024)
|
|
459
|
+
throw new Error('Material import context count limit exceeded');
|
|
460
|
+
const context = await resolveMaterialRecordClosure(records.map((r) => r.manifest), recordClosureReader(candidates, this), {
|
|
461
|
+
maxRecords: 1024,
|
|
462
|
+
maxBytes: 256 * 1024 * 1024,
|
|
463
|
+
maxPixels: 1024 * MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
|
|
464
|
+
});
|
|
465
|
+
const admitted = new Map(context.map((record) => [identity(record.manifest), record]));
|
|
466
|
+
snapshots = records.map((record) => admitted.get(identity(record.manifest)));
|
|
467
|
+
}
|
|
371
468
|
for (const record of snapshots) {
|
|
372
469
|
const { manifest, files } = record;
|
|
373
470
|
const dest = await this.path(manifest.materialId, manifest.revisionId.slice(7));
|
|
@@ -400,3 +497,586 @@ export class FileMaterialLibrary {
|
|
|
400
497
|
return snapshots.map((record) => record.manifest);
|
|
401
498
|
}
|
|
402
499
|
}
|
|
500
|
+
export function validateMaterialManifestIdentityAny(value) {
|
|
501
|
+
const manifest = validateMaterialManifestAny(value);
|
|
502
|
+
if (revisionHash(manifest) !== manifest.revisionId)
|
|
503
|
+
throw new Error('Material identity hash mismatch');
|
|
504
|
+
return manifest;
|
|
505
|
+
}
|
|
506
|
+
function snapshotAny(record) {
|
|
507
|
+
validateMaterialRecordShapeAny(record);
|
|
508
|
+
return {
|
|
509
|
+
manifest: structuredClone(record.manifest),
|
|
510
|
+
files: Object.fromEntries(Object.entries(record.files).map(([name, bytes]) => [name, bytes.slice()])),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
const identity = (manifest) => `${manifest.materialId}/${manifest.revisionId}`;
|
|
514
|
+
export async function resolveMaterialRecordClosure(roots, reader, limits = {
|
|
515
|
+
maxRecords: MATERIAL_LIBRARY_LIMITS.maxPayloadRecords,
|
|
516
|
+
maxBytes: MATERIAL_LIBRARY_LIMITS.maxPayloadBytes,
|
|
517
|
+
maxPixels: MATERIAL_LIBRARY_LIMITS.maxPayloadPixels,
|
|
518
|
+
}) {
|
|
519
|
+
for (const n of Object.values(limits))
|
|
520
|
+
if (!Number.isSafeInteger(n) || n < 1)
|
|
521
|
+
throw new Error('Invalid material closure limits');
|
|
522
|
+
const manifests = new Map(), active = new Set();
|
|
523
|
+
let bytes = 0, pixels = 0;
|
|
524
|
+
const visit = async (value) => {
|
|
525
|
+
const manifest = validateMaterialManifestIdentityAny(value), key = identity(manifest);
|
|
526
|
+
if (active.has(key))
|
|
527
|
+
throw new Error('Material dependency cycle');
|
|
528
|
+
const existing = manifests.get(key);
|
|
529
|
+
if (existing) {
|
|
530
|
+
if (canonicalMaterialJson(existing) !== canonicalMaterialJson(manifest))
|
|
531
|
+
throw new Error('Conflicting material dependency identity');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
manifests.set(key, structuredClone(manifest));
|
|
535
|
+
active.add(key);
|
|
536
|
+
for (const map of manifest.maps) {
|
|
537
|
+
bytes += map.bytes;
|
|
538
|
+
pixels += map.width * map.height;
|
|
539
|
+
}
|
|
540
|
+
if (manifests.size > limits.maxRecords || bytes > limits.maxBytes || pixels > limits.maxPixels)
|
|
541
|
+
throw new Error('Material closure count/byte/pixel limit exceeded');
|
|
542
|
+
for (const pin of materialRecordDependencyPins(manifest)) {
|
|
543
|
+
const child = await reader.readManifest(pin.resourceId, pin.revisionId);
|
|
544
|
+
if (child.materialId !== pin.resourceId ||
|
|
545
|
+
child.revisionId !== pin.revisionId ||
|
|
546
|
+
pin.sha256 !== pin.revisionId)
|
|
547
|
+
throw new Error('Material dependency pin mismatch');
|
|
548
|
+
await visit(child);
|
|
549
|
+
}
|
|
550
|
+
active.delete(key);
|
|
551
|
+
};
|
|
552
|
+
for (const root of roots)
|
|
553
|
+
await visit(root);
|
|
554
|
+
const records = [];
|
|
555
|
+
for (const [key, manifest] of manifests) {
|
|
556
|
+
const record = snapshotAny(await reader.read(manifest.materialId, manifest.revisionId));
|
|
557
|
+
if (identity(record.manifest) !== key ||
|
|
558
|
+
canonicalMaterialJson(record.manifest) !== canonicalMaterialJson(manifest))
|
|
559
|
+
throw new Error('Material closure manifest changed during read');
|
|
560
|
+
validateMaterialManifestIdentityAny(record.manifest);
|
|
561
|
+
for (const map of record.manifest.maps)
|
|
562
|
+
if (digest(record.files[map.file]) !== map.sha256)
|
|
563
|
+
throw new Error('Material integrity hash mismatch');
|
|
564
|
+
records.push(record);
|
|
565
|
+
}
|
|
566
|
+
for (const record of records)
|
|
567
|
+
await verifyMaterialRecordContents(record, { materials: records });
|
|
568
|
+
return records;
|
|
569
|
+
}
|
|
570
|
+
function recordClosureReader(records, fallback) {
|
|
571
|
+
const byId = new Map();
|
|
572
|
+
const duplicates = new Map();
|
|
573
|
+
for (const record of records) {
|
|
574
|
+
const key = identity(record.manifest), existing = byId.get(key);
|
|
575
|
+
if (existing &&
|
|
576
|
+
canonicalMaterialJson(existing.manifest) !== canonicalMaterialJson(record.manifest))
|
|
577
|
+
throw new Error('Conflicting material record identity');
|
|
578
|
+
if (!existing)
|
|
579
|
+
byId.set(key, record);
|
|
580
|
+
else if (existing !== record)
|
|
581
|
+
duplicates.set(key, [...(duplicates.get(key) ?? []), record]);
|
|
582
|
+
}
|
|
583
|
+
return {
|
|
584
|
+
async readManifest(id, revision) {
|
|
585
|
+
const record = byId.get(`${id}/${revision}`);
|
|
586
|
+
if (record)
|
|
587
|
+
return record.manifest;
|
|
588
|
+
if (fallback)
|
|
589
|
+
return fallback.readManifest(id, revision);
|
|
590
|
+
throw new Error(`Missing pinned material revision: ${id} at ${revision}`);
|
|
591
|
+
},
|
|
592
|
+
async read(id, revision) {
|
|
593
|
+
const key = `${id}/${revision}`, record = byId.get(key);
|
|
594
|
+
if (record) {
|
|
595
|
+
// Deferred until the complete metadata graph is admitted, before dedup reuse.
|
|
596
|
+
for (const candidate of [record, ...(duplicates.get(key) ?? [])]) {
|
|
597
|
+
const manifest = validateMaterialRecordShapeAny(candidate);
|
|
598
|
+
if (canonicalMaterialJson(manifest) !== canonicalMaterialJson(record.manifest))
|
|
599
|
+
throw new Error('Conflicting material record identity');
|
|
600
|
+
for (const map of manifest.maps)
|
|
601
|
+
if (digest(candidate.files[map.file]) !== map.sha256)
|
|
602
|
+
throw new Error('Conflicting material record encoded integrity hash');
|
|
603
|
+
}
|
|
604
|
+
return record;
|
|
605
|
+
}
|
|
606
|
+
if (fallback)
|
|
607
|
+
return fallback.readRecordFiles(id, revision);
|
|
608
|
+
throw new Error(`Missing pinned material revision: ${id} at ${revision}`);
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
/** The input closure is admitted and encoded-verified before this capability is constructed. */
|
|
613
|
+
function createPinnedMaterialMapPort(records) {
|
|
614
|
+
const entries = new Map();
|
|
615
|
+
for (const record of records) {
|
|
616
|
+
validateMaterialRecordShapeAny(record);
|
|
617
|
+
validateMaterialManifestIdentityAny(record.manifest);
|
|
618
|
+
for (const map of record.manifest.maps) {
|
|
619
|
+
if (digest(record.files[map.file]) !== map.sha256)
|
|
620
|
+
throw new Error('Material integrity hash mismatch');
|
|
621
|
+
entries.set(`${identity(record.manifest)}/${materialLibraryResourceId(record.manifest, map.slot)}`, { record, map });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
const find = (resourceId, pin) => {
|
|
625
|
+
if (typeof resourceId !== 'string' || pin.sha256 !== pin.revisionId)
|
|
626
|
+
return undefined;
|
|
627
|
+
return entries.get(`${pin.resourceId}/${pin.revisionId}/${resourceId}`);
|
|
628
|
+
};
|
|
629
|
+
const describe = (resourceId, pin) => {
|
|
630
|
+
const entry = find(resourceId, pin);
|
|
631
|
+
if (!entry)
|
|
632
|
+
return undefined;
|
|
633
|
+
const { record, map } = entry;
|
|
634
|
+
return {
|
|
635
|
+
materialId: record.manifest.materialId,
|
|
636
|
+
revisionId: record.manifest.revisionId,
|
|
637
|
+
resourceId: resourceId,
|
|
638
|
+
slot: map.slot,
|
|
639
|
+
usage: map.usage,
|
|
640
|
+
colorSpace: map.colorSpace,
|
|
641
|
+
width: map.width,
|
|
642
|
+
height: map.height,
|
|
643
|
+
encodedByteLength: map.bytes,
|
|
644
|
+
imageSha256: map.sha256,
|
|
645
|
+
...('pixelHash' in map ? { decodedPixelHash: map.pixelHash } : {}),
|
|
646
|
+
};
|
|
647
|
+
};
|
|
648
|
+
return {
|
|
649
|
+
describePinnedMaterialMap: describe,
|
|
650
|
+
async resolvePinnedMaterialMapPixels(resourceId, pin, context) {
|
|
651
|
+
context?.signal?.throwIfAborted();
|
|
652
|
+
const descriptor = describe(resourceId, pin), entry = find(resourceId, pin);
|
|
653
|
+
if (!descriptor || !entry)
|
|
654
|
+
throw new Error('Missing verified pinned-library map; approved resources cannot be atlas sources');
|
|
655
|
+
const { data, info } = await sharp(entry.record.files[entry.map.file], {
|
|
656
|
+
limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
|
|
657
|
+
failOn: 'warning',
|
|
658
|
+
})
|
|
659
|
+
.ensureAlpha()
|
|
660
|
+
.raw()
|
|
661
|
+
.toBuffer({ resolveWithObject: true });
|
|
662
|
+
context?.signal?.throwIfAborted();
|
|
663
|
+
if (info.width !== descriptor.width ||
|
|
664
|
+
info.height !== descriptor.height ||
|
|
665
|
+
info.channels !== 4)
|
|
666
|
+
throw new Error('Pinned material decoded dimensions mismatch');
|
|
667
|
+
const decodedPixelHash = digest(data);
|
|
668
|
+
if (descriptor.decodedPixelHash && descriptor.decodedPixelHash !== decodedPixelHash)
|
|
669
|
+
throw new Error('Pinned material pixel hash mismatch');
|
|
670
|
+
return { descriptor, pixels: Uint8Array.from(data), decodedPixelHash };
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
async function verifyMaterialRecordContents(record, options) {
|
|
675
|
+
if (record.manifest.schemaVersion === 1)
|
|
676
|
+
return verifyMaterialRecordV1(record);
|
|
677
|
+
const manifest = validateMaterialRecordShapeAny(record);
|
|
678
|
+
validateMaterialManifestIdentityAny(manifest);
|
|
679
|
+
const pins = new Set(materialRecordDependencyPins(manifest).map((pin) => `${pin.resourceId}/${pin.revisionId}`));
|
|
680
|
+
pins.add(identity(manifest));
|
|
681
|
+
const resources = createPinnedMaterialMapPort((options?.materials ?? [record]).filter((candidate) => pins.has(identity(candidate.manifest))));
|
|
682
|
+
admitMaterialManifestRecipes(manifest, resources);
|
|
683
|
+
for (const map of manifest.maps) {
|
|
684
|
+
const bytes = record.files[map.file];
|
|
685
|
+
if (digest(bytes) !== map.sha256)
|
|
686
|
+
throw new Error('Material integrity hash mismatch');
|
|
687
|
+
const { data, info } = await sharp(bytes, {
|
|
688
|
+
limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
|
|
689
|
+
failOn: 'warning',
|
|
690
|
+
})
|
|
691
|
+
.ensureAlpha()
|
|
692
|
+
.raw()
|
|
693
|
+
.toBuffer({ resolveWithObject: true });
|
|
694
|
+
if (info.width !== map.width ||
|
|
695
|
+
info.height !== map.height ||
|
|
696
|
+
info.channels !== 4 ||
|
|
697
|
+
digest(data) !== map.pixelHash)
|
|
698
|
+
throw new Error('Material decoded pixel hash/dimensions mismatch');
|
|
699
|
+
const recipe = map.recipe;
|
|
700
|
+
if (!recipe)
|
|
701
|
+
continue;
|
|
702
|
+
if (recipe.kind === 'atlas') {
|
|
703
|
+
const compiled = await compileTextureAtlasSpecV1(recipe.spec, { resources });
|
|
704
|
+
if (canonicalMaterialJson(compiled.manifest) !== canonicalMaterialJson(recipe.manifest) ||
|
|
705
|
+
digest(compiled.pixels) !== map.pixelHash)
|
|
706
|
+
throw new Error('Atlas recipe/manifest/pixel hash mismatch');
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
const compiled = recipe.spec.schemaVersion === 3
|
|
710
|
+
? compileProceduralTextureSpecV3(recipe.spec)
|
|
711
|
+
: materialPixels(recipe.spec, recipe.derive);
|
|
712
|
+
if (compiled.recipeHash !== recipe.recipeHash || digest(compiled.pixels) !== recipe.pixelHash)
|
|
713
|
+
throw new Error('Procedural recipe/pixel hash mismatch');
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
/** Verify the complete immutable dependency closure; admission precedes byte snapshots and PNG decode. */
|
|
718
|
+
export async function verifyMaterialRecord(record, options) {
|
|
719
|
+
if (record.manifest.schemaVersion === 1)
|
|
720
|
+
return verifyMaterialRecordV1(record);
|
|
721
|
+
await resolveMaterialRecordClosure([record.manifest], recordClosureReader([record, ...(options?.materials ?? [])]));
|
|
722
|
+
}
|
|
723
|
+
function admitMaterialManifestRecipes(manifest, resources) {
|
|
724
|
+
let texels = 0, work = 0, allocation = 0, encoded = 0;
|
|
725
|
+
for (const map of manifest.maps) {
|
|
726
|
+
encoded += map.bytes;
|
|
727
|
+
const recipe = map.recipe;
|
|
728
|
+
if (recipe?.kind === 'procedural') {
|
|
729
|
+
if (recipe.spec.schemaVersion === 3) {
|
|
730
|
+
const a = admitProceduralTextureSpecV3(recipe.spec);
|
|
731
|
+
texels += a.spec.width * a.spec.height;
|
|
732
|
+
work += a.estimatedWorkUnits;
|
|
733
|
+
allocation += a.conservativePixelAllocationBytes;
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
texels += recipe.spec.size ** 2;
|
|
737
|
+
work += recipe.spec.size ** 2 * recipe.spec.layers.length * 16;
|
|
738
|
+
allocation += recipe.spec.size ** 2 * 12;
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
else if (recipe?.kind === 'atlas') {
|
|
742
|
+
const a = admitTextureAtlasSpecV1(recipe.spec, { resources });
|
|
743
|
+
texels += a.spec.width * a.spec.height + a.budget.sourceTexels;
|
|
744
|
+
work += a.budget.workUnits;
|
|
745
|
+
allocation += a.budget.conservativePixelAllocationBytes;
|
|
746
|
+
encoded += a.budget.encodedBytes;
|
|
747
|
+
}
|
|
748
|
+
else {
|
|
749
|
+
texels += map.width * map.height;
|
|
750
|
+
work += map.width * map.height * 12 + map.bytes * 8;
|
|
751
|
+
allocation += map.width * map.height * 12 + map.bytes * 2;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
if (texels > PORTABLE_MATERIAL_V3_LIMITS.maxTexels ||
|
|
755
|
+
work > PORTABLE_MATERIAL_V3_LIMITS.maxWork ||
|
|
756
|
+
allocation > PORTABLE_MATERIAL_V3_LIMITS.maxTransientBytes ||
|
|
757
|
+
encoded > PORTABLE_MATERIAL_V3_LIMITS.maxEncodedBytes)
|
|
758
|
+
throw new Error('Material recipe aggregate texel/work/allocation/encoded budget exceeded');
|
|
759
|
+
}
|
|
760
|
+
export function validateMaterialLibraryPayloadAny(value) {
|
|
761
|
+
assertMaterialJson(value);
|
|
762
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
763
|
+
throw new Error('Invalid material resource payload');
|
|
764
|
+
if (value['schemaVersion'] === 1)
|
|
765
|
+
return validateMaterialLibraryPayload(value);
|
|
766
|
+
const payload = value;
|
|
767
|
+
if (Object.keys(payload).some((k) => !['schemaVersion', 'records'].includes(k)) ||
|
|
768
|
+
payload.schemaVersion !== 2 ||
|
|
769
|
+
!Array.isArray(payload.records) ||
|
|
770
|
+
payload.records.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
|
|
771
|
+
throw new Error('Invalid material resource payload');
|
|
772
|
+
let bytes = 0, pixels = 0;
|
|
773
|
+
const seen = new Set();
|
|
774
|
+
for (const wire of payload.records) {
|
|
775
|
+
if (!wire ||
|
|
776
|
+
typeof wire !== 'object' ||
|
|
777
|
+
Object.keys(wire).some((k) => !['manifest', 'files'].includes(k)))
|
|
778
|
+
throw new Error('Invalid material resource record');
|
|
779
|
+
const manifest = validateMaterialManifestIdentityAny(wire.manifest), key = identity(manifest);
|
|
780
|
+
if (seen.has(key))
|
|
781
|
+
throw new Error('Duplicate material resource revision');
|
|
782
|
+
seen.add(key);
|
|
783
|
+
if (!wire.files ||
|
|
784
|
+
typeof wire.files !== 'object' ||
|
|
785
|
+
Array.isArray(wire.files) ||
|
|
786
|
+
Object.keys(wire.files).length !== manifest.maps.length)
|
|
787
|
+
throw new Error('Material payload inventory mismatch');
|
|
788
|
+
for (const map of manifest.maps) {
|
|
789
|
+
const encoded = wire.files[map.file];
|
|
790
|
+
if (typeof encoded !== 'string' ||
|
|
791
|
+
encoded.length !== Math.ceil(map.bytes / 3) * 4 ||
|
|
792
|
+
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded))
|
|
793
|
+
throw new Error('Invalid material map base64');
|
|
794
|
+
bytes += map.bytes;
|
|
795
|
+
pixels += map.width * map.height;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
if (bytes > MATERIAL_LIBRARY_LIMITS.maxPayloadBytes ||
|
|
799
|
+
pixels > MATERIAL_LIBRARY_LIMITS.maxPayloadPixels)
|
|
800
|
+
throw new Error('Material resource payload exceeds byte/pixel budget');
|
|
801
|
+
return payload;
|
|
802
|
+
}
|
|
803
|
+
export async function createMaterialLibraryPayload(records) {
|
|
804
|
+
if (records.every((r) => r.manifest.schemaVersion === 1))
|
|
805
|
+
return createMaterialLibraryPayloadV1(records);
|
|
806
|
+
// Admit metadata before snapshots, encoding or recipe/pixel compilation.
|
|
807
|
+
let bytes = 0, pixels = 0;
|
|
808
|
+
if (records.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
|
|
809
|
+
throw new Error('Material payload record limit exceeded');
|
|
810
|
+
for (const record of records)
|
|
811
|
+
for (const map of validateMaterialManifestIdentityAny(record.manifest).maps) {
|
|
812
|
+
bytes += map.bytes;
|
|
813
|
+
pixels += map.width * map.height;
|
|
814
|
+
}
|
|
815
|
+
if (bytes > MATERIAL_LIBRARY_LIMITS.maxPayloadBytes ||
|
|
816
|
+
pixels > MATERIAL_LIBRARY_LIMITS.maxPayloadPixels)
|
|
817
|
+
throw new Error('Material payload byte/pixel budget exceeded');
|
|
818
|
+
const admitted = await resolveMaterialRecordClosure(records.map((r) => r.manifest), recordClosureReader(records));
|
|
819
|
+
const byIdentity = new Map(admitted.map((r) => [identity(r.manifest), r]));
|
|
820
|
+
const snapshots = records.map((r) => byIdentity.get(identity(r.manifest)));
|
|
821
|
+
return validateMaterialLibraryPayloadAny({
|
|
822
|
+
schemaVersion: 2,
|
|
823
|
+
records: snapshots.map((r) => ({
|
|
824
|
+
manifest: r.manifest,
|
|
825
|
+
files: Object.fromEntries(Object.entries(r.files).map(([name, bytes]) => [
|
|
826
|
+
name,
|
|
827
|
+
Buffer.from(bytes).toString('base64'),
|
|
828
|
+
])),
|
|
829
|
+
})),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
export async function decodeMaterialLibraryPayload(value) {
|
|
833
|
+
const payload = validateMaterialLibraryPayloadAny(value);
|
|
834
|
+
if (payload.schemaVersion === 1)
|
|
835
|
+
return decodeMaterialLibraryPayloadV1(payload);
|
|
836
|
+
const wires = payload.records.map((w) => ({
|
|
837
|
+
manifest: validateMaterialManifestIdentityAny(w.manifest),
|
|
838
|
+
files: { ...w.files },
|
|
839
|
+
}));
|
|
840
|
+
const byIdentity = new Map(wires.map((w) => [identity(w.manifest), w]));
|
|
841
|
+
const reader = {
|
|
842
|
+
async readManifest(id, revision) {
|
|
843
|
+
const wire = byIdentity.get(`${id}/${revision}`);
|
|
844
|
+
if (!wire)
|
|
845
|
+
throw new Error('Missing pinned material revision');
|
|
846
|
+
return wire.manifest;
|
|
847
|
+
},
|
|
848
|
+
async read(id, revision) {
|
|
849
|
+
const wire = byIdentity.get(`${id}/${revision}`);
|
|
850
|
+
if (!wire)
|
|
851
|
+
throw new Error('Missing pinned material revision');
|
|
852
|
+
return {
|
|
853
|
+
manifest: wire.manifest,
|
|
854
|
+
files: Object.fromEntries(Object.entries(wire.files).map(([name, encoded]) => [
|
|
855
|
+
name,
|
|
856
|
+
new Uint8Array(Buffer.from(encoded, 'base64')),
|
|
857
|
+
])),
|
|
858
|
+
};
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
const admitted = await resolveMaterialRecordClosure(wires.map((w) => w.manifest), reader);
|
|
862
|
+
const result = new Map(admitted.map((r) => [identity(r.manifest), r]));
|
|
863
|
+
return wires.map((w) => result.get(identity(w.manifest)));
|
|
864
|
+
}
|
|
865
|
+
export async function createMaterialRecordV2(draft, options) {
|
|
866
|
+
assertMaterialJson(draft, true);
|
|
867
|
+
if (!draft || typeof draft !== 'object' || Array.isArray(draft))
|
|
868
|
+
throw new Error('Invalid material draft');
|
|
869
|
+
exactKeys(draft, [
|
|
870
|
+
'schemaVersion',
|
|
871
|
+
'materialId',
|
|
872
|
+
'name',
|
|
873
|
+
'tags',
|
|
874
|
+
'tileable',
|
|
875
|
+
'physicalSizeMeters',
|
|
876
|
+
'parameters',
|
|
877
|
+
'sources',
|
|
878
|
+
'maps',
|
|
879
|
+
]);
|
|
880
|
+
const input = draft;
|
|
881
|
+
if (input['schemaVersion'] !== 2 ||
|
|
882
|
+
!Array.isArray(input['maps']) ||
|
|
883
|
+
input['maps'].length < 1 ||
|
|
884
|
+
input['maps'].length > 5)
|
|
885
|
+
throw new Error('V2 material requires schemaVersion2 and1..5 maps');
|
|
886
|
+
const candidates = options?.materials ?? [];
|
|
887
|
+
if (candidates.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
|
|
888
|
+
throw new Error('Material source closure count limit exceeded');
|
|
889
|
+
const records = candidates.length
|
|
890
|
+
? await resolveMaterialRecordClosure(candidates.map((r) => r.manifest), recordClosureReader(candidates))
|
|
891
|
+
: [];
|
|
892
|
+
const resources = createPinnedMaterialMapPort(records), prepared = [];
|
|
893
|
+
let texels = 0, work = 0, allocation = 0, encoded = 0;
|
|
894
|
+
for (const value of input['maps']) {
|
|
895
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
896
|
+
throw new Error('Invalid material map draft');
|
|
897
|
+
exactKeys(value, [
|
|
898
|
+
'slot',
|
|
899
|
+
'sourceId',
|
|
900
|
+
'transforms',
|
|
901
|
+
'normalConvention',
|
|
902
|
+
'channelPacking',
|
|
903
|
+
'sampler',
|
|
904
|
+
'bytes',
|
|
905
|
+
'originalFile',
|
|
906
|
+
'procedural',
|
|
907
|
+
'derive',
|
|
908
|
+
'atlas',
|
|
909
|
+
]);
|
|
910
|
+
const raw = value, slot = raw['slot'];
|
|
911
|
+
if (!MATERIAL_LIBRARY_SLOTS.includes(slot))
|
|
912
|
+
throw new Error('Unsupported material map slot');
|
|
913
|
+
const sampler = canonicalizePortableSamplerV1(raw['sampler']);
|
|
914
|
+
if (['bytes', 'procedural', 'atlas'].filter((k) => raw[k] !== undefined).length !== 1)
|
|
915
|
+
throw new Error('Supply exactly one map bytes/procedural/atlas source');
|
|
916
|
+
if (raw['procedural'] !== undefined) {
|
|
917
|
+
const spec = raw['procedural'];
|
|
918
|
+
if (spec.schemaVersion === 3) {
|
|
919
|
+
if (raw['derive'] !== undefined)
|
|
920
|
+
throw new Error('V3 normal derivation is unsupported');
|
|
921
|
+
const a = admitProceduralTextureSpecV3(spec);
|
|
922
|
+
if (a.spec.usage !== MATERIAL_LIBRARY_USAGE[slot])
|
|
923
|
+
throw new Error('Procedural usage mismatch');
|
|
924
|
+
texels += a.spec.width * a.spec.height;
|
|
925
|
+
work += a.estimatedWorkUnits;
|
|
926
|
+
allocation += a.conservativePixelAllocationBytes;
|
|
927
|
+
prepared.push({ raw, slot, sampler, procedural: a.spec });
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
const canonical = canonicalizeProceduralTextureSpecV2(spec);
|
|
931
|
+
if (canonical.usage !== MATERIAL_LIBRARY_USAGE[slot])
|
|
932
|
+
throw new Error('Procedural usage mismatch');
|
|
933
|
+
texels += canonical.size ** 2;
|
|
934
|
+
work += canonical.size ** 2 * canonical.layers.length * 16;
|
|
935
|
+
allocation += canonical.size ** 2 * 12;
|
|
936
|
+
prepared.push({ raw, slot, sampler, procedural: canonical });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
else if (raw['atlas'] !== undefined) {
|
|
940
|
+
if (raw['derive'] !== undefined)
|
|
941
|
+
throw new Error('Atlas derivation unsupported');
|
|
942
|
+
const a = admitTextureAtlasSpecV1(raw['atlas'], { resources });
|
|
943
|
+
if (a.spec.usage !== MATERIAL_LIBRARY_USAGE[slot] ||
|
|
944
|
+
canonicalMaterialJson(a.spec.sampler) !== canonicalMaterialJson(sampler))
|
|
945
|
+
throw new Error('Atlas usage/sampler mismatch');
|
|
946
|
+
texels += a.spec.width * a.spec.height + a.budget.sourceTexels;
|
|
947
|
+
work += a.budget.workUnits;
|
|
948
|
+
allocation += a.budget.conservativePixelAllocationBytes;
|
|
949
|
+
encoded += a.budget.encodedBytes;
|
|
950
|
+
prepared.push({ raw, slot, sampler, atlas: a.spec });
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
if (raw['derive'] !== undefined || !(raw['bytes'] instanceof Uint8Array))
|
|
954
|
+
throw new Error('Invalid external map bytes/derivation');
|
|
955
|
+
const bytes = raw['bytes'];
|
|
956
|
+
if (bytes.length > MATERIAL_LIBRARY_LIMITS.maxMapBytes)
|
|
957
|
+
throw new Error('Material map encoded byte limit');
|
|
958
|
+
const d = materialPngDimensions(bytes);
|
|
959
|
+
if (d.width < 1 || d.height < 1 || d.width > 4096 || d.height > 4096)
|
|
960
|
+
throw new Error('Material map dimensions limit');
|
|
961
|
+
texels += d.width * d.height;
|
|
962
|
+
encoded += bytes.length;
|
|
963
|
+
work += d.width * d.height * 12 + bytes.length * 8;
|
|
964
|
+
allocation += d.width * d.height * 12 + bytes.length * 2;
|
|
965
|
+
prepared.push({ raw, slot, sampler, bytes });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
if (texels > 4 * 1024 * 1024 ||
|
|
969
|
+
work > PORTABLE_MATERIAL_V3_LIMITS.maxWork ||
|
|
970
|
+
allocation > 64 * 1024 * 1024 ||
|
|
971
|
+
encoded > 16 * 1024 * 1024)
|
|
972
|
+
throw new Error('Material aggregate texel/work/allocation limit exceeded');
|
|
973
|
+
const maps = [], files = {}, pins = new Map();
|
|
974
|
+
for (const item of prepared) {
|
|
975
|
+
const { raw, slot, sampler } = item;
|
|
976
|
+
let bytes, pixelHash, recipe;
|
|
977
|
+
if (item.procedural !== undefined) {
|
|
978
|
+
const spec = item.procedural;
|
|
979
|
+
const compiled = spec.schemaVersion === 3
|
|
980
|
+
? compileProceduralTextureSpecV3(spec)
|
|
981
|
+
: materialPixels(spec, raw['derive']);
|
|
982
|
+
const d = compiled.spec.schemaVersion === 3
|
|
983
|
+
? { width: compiled.spec.width, height: compiled.spec.height }
|
|
984
|
+
: { width: compiled.spec.size, height: compiled.spec.size };
|
|
985
|
+
pixelHash = digest(compiled.pixels);
|
|
986
|
+
bytes = new Uint8Array(await sharp(compiled.pixels, { raw: { ...d, channels: 4 } })
|
|
987
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false })
|
|
988
|
+
.toBuffer());
|
|
989
|
+
recipe = {
|
|
990
|
+
kind: 'procedural',
|
|
991
|
+
compilerId: compiled.spec.schemaVersion === 3
|
|
992
|
+
? PROCEDURAL_TEXTURE_V3_COMPILER_ID
|
|
993
|
+
: 'kiln.procedural-texture.v2',
|
|
994
|
+
spec: compiled.spec,
|
|
995
|
+
recipeHash: compiled.recipeHash,
|
|
996
|
+
pixelHash,
|
|
997
|
+
encoder,
|
|
998
|
+
...(raw['derive']
|
|
999
|
+
? {
|
|
1000
|
+
derive: structuredClone(raw['derive']),
|
|
1001
|
+
}
|
|
1002
|
+
: {}),
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
else if (item.atlas !== undefined) {
|
|
1006
|
+
const compiled = await compileTextureAtlasSpecV1(item.atlas, { resources });
|
|
1007
|
+
pixelHash = compiled.manifest.decodedPixelHash;
|
|
1008
|
+
bytes = new Uint8Array(await sharp(compiled.pixels, {
|
|
1009
|
+
raw: { width: compiled.spec.width, height: compiled.spec.height, channels: 4 },
|
|
1010
|
+
})
|
|
1011
|
+
.png({ compressionLevel: 9, adaptiveFiltering: false })
|
|
1012
|
+
.toBuffer());
|
|
1013
|
+
recipe = {
|
|
1014
|
+
kind: 'atlas',
|
|
1015
|
+
compilerId: TEXTURE_ATLAS_COMPILER_ID,
|
|
1016
|
+
spec: compiled.spec,
|
|
1017
|
+
manifest: compiled.manifest,
|
|
1018
|
+
encoder,
|
|
1019
|
+
};
|
|
1020
|
+
for (const r of compiled.spec.regions)
|
|
1021
|
+
if (r.source.kind === 'resource')
|
|
1022
|
+
pins.set(`${r.source.pin.resourceId}/${r.source.pin.revisionId}`, r.source.pin);
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
bytes = item.bytes.slice();
|
|
1026
|
+
const decoded = await sharp(bytes, {
|
|
1027
|
+
limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
|
|
1028
|
+
failOn: 'warning',
|
|
1029
|
+
})
|
|
1030
|
+
.ensureAlpha()
|
|
1031
|
+
.raw()
|
|
1032
|
+
.toBuffer();
|
|
1033
|
+
pixelHash = digest(decoded);
|
|
1034
|
+
}
|
|
1035
|
+
const d = materialPngDimensions(bytes), file = `${slot}.png`;
|
|
1036
|
+
if (files[file])
|
|
1037
|
+
throw new Error('Duplicate material map slot');
|
|
1038
|
+
files[file] = bytes;
|
|
1039
|
+
maps.push({
|
|
1040
|
+
slot,
|
|
1041
|
+
file,
|
|
1042
|
+
...d,
|
|
1043
|
+
sha256: digest(bytes),
|
|
1044
|
+
bytes: bytes.length,
|
|
1045
|
+
usage: MATERIAL_LIBRARY_USAGE[slot],
|
|
1046
|
+
colorSpace: slot === 'baseColor' || slot === 'emissive' ? 'srgb' : 'linear',
|
|
1047
|
+
sampler,
|
|
1048
|
+
pixelHash,
|
|
1049
|
+
sourceId: raw['sourceId'],
|
|
1050
|
+
transforms: structuredClone(raw['transforms']),
|
|
1051
|
+
...(slot === 'normal'
|
|
1052
|
+
? {
|
|
1053
|
+
normalConvention: (raw['normalConvention'] ??
|
|
1054
|
+
(recipe ? 'opengl' : undefined)),
|
|
1055
|
+
}
|
|
1056
|
+
: {}),
|
|
1057
|
+
...(slot === 'metallicRoughness'
|
|
1058
|
+
? {
|
|
1059
|
+
channelPacking: (raw['channelPacking'] ??
|
|
1060
|
+
(recipe
|
|
1061
|
+
? 'r-occlusion-g-roughness-b-metallic'
|
|
1062
|
+
: undefined)),
|
|
1063
|
+
}
|
|
1064
|
+
: {}),
|
|
1065
|
+
...(raw['originalFile'] ? { originalFile: raw['originalFile'] } : {}),
|
|
1066
|
+
...(recipe ? { recipe } : {}),
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
const manifest = materialManifestV2Schema.parse({
|
|
1070
|
+
...input,
|
|
1071
|
+
schemaVersion: 2,
|
|
1072
|
+
revisionId: `sha256:${'0'.repeat(64)}`,
|
|
1073
|
+
tags: input['tags'] ?? [],
|
|
1074
|
+
parameters: input['parameters'] ?? {},
|
|
1075
|
+
maps,
|
|
1076
|
+
dependencies: [...pins.values()],
|
|
1077
|
+
});
|
|
1078
|
+
manifest.revisionId = revisionHash(manifest);
|
|
1079
|
+
const record = { manifest, files };
|
|
1080
|
+
await verifyMaterialRecord(record, { materials: [...records, record] });
|
|
1081
|
+
return record;
|
|
1082
|
+
}
|