@instruktlabs/kiln 1.2.2 → 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.
Files changed (214) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +74 -0
  4. package/README.md +7 -0
  5. package/dist/agent-run.mjs +6392 -1477
  6. package/dist/build.json +23 -23
  7. package/dist/cli.mjs +8453 -3407
  8. package/dist/evaluator-worker.mjs +16608 -12387
  9. package/dist/mcp-engine.mjs +6746 -1725
  10. package/dist/mcp-server.mjs +4 -4
  11. package/dist/viewer/app.js +203 -199
  12. package/dist/viewer/chat.html +209 -205
  13. package/dist/viewer/style.css +10 -0
  14. package/docs/atlas-provenance.md +23 -0
  15. package/docs/community-exporter.md +27 -0
  16. package/docs/export-profiles.md +20 -0
  17. package/docs/material-library.md +9 -5
  18. package/docs/procedural-surfaces-v3.md +276 -0
  19. package/docs/projects-and-live-review.md +1 -1
  20. package/docs/rendering.md +41 -0
  21. package/docs/sdk.md +53 -0
  22. package/docs/tools-operations.md +2 -2
  23. package/docs/tools.md +3 -3
  24. package/lib/asset-materials-node.d.ts +3 -3
  25. package/lib/asset-materials-node.js +29 -33
  26. package/lib/assets-node.d.ts +1 -1
  27. package/lib/assets-node.js +11 -4
  28. package/lib/assets.d.ts +4 -2
  29. package/lib/assets.js +61 -16
  30. package/lib/atlas-provenance.d.ts +86 -0
  31. package/lib/atlas-provenance.js +448 -0
  32. package/lib/character.d.ts +11 -2
  33. package/lib/character.js +90 -2
  34. package/lib/community-exporter.d.ts +3 -2
  35. package/lib/community-exporter.js +139 -3
  36. package/lib/discovery/helper-contracts.js +161 -7
  37. package/lib/discovery/helper-specs.js +49 -4
  38. package/lib/discovery/shapes.js +20 -7
  39. package/lib/engine-identity.d.ts +1 -1
  40. package/lib/engine-identity.js +1 -1
  41. package/lib/evaluator/authoring-diagnostic.d.ts +1 -1
  42. package/lib/evaluator/authoring-diagnostic.js +12 -0
  43. package/lib/evaluator/protocol.d.ts +2 -2
  44. package/lib/evaluator/protocol.js +16 -6
  45. package/lib/evaluator/subprocess.js +2 -2
  46. package/lib/exporter-node.js +9 -1
  47. package/lib/geometry-budget.d.ts +7 -0
  48. package/lib/geometry-budget.js +8 -0
  49. package/lib/gltf-texture-binding.d.ts +6 -0
  50. package/lib/gltf-texture-binding.js +54 -0
  51. package/lib/material-authoring-node.d.ts +6 -0
  52. package/lib/material-authoring-node.js +49 -0
  53. package/lib/material-authoring-schema.d.ts +871 -0
  54. package/lib/material-authoring-schema.js +310 -0
  55. package/lib/material-export.d.ts +5 -0
  56. package/lib/material-export.js +21 -0
  57. package/lib/material-library-node.d.ts +34 -8
  58. package/lib/material-library-node.js +691 -11
  59. package/lib/material-library.d.ts +3704 -4
  60. package/lib/material-library.js +272 -0
  61. package/lib/mcp-core.d.ts +1 -1
  62. package/lib/pinned-material-map.d.ts +33 -0
  63. package/lib/pinned-material-map.js +1 -0
  64. package/lib/portable-material-runtime.d.ts +5 -0
  65. package/lib/portable-material-runtime.js +160 -0
  66. package/lib/portable-sampler-runtime.d.ts +4 -0
  67. package/lib/portable-sampler-runtime.js +26 -0
  68. package/lib/portable-sampler.d.ts +11 -0
  69. package/lib/portable-sampler.js +42 -0
  70. package/lib/primitives.d.ts +15 -3
  71. package/lib/primitives.js +24 -4
  72. package/lib/procedural-material-v2.d.ts +2 -0
  73. package/lib/procedural-material-v2.js +2 -1
  74. package/lib/procedural-material-v3.d.ts +72 -0
  75. package/lib/procedural-material-v3.js +162 -0
  76. package/lib/procedural-texture-v2-compiler.d.ts +31 -0
  77. package/lib/procedural-texture-v2-compiler.js +172 -0
  78. package/lib/procedural-texture-v3-contract.d.ts +125 -0
  79. package/lib/procedural-texture-v3-contract.js +333 -0
  80. package/lib/procedural-texture-v3.d.ts +12 -0
  81. package/lib/procedural-texture-v3.js +165 -0
  82. package/lib/procedural-texture.d.ts +9 -31
  83. package/lib/procedural-texture.js +43 -170
  84. package/lib/project-bundle-node.d.ts +1 -1
  85. package/lib/project-bundle-node.js +70 -13
  86. package/lib/project-bundle.d.ts +91 -3
  87. package/lib/project-bundle.js +48 -7
  88. package/lib/qa/atlas.d.ts +21 -0
  89. package/lib/qa/atlas.js +474 -0
  90. package/lib/qa/index.d.ts +1 -0
  91. package/lib/qa/index.js +1 -0
  92. package/lib/qa/material.js +2 -0
  93. package/lib/qa/skin-integrity.d.ts +18 -0
  94. package/lib/qa/skin-integrity.js +221 -0
  95. package/lib/render-service-host.js +4 -7
  96. package/lib/render.d.ts +3 -3
  97. package/lib/render.js +108 -16
  98. package/lib/skin-binding.d.ts +20 -0
  99. package/lib/skin-binding.js +144 -0
  100. package/lib/texture-atlas-contract.d.ts +97 -0
  101. package/lib/texture-atlas-contract.js +414 -0
  102. package/lib/texture-atlas-pixels.d.ts +73 -0
  103. package/lib/texture-atlas-pixels.js +290 -0
  104. package/lib/texture-atlas.d.ts +10 -0
  105. package/lib/texture-atlas.js +143 -0
  106. package/lib/texture-bake.d.ts +3 -0
  107. package/lib/texture-bake.js +66 -11
  108. package/lib/texture-resolver.d.ts +22 -0
  109. package/lib/texture-resolver.js +24 -0
  110. package/lib/tools/materials.js +8 -7
  111. package/lib/tools/registry.js +5 -16
  112. package/lib/tools/review.js +1 -1
  113. package/lib/uv-data.d.ts +32 -0
  114. package/lib/uv-data.js +172 -0
  115. package/lib/uv-shapes.js +3 -0
  116. package/lib/uv.d.ts +9 -3
  117. package/lib/uv.js +53 -82
  118. package/lib/views/camera.d.ts +3 -3
  119. package/lib/views/camera.js +32 -28
  120. package/lib/views/face-policy.d.ts +9 -0
  121. package/lib/views/face-policy.js +16 -0
  122. package/lib/views/glb.js +218 -24
  123. package/lib/views/pose.d.ts +7 -1
  124. package/lib/views/pose.js +63 -12
  125. package/lib/views/raster.d.ts +6 -2
  126. package/lib/views/raster.js +49 -37
  127. package/lib/views/scene-copy.d.ts +6 -0
  128. package/lib/views/scene-copy.js +73 -0
  129. package/lib/views/scene-raster.js +2 -1
  130. package/lib/views/triangle-coverage.d.ts +8 -0
  131. package/lib/views/triangle-coverage.js +58 -0
  132. package/lib/workspace-node.js +7 -3
  133. package/lib/workspace.d.ts +2 -2
  134. package/package.json +3 -1
  135. package/plugin.json +1 -1
  136. package/plugins/kiln-engine/.claude-plugin/plugin.json +1 -1
  137. package/plugins/kiln-engine/README.md +3 -3
  138. package/plugins/kiln-engine/package-provenance.json +6 -6
  139. package/plugins/kiln-engine/plugin.json +1 -1
  140. package/plugins/kiln-engine/runtime.json +1 -1
  141. package/skills/kiln-author-asset/references/geometry-recipes.md +124 -0
  142. package/skills/kiln-author-asset/references/projects-and-materials.md +32 -2
  143. package/skills/kiln-compose-scene/references/runtime-scenes.md +19 -0
  144. package/skills/kiln-qa-asset/references/integration-checks.md +37 -2
  145. package/src/asset-materials-node.ts +38 -45
  146. package/src/assets-node.ts +22 -5
  147. package/src/assets.ts +61 -20
  148. package/src/atlas-provenance.ts +522 -0
  149. package/src/character.ts +107 -2
  150. package/src/community-exporter.ts +143 -3
  151. package/src/discovery/helper-contracts.ts +188 -7
  152. package/src/discovery/helper-specs.ts +69 -4
  153. package/src/discovery/shapes.ts +25 -9
  154. package/src/engine-identity.ts +1 -1
  155. package/src/evaluator/authoring-diagnostic.ts +19 -1
  156. package/src/evaluator/protocol.ts +19 -9
  157. package/src/evaluator/subprocess.ts +2 -2
  158. package/src/exporter-node.ts +12 -1
  159. package/src/generated/mcp-manifest.json +3 -3
  160. package/src/geometry-budget.ts +8 -0
  161. package/src/gltf-texture-binding.ts +60 -0
  162. package/src/material-authoring-node.ts +68 -0
  163. package/src/material-authoring-schema.ts +343 -0
  164. package/src/material-export.ts +25 -0
  165. package/src/material-library-node.ts +840 -16
  166. package/src/material-library.ts +348 -5
  167. package/src/pinned-material-map.ts +43 -0
  168. package/src/portable-material-runtime.ts +200 -0
  169. package/src/portable-sampler-runtime.ts +40 -0
  170. package/src/portable-sampler.ts +54 -0
  171. package/src/primitives.ts +98 -4
  172. package/src/procedural-material-v2.ts +2 -1
  173. package/src/procedural-material-v3.ts +224 -0
  174. package/src/procedural-texture-v2-compiler.ts +234 -0
  175. package/src/procedural-texture-v3-contract.ts +437 -0
  176. package/src/procedural-texture-v3.ts +220 -0
  177. package/src/procedural-texture.ts +61 -229
  178. package/src/project-bundle-node.ts +106 -14
  179. package/src/project-bundle.ts +61 -18
  180. package/src/project-cli.ts +29 -17
  181. package/src/qa/atlas.ts +597 -0
  182. package/src/qa/index.ts +1 -0
  183. package/src/qa/material.ts +2 -0
  184. package/src/qa/skin-integrity.ts +291 -0
  185. package/src/render-service-host.ts +4 -6
  186. package/src/render.ts +133 -15
  187. package/src/skin-binding.ts +225 -0
  188. package/src/texture-atlas-contract.ts +558 -0
  189. package/src/texture-atlas-pixels.ts +456 -0
  190. package/src/texture-atlas.ts +185 -0
  191. package/src/texture-bake.ts +88 -10
  192. package/src/texture-resolver.ts +55 -0
  193. package/src/tools/materials.ts +15 -11
  194. package/src/tools/registry.ts +5 -15
  195. package/src/tools/review.ts +1 -1
  196. package/src/uv-data.ts +238 -0
  197. package/src/uv-shapes.ts +3 -0
  198. package/src/uv.ts +73 -128
  199. package/src/viewer/app.ts +37 -0
  200. package/src/viewer/live-panel.ts +57 -1
  201. package/src/viewer/material-preview-panel.ts +140 -0
  202. package/src/viewer/material-preview.ts +138 -0
  203. package/src/viewer/materials-panel.ts +34 -5
  204. package/src/viewer/navigation.ts +2 -0
  205. package/src/views/camera.ts +37 -40
  206. package/src/views/face-policy.ts +20 -0
  207. package/src/views/glb.ts +294 -17
  208. package/src/views/pose.ts +71 -13
  209. package/src/views/raster.ts +73 -46
  210. package/src/views/scene-copy.ts +86 -0
  211. package/src/views/scene-raster.ts +2 -1
  212. package/src/views/triangle-coverage.ts +78 -0
  213. package/src/workspace-node.ts +11 -3
  214. package/src/workspace.ts +2 -2
@@ -25,11 +25,39 @@ import {
25
25
  type MaterialRecordV1,
26
26
  type MaterialSourceV1,
27
27
  type MaterialTransformV1,
28
+ type MaterialManifest,
29
+ type MaterialManifestV2,
30
+ type MaterialMapV2,
31
+ type MaterialRecord,
32
+ type MaterialRecordV2,
33
+ type MaterialLibraryPayload,
34
+ validateMaterialManifestAny,
35
+ validateMaterialRecordShapeAny,
36
+ materialManifestV2Schema,
37
+ materialRecordDependencyPins,
28
38
  } from './material-library';
39
+ import { canonicalizePortableSamplerV1, type PortableSamplerV1 } from './portable-sampler';
40
+ import { applyPortableSamplerV1 } from './portable-sampler-runtime';
41
+ import { compileProceduralTextureSpecV3 } from './procedural-texture-v3';
42
+ import {
43
+ admitProceduralTextureSpecV3,
44
+ PROCEDURAL_TEXTURE_V3_COMPILER_ID,
45
+ } from './procedural-texture-v3-contract';
46
+ import { admitTextureAtlasSpecV1, compileTextureAtlasSpecV1 } from './texture-atlas-pixels';
47
+ import { TEXTURE_ATLAS_COMPILER_ID } from './texture-atlas-contract';
48
+ import type {
49
+ MaterialMapPinV1,
50
+ PinnedMaterialMapPortV1,
51
+ VerifiedPinnedMaterialMapDescriptorV1,
52
+ } from './pinned-material-map';
29
53
  import { compileProceduralTextureSpecV2, normalMapFromHeight } from './procedural-texture';
30
- import type { ProceduralTextureSpecV2 } from './procedural-material-v2';
54
+ import {
55
+ canonicalizeProceduralTextureSpecV2,
56
+ type ProceduralTextureSpecV2,
57
+ } from './procedural-material-v2';
31
58
  import { loadTexture } from './textures';
32
59
  import { DEFAULT_TEXTURE_RESOLVER, type TextureResolver } from './texture-resolver';
60
+ import { PORTABLE_MATERIAL_V3_LIMITS } from './procedural-material-v3';
33
61
 
34
62
  const digest = (bytes: Uint8Array | string): `sha256:${string}` =>
35
63
  `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
@@ -61,7 +89,7 @@ export interface MaterialDraftV1 {
61
89
  sources: MaterialSourceV1[];
62
90
  maps: MaterialMapDraftV1[];
63
91
  }
64
- function revisionHash(manifest: MaterialManifestV1): `sha256:${string}` {
92
+ function revisionHash(manifest: MaterialManifest): `sha256:${string}` {
65
93
  const { revisionId: _, ...content } = manifest;
66
94
  return digest(canonicalMaterialJson(content));
67
95
  }
@@ -303,7 +331,7 @@ export function validateMaterialLibraryPayload(value: unknown): MaterialLibraryP
303
331
  throw new Error('Material resource payload exceeds byte or pixel budget');
304
332
  return payload;
305
333
  }
306
- export async function createMaterialLibraryPayload(
334
+ async function createMaterialLibraryPayloadV1(
307
335
  records: MaterialRecordV1[],
308
336
  ): Promise<MaterialLibraryPayloadV1> {
309
337
  const snapshots = records.map(snapshot);
@@ -321,7 +349,7 @@ export async function createMaterialLibraryPayload(
321
349
  })),
322
350
  });
323
351
  }
324
- export async function decodeMaterialLibraryPayload(value: unknown): Promise<MaterialRecordV1[]> {
352
+ async function decodeMaterialLibraryPayloadV1(value: unknown): Promise<MaterialRecordV1[]> {
325
353
  const payload = validateMaterialLibraryPayload(value);
326
354
  const records = payload.records.map((wire) => ({
327
355
  manifest: structuredClone(wire.manifest),
@@ -336,15 +364,16 @@ export async function decodeMaterialLibraryPayload(value: unknown): Promise<Mate
336
364
  return records;
337
365
  }
338
366
  export async function createMaterialLibraryTextureResolver(
339
- records: MaterialRecordV1[],
367
+ records: MaterialRecord[],
340
368
  fallback: TextureResolver = DEFAULT_TEXTURE_RESOLVER,
341
369
  ): Promise<TextureResolver> {
342
370
  const snapshots = await decodeMaterialLibraryPayload(await createMaterialLibraryPayload(records));
343
- const maps = new Map<string, { record: MaterialRecordV1; map: MaterialMapV1 }>();
371
+ const maps = new Map<string, { record: MaterialRecord; map: MaterialMapV1 | MaterialMapV2 }>();
344
372
  for (const record of snapshots)
345
373
  for (const map of record.manifest.maps)
346
374
  maps.set(materialLibraryResourceId(record.manifest, map.slot), { record, map });
347
375
  return Object.freeze({
376
+ pinnedMaterialMaps: createPinnedMaterialMapPort(snapshots),
348
377
  describeApprovedTexture(id: unknown) {
349
378
  const entry = typeof id === 'string' ? maps.get(id) : undefined;
350
379
  return entry
@@ -360,7 +389,7 @@ export async function createMaterialLibraryTextureResolver(
360
389
  name: id as string,
361
390
  });
362
391
  texture.userData['kilnMaterialResource'] = {
363
- schemaVersion: 1,
392
+ schemaVersion: entry.record.manifest.schemaVersion,
364
393
  materialId: entry.record.manifest.materialId,
365
394
  revisionId: entry.record.manifest.revisionId,
366
395
  resourceId: id,
@@ -370,8 +399,58 @@ export async function createMaterialLibraryTextureResolver(
370
399
  ? { physicalSizeMeters: { ...entry.record.manifest.physicalSizeMeters } }
371
400
  : {}),
372
401
  };
402
+ if ('sampler' in entry.map) applyPortableSamplerV1(texture, entry.map.sampler);
403
+ if ('recipe' in entry.map && entry.map.recipe?.kind === 'atlas')
404
+ texture.userData['kilnAtlas'] = {
405
+ spec: structuredClone(entry.map.recipe.spec),
406
+ manifest: structuredClone(entry.map.recipe.manifest),
407
+ };
373
408
  return texture;
374
409
  },
410
+ async preparePortableTexture(id: unknown, context?: { signal?: AbortSignal }) {
411
+ context?.signal?.throwIfAborted();
412
+ const entry = typeof id === 'string' ? maps.get(id) : undefined;
413
+ if (!entry) {
414
+ if (!fallback.preparePortableTexture)
415
+ throw new Error('Portable texture preparation unavailable');
416
+ return fallback.preparePortableTexture(id, context);
417
+ }
418
+ const { record, map } = entry;
419
+ return {
420
+ descriptor: {
421
+ resourceId: id as string,
422
+ usage: map.usage,
423
+ allowedSlots: [map.slot],
424
+ colorSpace: map.colorSpace,
425
+ width: map.width,
426
+ height: map.height,
427
+ encodedByteLength: map.bytes,
428
+ imageSha256: map.sha256 as `sha256:${string}`,
429
+ },
430
+ bytes: record.files[map.file]!.slice(),
431
+ userData: {
432
+ ...('recipe' in map && map.recipe?.kind === 'atlas'
433
+ ? {
434
+ kilnAtlas: {
435
+ spec: structuredClone(map.recipe.spec),
436
+ manifest: structuredClone(map.recipe.manifest),
437
+ },
438
+ }
439
+ : {}),
440
+ kilnMaterialResource: {
441
+ schemaVersion: record.manifest.schemaVersion,
442
+ materialId: record.manifest.materialId,
443
+ revisionId: record.manifest.revisionId,
444
+ resourceId: id,
445
+ map: structuredClone(map),
446
+ sources: structuredClone(record.manifest.sources),
447
+ ...(record.manifest.physicalSizeMeters
448
+ ? { physicalSizeMeters: structuredClone(record.manifest.physicalSizeMeters) }
449
+ : {}),
450
+ },
451
+ },
452
+ };
453
+ },
375
454
  materialRecipe: (id: unknown, overrides?: unknown) => fallback.materialRecipe(id, overrides),
376
455
  });
377
456
  }
@@ -410,11 +489,11 @@ export class FileMaterialLibrary {
410
489
  throw new Error('Invalid material library file');
411
490
  return new Uint8Array(await readFile(path));
412
491
  }
413
- async read(materialId: string, revisionId: string): Promise<MaterialRecordV1> {
492
+ async readManifest(materialId: string, revisionId: string): Promise<MaterialManifest> {
414
493
  materialLibraryIdSchema.parse(materialId);
415
494
  materialLibraryHashSchema.parse(revisionId);
416
495
  const dir = await this.path(materialId, revisionId.slice(7));
417
- const manifest = materialManifestSchema.parse(
496
+ const manifest = validateMaterialManifestIdentityAny(
418
497
  JSON.parse(
419
498
  new TextDecoder('utf-8', { fatal: true }).decode(
420
499
  await this.file(dir, 'manifest.json', MATERIAL_LIBRARY_LIMITS.maxManifestBytes),
@@ -423,15 +502,40 @@ export class FileMaterialLibrary {
423
502
  );
424
503
  if (manifest.materialId !== materialId || manifest.revisionId !== revisionId)
425
504
  throw new Error('Material identity mismatch');
505
+ return manifest;
506
+ }
507
+ /** Host-only file materialization after metadata admission; pixel/recipe verification is deferred. */
508
+ async readRecordFiles(materialId: string, revisionId: string): Promise<MaterialRecord> {
509
+ const manifest = await this.readManifest(materialId, revisionId);
510
+ const dir = await this.path(materialId, revisionId.slice(7));
426
511
  const files: Record<string, Uint8Array> = {};
427
512
  for (const map of manifest.maps)
428
513
  files[map.file] = await this.file(dir, map.file, MATERIAL_LIBRARY_LIMITS.maxMapBytes);
429
- const record = { manifest, files };
430
- await verifyMaterialRecordV1(record);
514
+ const record = { manifest, files } as MaterialRecord;
515
+ validateMaterialRecordShapeAny(record);
516
+ for (const map of manifest.maps)
517
+ if (digest(files[map.file]!) !== map.sha256)
518
+ throw new Error('Material integrity hash mismatch');
431
519
  return record;
432
520
  }
433
- async list(): Promise<MaterialManifestV1[]> {
434
- const manifests: MaterialManifestV1[] = [];
521
+ async read(materialId: string, revisionId: string): Promise<MaterialRecord> {
522
+ const root = await this.readManifest(materialId, revisionId);
523
+ if (root.schemaVersion === 1) {
524
+ const record = (await this.readRecordFiles(materialId, revisionId)) as MaterialRecordV1;
525
+ await verifyMaterialRecordV1(record);
526
+ return record;
527
+ }
528
+ const closure = await resolveMaterialRecordClosure([root], {
529
+ readManifest: this.readManifest.bind(this),
530
+ read: this.readRecordFiles.bind(this),
531
+ });
532
+ return closure.find(
533
+ (record) =>
534
+ record.manifest.materialId === materialId && record.manifest.revisionId === revisionId,
535
+ )!;
536
+ }
537
+ async list(): Promise<MaterialManifest[]> {
538
+ const manifests: MaterialManifest[] = [];
435
539
  for (const material of await readdir(await this.path(), { withFileTypes: true })) {
436
540
  if (!material.isDirectory() || !materialLibraryIdSchema.safeParse(material.name).success)
437
541
  continue;
@@ -447,11 +551,32 @@ export class FileMaterialLibrary {
447
551
  a.materialId.localeCompare(b.materialId) || a.revisionId.localeCompare(b.revisionId),
448
552
  );
449
553
  }
450
- async import(records: MaterialRecordV1[]): Promise<MaterialManifestV1[]> {
554
+ async import(
555
+ records: MaterialRecord[],
556
+ options?: { materials?: readonly MaterialRecord[] },
557
+ ): Promise<MaterialManifest[]> {
451
558
  if (!Array.isArray(records) || !records.length || records.length > 100)
452
559
  throw new Error('Material import requires 1..100 records');
453
- const snapshots = records.map(snapshot);
454
- for (const record of snapshots) await verifyMaterialRecordV1(record);
560
+ let snapshots: MaterialRecord[];
561
+ if (records.every((record) => record.manifest.schemaVersion === 1) && !options?.materials) {
562
+ snapshots = (records as MaterialRecordV1[]).map(snapshot);
563
+ for (const record of snapshots) await verifyMaterialRecordV1(record as MaterialRecordV1);
564
+ } else {
565
+ const candidates = [...records, ...(options?.materials ?? [])];
566
+ if (new Set(candidates.map((record) => identity(record.manifest))).size > 1024)
567
+ throw new Error('Material import context count limit exceeded');
568
+ const context = await resolveMaterialRecordClosure(
569
+ records.map((r) => r.manifest),
570
+ recordClosureReader(candidates, this),
571
+ {
572
+ maxRecords: 1024,
573
+ maxBytes: 256 * 1024 * 1024,
574
+ maxPixels: 1024 * MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
575
+ },
576
+ );
577
+ const admitted = new Map(context.map((record) => [identity(record.manifest), record]));
578
+ snapshots = records.map((record) => admitted.get(identity(record.manifest))!);
579
+ }
455
580
  for (const record of snapshots) {
456
581
  const { manifest, files } = record;
457
582
  const dest = await this.path(manifest.materialId, manifest.revisionId.slice(7));
@@ -486,3 +611,702 @@ export class FileMaterialLibrary {
486
611
  return snapshots.map((record) => record.manifest);
487
612
  }
488
613
  }
614
+
615
+ export function validateMaterialManifestIdentityAny(value: unknown): MaterialManifest {
616
+ const manifest = validateMaterialManifestAny(value);
617
+ if (revisionHash(manifest) !== manifest.revisionId)
618
+ throw new Error('Material identity hash mismatch');
619
+ return manifest;
620
+ }
621
+ function snapshotAny(record: MaterialRecord): MaterialRecord {
622
+ validateMaterialRecordShapeAny(record);
623
+ return {
624
+ manifest: structuredClone(record.manifest),
625
+ files: Object.fromEntries(
626
+ Object.entries(record.files).map(([name, bytes]) => [name, bytes.slice()]),
627
+ ),
628
+ } as MaterialRecord;
629
+ }
630
+ const identity = (manifest: MaterialManifest) => `${manifest.materialId}/${manifest.revisionId}`;
631
+ export interface MaterialRecordClosureReader {
632
+ readManifest(materialId: string, revisionId: string): Promise<MaterialManifest>;
633
+ /** Return owned bytes after metadata admission; the walker verifies the complete closure. */
634
+ read(materialId: string, revisionId: string): Promise<MaterialRecord>;
635
+ }
636
+ export interface MaterialRecordClosureLimits {
637
+ maxRecords: number;
638
+ maxBytes: number;
639
+ maxPixels: number;
640
+ }
641
+ export async function resolveMaterialRecordClosure(
642
+ roots: readonly MaterialManifest[],
643
+ reader: MaterialRecordClosureReader,
644
+ limits: MaterialRecordClosureLimits = {
645
+ maxRecords: MATERIAL_LIBRARY_LIMITS.maxPayloadRecords,
646
+ maxBytes: MATERIAL_LIBRARY_LIMITS.maxPayloadBytes,
647
+ maxPixels: MATERIAL_LIBRARY_LIMITS.maxPayloadPixels,
648
+ },
649
+ ): Promise<MaterialRecord[]> {
650
+ for (const n of Object.values(limits))
651
+ if (!Number.isSafeInteger(n) || n < 1) throw new Error('Invalid material closure limits');
652
+ const manifests = new Map<string, MaterialManifest>(),
653
+ active = new Set<string>();
654
+ let bytes = 0,
655
+ pixels = 0;
656
+ const visit = async (value: MaterialManifest): Promise<void> => {
657
+ const manifest = validateMaterialManifestIdentityAny(value),
658
+ key = identity(manifest);
659
+ if (active.has(key)) throw new Error('Material dependency cycle');
660
+ const existing = manifests.get(key);
661
+ if (existing) {
662
+ if (canonicalMaterialJson(existing) !== canonicalMaterialJson(manifest))
663
+ throw new Error('Conflicting material dependency identity');
664
+ return;
665
+ }
666
+ manifests.set(key, structuredClone(manifest));
667
+ active.add(key);
668
+ for (const map of manifest.maps) {
669
+ bytes += map.bytes;
670
+ pixels += map.width * map.height;
671
+ }
672
+ if (manifests.size > limits.maxRecords || bytes > limits.maxBytes || pixels > limits.maxPixels)
673
+ throw new Error('Material closure count/byte/pixel limit exceeded');
674
+ for (const pin of materialRecordDependencyPins(manifest)) {
675
+ const child = await reader.readManifest(pin.resourceId, pin.revisionId);
676
+ if (
677
+ child.materialId !== pin.resourceId ||
678
+ child.revisionId !== pin.revisionId ||
679
+ pin.sha256 !== pin.revisionId
680
+ )
681
+ throw new Error('Material dependency pin mismatch');
682
+ await visit(child);
683
+ }
684
+ active.delete(key);
685
+ };
686
+ for (const root of roots) await visit(root);
687
+ const records: MaterialRecord[] = [];
688
+ for (const [key, manifest] of manifests) {
689
+ const record = snapshotAny(await reader.read(manifest.materialId, manifest.revisionId));
690
+ if (
691
+ identity(record.manifest) !== key ||
692
+ canonicalMaterialJson(record.manifest) !== canonicalMaterialJson(manifest)
693
+ )
694
+ throw new Error('Material closure manifest changed during read');
695
+ validateMaterialManifestIdentityAny(record.manifest);
696
+ for (const map of record.manifest.maps)
697
+ if (digest(record.files[map.file]!) !== map.sha256)
698
+ throw new Error('Material integrity hash mismatch');
699
+ records.push(record);
700
+ }
701
+ for (const record of records) await verifyMaterialRecordContents(record, { materials: records });
702
+ return records;
703
+ }
704
+ function recordClosureReader(
705
+ records: readonly MaterialRecord[],
706
+ fallback?: FileMaterialLibrary,
707
+ ): MaterialRecordClosureReader {
708
+ const byId = new Map<string, MaterialRecord>();
709
+ const duplicates = new Map<string, MaterialRecord[]>();
710
+ for (const record of records) {
711
+ const key = identity(record.manifest),
712
+ existing = byId.get(key);
713
+ if (
714
+ existing &&
715
+ canonicalMaterialJson(existing.manifest) !== canonicalMaterialJson(record.manifest)
716
+ )
717
+ throw new Error('Conflicting material record identity');
718
+ if (!existing) byId.set(key, record);
719
+ else if (existing !== record) duplicates.set(key, [...(duplicates.get(key) ?? []), record]);
720
+ }
721
+ return {
722
+ async readManifest(id, revision) {
723
+ const record = byId.get(`${id}/${revision}`);
724
+ if (record) return record.manifest;
725
+ if (fallback) return fallback.readManifest(id, revision);
726
+ throw new Error(`Missing pinned material revision: ${id} at ${revision}`);
727
+ },
728
+ async read(id, revision) {
729
+ const key = `${id}/${revision}`,
730
+ record = byId.get(key);
731
+ if (record) {
732
+ // Deferred until the complete metadata graph is admitted, before dedup reuse.
733
+ for (const candidate of [record, ...(duplicates.get(key) ?? [])]) {
734
+ const manifest = validateMaterialRecordShapeAny(candidate);
735
+ if (canonicalMaterialJson(manifest) !== canonicalMaterialJson(record.manifest))
736
+ throw new Error('Conflicting material record identity');
737
+ for (const map of manifest.maps)
738
+ if (digest(candidate.files[map.file]!) !== map.sha256)
739
+ throw new Error('Conflicting material record encoded integrity hash');
740
+ }
741
+ return record;
742
+ }
743
+ if (fallback) return fallback.readRecordFiles(id, revision);
744
+ throw new Error(`Missing pinned material revision: ${id} at ${revision}`);
745
+ },
746
+ };
747
+ }
748
+
749
+ /** The input closure is admitted and encoded-verified before this capability is constructed. */
750
+ function createPinnedMaterialMapPort(records: readonly MaterialRecord[]): PinnedMaterialMapPortV1 {
751
+ const entries = new Map<string, { record: MaterialRecord; map: MaterialMapV1 | MaterialMapV2 }>();
752
+ for (const record of records) {
753
+ validateMaterialRecordShapeAny(record);
754
+ validateMaterialManifestIdentityAny(record.manifest);
755
+ for (const map of record.manifest.maps) {
756
+ if (digest(record.files[map.file]!) !== map.sha256)
757
+ throw new Error('Material integrity hash mismatch');
758
+ entries.set(
759
+ `${identity(record.manifest)}/${materialLibraryResourceId(record.manifest, map.slot)}`,
760
+ { record, map },
761
+ );
762
+ }
763
+ }
764
+ const find = (resourceId: unknown, pin: MaterialMapPinV1) => {
765
+ if (typeof resourceId !== 'string' || pin.sha256 !== pin.revisionId) return undefined;
766
+ return entries.get(`${pin.resourceId}/${pin.revisionId}/${resourceId}`);
767
+ };
768
+ const describe = (
769
+ resourceId: unknown,
770
+ pin: MaterialMapPinV1,
771
+ ): VerifiedPinnedMaterialMapDescriptorV1 | undefined => {
772
+ const entry = find(resourceId, pin);
773
+ if (!entry) return undefined;
774
+ const { record, map } = entry;
775
+ return {
776
+ materialId: record.manifest.materialId,
777
+ revisionId: record.manifest.revisionId as `sha256:${string}`,
778
+ resourceId: resourceId as string,
779
+ slot: map.slot,
780
+ usage: map.usage,
781
+ colorSpace: map.colorSpace,
782
+ width: map.width,
783
+ height: map.height,
784
+ encodedByteLength: map.bytes,
785
+ imageSha256: map.sha256 as `sha256:${string}`,
786
+ ...('pixelHash' in map ? { decodedPixelHash: map.pixelHash as `sha256:${string}` } : {}),
787
+ };
788
+ };
789
+ return {
790
+ describePinnedMaterialMap: describe,
791
+ async resolvePinnedMaterialMapPixels(resourceId, pin, context) {
792
+ context?.signal?.throwIfAborted();
793
+ const descriptor = describe(resourceId, pin),
794
+ entry = find(resourceId, pin);
795
+ if (!descriptor || !entry)
796
+ throw new Error(
797
+ 'Missing verified pinned-library map; approved resources cannot be atlas sources',
798
+ );
799
+ const { data, info } = await sharp(entry.record.files[entry.map.file]!, {
800
+ limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
801
+ failOn: 'warning',
802
+ })
803
+ .ensureAlpha()
804
+ .raw()
805
+ .toBuffer({ resolveWithObject: true });
806
+ context?.signal?.throwIfAborted();
807
+ if (
808
+ info.width !== descriptor.width ||
809
+ info.height !== descriptor.height ||
810
+ info.channels !== 4
811
+ )
812
+ throw new Error('Pinned material decoded dimensions mismatch');
813
+ const decodedPixelHash = digest(data);
814
+ if (descriptor.decodedPixelHash && descriptor.decodedPixelHash !== decodedPixelHash)
815
+ throw new Error('Pinned material pixel hash mismatch');
816
+ return { descriptor, pixels: Uint8Array.from(data), decodedPixelHash };
817
+ },
818
+ };
819
+ }
820
+
821
+ async function verifyMaterialRecordContents(
822
+ record: MaterialRecord,
823
+ options?: { materials?: readonly MaterialRecord[] },
824
+ ): Promise<void> {
825
+ if (record.manifest.schemaVersion === 1)
826
+ return verifyMaterialRecordV1(record as MaterialRecordV1);
827
+ const manifest = validateMaterialRecordShapeAny(record) as MaterialManifestV2;
828
+ validateMaterialManifestIdentityAny(manifest);
829
+ const pins = new Set(
830
+ materialRecordDependencyPins(manifest).map((pin) => `${pin.resourceId}/${pin.revisionId}`),
831
+ );
832
+ pins.add(identity(manifest));
833
+ const resources = createPinnedMaterialMapPort(
834
+ (options?.materials ?? [record]).filter((candidate) => pins.has(identity(candidate.manifest))),
835
+ );
836
+ admitMaterialManifestRecipes(manifest, resources);
837
+ for (const map of manifest.maps) {
838
+ const bytes = record.files[map.file]!;
839
+ if (digest(bytes) !== map.sha256) throw new Error('Material integrity hash mismatch');
840
+ const { data, info } = await sharp(bytes, {
841
+ limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
842
+ failOn: 'warning',
843
+ })
844
+ .ensureAlpha()
845
+ .raw()
846
+ .toBuffer({ resolveWithObject: true });
847
+ if (
848
+ info.width !== map.width ||
849
+ info.height !== map.height ||
850
+ info.channels !== 4 ||
851
+ digest(data) !== map.pixelHash
852
+ )
853
+ throw new Error('Material decoded pixel hash/dimensions mismatch');
854
+ const recipe = map.recipe;
855
+ if (!recipe) continue;
856
+ if (recipe.kind === 'atlas') {
857
+ const compiled = await compileTextureAtlasSpecV1(recipe.spec, { resources });
858
+ if (
859
+ canonicalMaterialJson(compiled.manifest) !== canonicalMaterialJson(recipe.manifest) ||
860
+ digest(compiled.pixels) !== map.pixelHash
861
+ )
862
+ throw new Error('Atlas recipe/manifest/pixel hash mismatch');
863
+ } else {
864
+ const compiled =
865
+ recipe.spec.schemaVersion === 3
866
+ ? compileProceduralTextureSpecV3(recipe.spec)
867
+ : materialPixels(recipe.spec, recipe.derive);
868
+ if (compiled.recipeHash !== recipe.recipeHash || digest(compiled.pixels) !== recipe.pixelHash)
869
+ throw new Error('Procedural recipe/pixel hash mismatch');
870
+ }
871
+ }
872
+ }
873
+
874
+ /** Verify the complete immutable dependency closure; admission precedes byte snapshots and PNG decode. */
875
+ export async function verifyMaterialRecord(
876
+ record: MaterialRecord,
877
+ options?: { materials?: readonly MaterialRecord[] },
878
+ ): Promise<void> {
879
+ if (record.manifest.schemaVersion === 1)
880
+ return verifyMaterialRecordV1(record as MaterialRecordV1);
881
+ await resolveMaterialRecordClosure(
882
+ [record.manifest],
883
+ recordClosureReader([record, ...(options?.materials ?? [])]),
884
+ );
885
+ }
886
+ function admitMaterialManifestRecipes(
887
+ manifest: MaterialManifestV2,
888
+ resources: PinnedMaterialMapPortV1,
889
+ ): void {
890
+ let texels = 0,
891
+ work = 0,
892
+ allocation = 0,
893
+ encoded = 0;
894
+ for (const map of manifest.maps) {
895
+ encoded += map.bytes;
896
+ const recipe = map.recipe;
897
+ if (recipe?.kind === 'procedural') {
898
+ if (recipe.spec.schemaVersion === 3) {
899
+ const a = admitProceduralTextureSpecV3(recipe.spec);
900
+ texels += a.spec.width * a.spec.height;
901
+ work += a.estimatedWorkUnits;
902
+ allocation += a.conservativePixelAllocationBytes;
903
+ } else {
904
+ texels += recipe.spec.size ** 2;
905
+ work += recipe.spec.size ** 2 * recipe.spec.layers.length * 16;
906
+ allocation += recipe.spec.size ** 2 * 12;
907
+ }
908
+ } else if (recipe?.kind === 'atlas') {
909
+ const a = admitTextureAtlasSpecV1(recipe.spec, { resources });
910
+ texels += a.spec.width * a.spec.height + a.budget.sourceTexels;
911
+ work += a.budget.workUnits;
912
+ allocation += a.budget.conservativePixelAllocationBytes;
913
+ encoded += a.budget.encodedBytes;
914
+ } else {
915
+ texels += map.width * map.height;
916
+ work += map.width * map.height * 12 + map.bytes * 8;
917
+ allocation += map.width * map.height * 12 + map.bytes * 2;
918
+ }
919
+ }
920
+ if (
921
+ texels > PORTABLE_MATERIAL_V3_LIMITS.maxTexels ||
922
+ work > PORTABLE_MATERIAL_V3_LIMITS.maxWork ||
923
+ allocation > PORTABLE_MATERIAL_V3_LIMITS.maxTransientBytes ||
924
+ encoded > PORTABLE_MATERIAL_V3_LIMITS.maxEncodedBytes
925
+ )
926
+ throw new Error('Material recipe aggregate texel/work/allocation/encoded budget exceeded');
927
+ }
928
+
929
+ export function validateMaterialLibraryPayloadAny(value: unknown): MaterialLibraryPayload {
930
+ assertMaterialJson(value);
931
+ if (!value || typeof value !== 'object' || Array.isArray(value))
932
+ throw new Error('Invalid material resource payload');
933
+ if ((value as Record<string, unknown>)['schemaVersion'] === 1)
934
+ return validateMaterialLibraryPayload(value);
935
+ const payload = value as MaterialLibraryPayload;
936
+ if (
937
+ Object.keys(payload).some((k) => !['schemaVersion', 'records'].includes(k)) ||
938
+ payload.schemaVersion !== 2 ||
939
+ !Array.isArray(payload.records) ||
940
+ payload.records.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords
941
+ )
942
+ throw new Error('Invalid material resource payload');
943
+ let bytes = 0,
944
+ pixels = 0;
945
+ const seen = new Set<string>();
946
+ for (const wire of payload.records) {
947
+ if (
948
+ !wire ||
949
+ typeof wire !== 'object' ||
950
+ Object.keys(wire).some((k) => !['manifest', 'files'].includes(k))
951
+ )
952
+ throw new Error('Invalid material resource record');
953
+ const manifest = validateMaterialManifestIdentityAny(wire.manifest),
954
+ key = identity(manifest);
955
+ if (seen.has(key)) throw new Error('Duplicate material resource revision');
956
+ seen.add(key);
957
+ if (
958
+ !wire.files ||
959
+ typeof wire.files !== 'object' ||
960
+ Array.isArray(wire.files) ||
961
+ Object.keys(wire.files).length !== manifest.maps.length
962
+ )
963
+ throw new Error('Material payload inventory mismatch');
964
+ for (const map of manifest.maps) {
965
+ const encoded = wire.files[map.file];
966
+ if (
967
+ typeof encoded !== 'string' ||
968
+ encoded.length !== Math.ceil(map.bytes / 3) * 4 ||
969
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(encoded)
970
+ )
971
+ throw new Error('Invalid material map base64');
972
+ bytes += map.bytes;
973
+ pixels += map.width * map.height;
974
+ }
975
+ }
976
+ if (
977
+ bytes > MATERIAL_LIBRARY_LIMITS.maxPayloadBytes ||
978
+ pixels > MATERIAL_LIBRARY_LIMITS.maxPayloadPixels
979
+ )
980
+ throw new Error('Material resource payload exceeds byte/pixel budget');
981
+ return payload;
982
+ }
983
+ export function createMaterialLibraryPayload(
984
+ records: MaterialRecordV1[],
985
+ ): Promise<MaterialLibraryPayloadV1>;
986
+ export function createMaterialLibraryPayload(
987
+ records: MaterialRecord[],
988
+ ): Promise<MaterialLibraryPayload>;
989
+ export async function createMaterialLibraryPayload(
990
+ records: MaterialRecord[],
991
+ ): Promise<MaterialLibraryPayload> {
992
+ if (records.every((r) => r.manifest.schemaVersion === 1))
993
+ return createMaterialLibraryPayloadV1(records as MaterialRecordV1[]);
994
+ // Admit metadata before snapshots, encoding or recipe/pixel compilation.
995
+ let bytes = 0,
996
+ pixels = 0;
997
+ if (records.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
998
+ throw new Error('Material payload record limit exceeded');
999
+ for (const record of records)
1000
+ for (const map of validateMaterialManifestIdentityAny(record.manifest).maps) {
1001
+ bytes += map.bytes;
1002
+ pixels += map.width * map.height;
1003
+ }
1004
+ if (
1005
+ bytes > MATERIAL_LIBRARY_LIMITS.maxPayloadBytes ||
1006
+ pixels > MATERIAL_LIBRARY_LIMITS.maxPayloadPixels
1007
+ )
1008
+ throw new Error('Material payload byte/pixel budget exceeded');
1009
+ const admitted = await resolveMaterialRecordClosure(
1010
+ records.map((r) => r.manifest),
1011
+ recordClosureReader(records),
1012
+ );
1013
+ const byIdentity = new Map(admitted.map((r) => [identity(r.manifest), r]));
1014
+ const snapshots = records.map((r) => byIdentity.get(identity(r.manifest))!);
1015
+ return validateMaterialLibraryPayloadAny({
1016
+ schemaVersion: 2,
1017
+ records: snapshots.map((r) => ({
1018
+ manifest: r.manifest,
1019
+ files: Object.fromEntries(
1020
+ Object.entries(r.files).map(([name, bytes]) => [
1021
+ name,
1022
+ Buffer.from(bytes).toString('base64'),
1023
+ ]),
1024
+ ),
1025
+ })),
1026
+ });
1027
+ }
1028
+ export async function decodeMaterialLibraryPayload(value: unknown): Promise<MaterialRecord[]> {
1029
+ const payload = validateMaterialLibraryPayloadAny(value);
1030
+ if (payload.schemaVersion === 1) return decodeMaterialLibraryPayloadV1(payload);
1031
+ const wires = payload.records.map((w) => ({
1032
+ manifest: validateMaterialManifestIdentityAny(w.manifest),
1033
+ files: { ...w.files },
1034
+ }));
1035
+ const byIdentity = new Map(wires.map((w) => [identity(w.manifest), w]));
1036
+ const reader: MaterialRecordClosureReader = {
1037
+ async readManifest(id, revision) {
1038
+ const wire = byIdentity.get(`${id}/${revision}`);
1039
+ if (!wire) throw new Error('Missing pinned material revision');
1040
+ return wire.manifest;
1041
+ },
1042
+ async read(id, revision) {
1043
+ const wire = byIdentity.get(`${id}/${revision}`);
1044
+ if (!wire) throw new Error('Missing pinned material revision');
1045
+ return {
1046
+ manifest: wire.manifest,
1047
+ files: Object.fromEntries(
1048
+ Object.entries(wire.files).map(([name, encoded]) => [
1049
+ name,
1050
+ new Uint8Array(Buffer.from(encoded, 'base64')),
1051
+ ]),
1052
+ ),
1053
+ } as MaterialRecord;
1054
+ },
1055
+ };
1056
+ const admitted = await resolveMaterialRecordClosure(
1057
+ wires.map((w) => w.manifest),
1058
+ reader,
1059
+ );
1060
+ const result = new Map(admitted.map((r) => [identity(r.manifest), r]));
1061
+ return wires.map((w) => result.get(identity(w.manifest))!);
1062
+ }
1063
+
1064
+ export async function createMaterialRecordV2(
1065
+ draft: unknown,
1066
+ options?: { materials?: readonly MaterialRecord[] },
1067
+ ): Promise<MaterialRecordV2> {
1068
+ assertMaterialJson(draft, true);
1069
+ if (!draft || typeof draft !== 'object' || Array.isArray(draft))
1070
+ throw new Error('Invalid material draft');
1071
+ exactKeys(draft, [
1072
+ 'schemaVersion',
1073
+ 'materialId',
1074
+ 'name',
1075
+ 'tags',
1076
+ 'tileable',
1077
+ 'physicalSizeMeters',
1078
+ 'parameters',
1079
+ 'sources',
1080
+ 'maps',
1081
+ ]);
1082
+ const input = draft as Record<string, unknown>;
1083
+ if (
1084
+ input['schemaVersion'] !== 2 ||
1085
+ !Array.isArray(input['maps']) ||
1086
+ input['maps'].length < 1 ||
1087
+ input['maps'].length > 5
1088
+ )
1089
+ throw new Error('V2 material requires schemaVersion2 and1..5 maps');
1090
+ const candidates = options?.materials ?? [];
1091
+ if (candidates.length > MATERIAL_LIBRARY_LIMITS.maxPayloadRecords)
1092
+ throw new Error('Material source closure count limit exceeded');
1093
+ const records = candidates.length
1094
+ ? await resolveMaterialRecordClosure(
1095
+ candidates.map((r) => r.manifest),
1096
+ recordClosureReader(candidates),
1097
+ )
1098
+ : [];
1099
+ const resources = createPinnedMaterialMapPort(records),
1100
+ prepared: {
1101
+ raw: Record<string, unknown>;
1102
+ slot: MaterialLibrarySlot;
1103
+ sampler: PortableSamplerV1;
1104
+ procedural?: unknown;
1105
+ atlas?: unknown;
1106
+ bytes?: Uint8Array;
1107
+ }[] = [];
1108
+ let texels = 0,
1109
+ work = 0,
1110
+ allocation = 0,
1111
+ encoded = 0;
1112
+ for (const value of input['maps']) {
1113
+ if (!value || typeof value !== 'object' || Array.isArray(value))
1114
+ throw new Error('Invalid material map draft');
1115
+ exactKeys(value, [
1116
+ 'slot',
1117
+ 'sourceId',
1118
+ 'transforms',
1119
+ 'normalConvention',
1120
+ 'channelPacking',
1121
+ 'sampler',
1122
+ 'bytes',
1123
+ 'originalFile',
1124
+ 'procedural',
1125
+ 'derive',
1126
+ 'atlas',
1127
+ ]);
1128
+ const raw = value as Record<string, unknown>,
1129
+ slot = raw['slot'] as MaterialLibrarySlot;
1130
+ if (!MATERIAL_LIBRARY_SLOTS.includes(slot)) throw new Error('Unsupported material map slot');
1131
+ const sampler = canonicalizePortableSamplerV1(raw['sampler']);
1132
+ if (['bytes', 'procedural', 'atlas'].filter((k) => raw[k] !== undefined).length !== 1)
1133
+ throw new Error('Supply exactly one map bytes/procedural/atlas source');
1134
+ if (raw['procedural'] !== undefined) {
1135
+ const spec = raw['procedural'] as { schemaVersion?: number };
1136
+ if (spec.schemaVersion === 3) {
1137
+ if (raw['derive'] !== undefined) throw new Error('V3 normal derivation is unsupported');
1138
+ const a = admitProceduralTextureSpecV3(spec);
1139
+ if (a.spec.usage !== MATERIAL_LIBRARY_USAGE[slot])
1140
+ throw new Error('Procedural usage mismatch');
1141
+ texels += a.spec.width * a.spec.height;
1142
+ work += a.estimatedWorkUnits;
1143
+ allocation += a.conservativePixelAllocationBytes;
1144
+ prepared.push({ raw, slot, sampler, procedural: a.spec });
1145
+ } else {
1146
+ const canonical = canonicalizeProceduralTextureSpecV2(spec);
1147
+ if (canonical.usage !== MATERIAL_LIBRARY_USAGE[slot])
1148
+ throw new Error('Procedural usage mismatch');
1149
+ texels += canonical.size ** 2;
1150
+ work += canonical.size ** 2 * canonical.layers.length * 16;
1151
+ allocation += canonical.size ** 2 * 12;
1152
+ prepared.push({ raw, slot, sampler, procedural: canonical });
1153
+ }
1154
+ } else if (raw['atlas'] !== undefined) {
1155
+ if (raw['derive'] !== undefined) throw new Error('Atlas derivation unsupported');
1156
+ const a = admitTextureAtlasSpecV1(raw['atlas'], { resources });
1157
+ if (
1158
+ a.spec.usage !== MATERIAL_LIBRARY_USAGE[slot] ||
1159
+ canonicalMaterialJson(a.spec.sampler) !== canonicalMaterialJson(sampler)
1160
+ )
1161
+ throw new Error('Atlas usage/sampler mismatch');
1162
+ texels += a.spec.width * a.spec.height + a.budget.sourceTexels;
1163
+ work += a.budget.workUnits;
1164
+ allocation += a.budget.conservativePixelAllocationBytes;
1165
+ encoded += a.budget.encodedBytes;
1166
+ prepared.push({ raw, slot, sampler, atlas: a.spec });
1167
+ } else {
1168
+ if (raw['derive'] !== undefined || !(raw['bytes'] instanceof Uint8Array))
1169
+ throw new Error('Invalid external map bytes/derivation');
1170
+ const bytes = raw['bytes'];
1171
+ if (bytes.length > MATERIAL_LIBRARY_LIMITS.maxMapBytes)
1172
+ throw new Error('Material map encoded byte limit');
1173
+ const d = materialPngDimensions(bytes);
1174
+ if (d.width < 1 || d.height < 1 || d.width > 4096 || d.height > 4096)
1175
+ throw new Error('Material map dimensions limit');
1176
+ texels += d.width * d.height;
1177
+ encoded += bytes.length;
1178
+ work += d.width * d.height * 12 + bytes.length * 8;
1179
+ allocation += d.width * d.height * 12 + bytes.length * 2;
1180
+ prepared.push({ raw, slot, sampler, bytes });
1181
+ }
1182
+ }
1183
+ if (
1184
+ texels > 4 * 1024 * 1024 ||
1185
+ work > PORTABLE_MATERIAL_V3_LIMITS.maxWork ||
1186
+ allocation > 64 * 1024 * 1024 ||
1187
+ encoded > 16 * 1024 * 1024
1188
+ )
1189
+ throw new Error('Material aggregate texel/work/allocation limit exceeded');
1190
+ const maps: MaterialMapV2[] = [],
1191
+ files: Record<string, Uint8Array> = {},
1192
+ pins = new Map<string, MaterialMapPinV1>();
1193
+ for (const item of prepared) {
1194
+ const { raw, slot, sampler } = item;
1195
+ let bytes: Uint8Array, pixelHash: `sha256:${string}`, recipe: MaterialMapV2['recipe'];
1196
+ if (item.procedural !== undefined) {
1197
+ const spec = item.procedural as ProceduralTextureSpecV2 | { schemaVersion: 3 };
1198
+ const compiled =
1199
+ spec.schemaVersion === 3
1200
+ ? compileProceduralTextureSpecV3(spec)
1201
+ : materialPixels(
1202
+ spec,
1203
+ raw['derive'] as { kind: 'normal-from-height'; strength: number } | undefined,
1204
+ );
1205
+ const d =
1206
+ compiled.spec.schemaVersion === 3
1207
+ ? { width: compiled.spec.width, height: compiled.spec.height }
1208
+ : { width: compiled.spec.size, height: compiled.spec.size };
1209
+ pixelHash = digest(compiled.pixels);
1210
+ bytes = new Uint8Array(
1211
+ await sharp(compiled.pixels, { raw: { ...d, channels: 4 } })
1212
+ .png({ compressionLevel: 9, adaptiveFiltering: false })
1213
+ .toBuffer(),
1214
+ );
1215
+ recipe = {
1216
+ kind: 'procedural',
1217
+ compilerId:
1218
+ compiled.spec.schemaVersion === 3
1219
+ ? PROCEDURAL_TEXTURE_V3_COMPILER_ID
1220
+ : 'kiln.procedural-texture.v2',
1221
+ spec: compiled.spec,
1222
+ recipeHash: compiled.recipeHash,
1223
+ pixelHash,
1224
+ encoder,
1225
+ ...(raw['derive']
1226
+ ? {
1227
+ derive: structuredClone(raw['derive']) as {
1228
+ kind: 'normal-from-height';
1229
+ strength: number;
1230
+ },
1231
+ }
1232
+ : {}),
1233
+ };
1234
+ } else if (item.atlas !== undefined) {
1235
+ const compiled = await compileTextureAtlasSpecV1(item.atlas, { resources });
1236
+ pixelHash = compiled.manifest.decodedPixelHash;
1237
+ bytes = new Uint8Array(
1238
+ await sharp(compiled.pixels, {
1239
+ raw: { width: compiled.spec.width, height: compiled.spec.height, channels: 4 },
1240
+ })
1241
+ .png({ compressionLevel: 9, adaptiveFiltering: false })
1242
+ .toBuffer(),
1243
+ );
1244
+ recipe = {
1245
+ kind: 'atlas',
1246
+ compilerId: TEXTURE_ATLAS_COMPILER_ID,
1247
+ spec: compiled.spec,
1248
+ manifest: compiled.manifest,
1249
+ encoder,
1250
+ };
1251
+ for (const r of compiled.spec.regions)
1252
+ if (r.source.kind === 'resource')
1253
+ pins.set(`${r.source.pin.resourceId}/${r.source.pin.revisionId}`, r.source.pin);
1254
+ } else {
1255
+ bytes = item.bytes!.slice();
1256
+ const decoded = await sharp(bytes, {
1257
+ limitInputPixels: MATERIAL_LIBRARY_LIMITS.maxRecordPixels,
1258
+ failOn: 'warning',
1259
+ })
1260
+ .ensureAlpha()
1261
+ .raw()
1262
+ .toBuffer();
1263
+ pixelHash = digest(decoded);
1264
+ }
1265
+ const d = materialPngDimensions(bytes),
1266
+ file = `${slot}.png` as MaterialMapV2['file'];
1267
+ if (files[file]) throw new Error('Duplicate material map slot');
1268
+ files[file] = bytes;
1269
+ maps.push({
1270
+ slot,
1271
+ file,
1272
+ ...d,
1273
+ sha256: digest(bytes),
1274
+ bytes: bytes.length,
1275
+ usage: MATERIAL_LIBRARY_USAGE[slot],
1276
+ colorSpace: slot === 'baseColor' || slot === 'emissive' ? 'srgb' : 'linear',
1277
+ sampler,
1278
+ pixelHash,
1279
+ sourceId: raw['sourceId'] as string,
1280
+ transforms: structuredClone(raw['transforms']) as MaterialTransformV1[],
1281
+ ...(slot === 'normal'
1282
+ ? {
1283
+ normalConvention: (raw['normalConvention'] ??
1284
+ (recipe ? 'opengl' : undefined)) as 'opengl',
1285
+ }
1286
+ : {}),
1287
+ ...(slot === 'metallicRoughness'
1288
+ ? {
1289
+ channelPacking: (raw['channelPacking'] ??
1290
+ (recipe
1291
+ ? 'r-occlusion-g-roughness-b-metallic'
1292
+ : undefined)) as 'r-occlusion-g-roughness-b-metallic',
1293
+ }
1294
+ : {}),
1295
+ ...(raw['originalFile'] ? { originalFile: raw['originalFile'] as string } : {}),
1296
+ ...(recipe ? { recipe } : {}),
1297
+ });
1298
+ }
1299
+ const manifest = materialManifestV2Schema.parse({
1300
+ ...input,
1301
+ schemaVersion: 2,
1302
+ revisionId: `sha256:${'0'.repeat(64)}`,
1303
+ tags: input['tags'] ?? [],
1304
+ parameters: input['parameters'] ?? {},
1305
+ maps,
1306
+ dependencies: [...pins.values()],
1307
+ });
1308
+ manifest.revisionId = revisionHash(manifest);
1309
+ const record = { manifest, files };
1310
+ await verifyMaterialRecord(record, { materials: [...records, record] });
1311
+ return record;
1312
+ }