@needle-tools/gltf-build-pipeline 2.14.0 → 2.15.0-next.0c5bc39

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.
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
2
3
  import { ExtensibleProperty } from "@gltf-transform/core";
3
4
  import { Logger } from "@donmccurdy/caporal";
4
5
  /**
@@ -5,9 +5,9 @@ import path from 'path';
5
5
  import { getVersion } from "../utils/version.js";
6
6
  // import * as CHECKDISCSPACE from 'check-disk-space'
7
7
  import xxhash from "xxhash-wasm";
8
- let hasher = null;
8
+ let xxHashModule = null;
9
9
  xxhash().then(module => {
10
- hasher = module.create32();
10
+ xxHashModule = module;
11
11
  });
12
12
  /**
13
13
  * Get the available space on the cache directory in bytes.
@@ -304,9 +304,9 @@ function hashObject(obj, depth = 0) {
304
304
  }
305
305
  }
306
306
  function hashBuffer(buffer, offset, length) {
307
- if (hasher) {
307
+ if (xxHashModule) {
308
308
  const view = new Uint8Array(buffer, offset, length);
309
- return hasher.update(view).digest();
309
+ return xxHashModule.h32Raw(view);
310
310
  }
311
311
  let hash = 0;
312
312
  const view = new Uint8Array(buffer, offset, length);
package/dist/cli/index.js CHANGED
File without changes
@@ -1,6 +1,8 @@
1
1
  import { Extension, ExtensionProperty, PropertyType, Node, TextureChannel } from '@gltf-transform/core';
2
2
  import { getTexture, tryGetInArray, writeExtension } from '../utils.js';
3
3
  import { isLightmap } from '../../utils/texture.js';
4
+ import { NEEDLE_progressive_texture_settings } from '../NEEDLE_progressive_texture_settings/index.js';
5
+ import { testAssert } from '../../utils/test-assert.js';
4
6
  const ALL_PROPERTY_TYPES = [];
5
7
  for (const key in PropertyType) {
6
8
  ALL_PROPERTY_TYPES.push(PropertyType[key]);
@@ -123,8 +125,11 @@ export function createOpaqueExtension(name, types, opts) {
123
125
  const prop = this.createExtension();
124
126
  this.jsonPointerHandler.read(context, texture, ext, prop);
125
127
  texture.setExtension(EXTENSION_NAME, prop);
126
- const handler = new TextureHandler(this.document, textureDefinition, EXTENSION_NAME, ext, debugLog);
128
+ const handler = new TextureHandler(this.document, textureDefinition, EXTENSION_NAME, ext, debugLog, texture);
127
129
  prop.add(handler);
130
+ const sourceIndex = textureDefinition.source;
131
+ if (debugLog)
132
+ console.log("Found texture with extension", EXTENSION_NAME, "at index", index, "source image index", sourceIndex, "name", texture.getName() || "<unnamed>");
128
133
  });
129
134
  break;
130
135
  case PropertyType.NODE:
@@ -188,11 +193,11 @@ export function createOpaqueExtension(name, types, opts) {
188
193
  const textures = this.document.getRoot().listTextures();
189
194
  for (let i = 0; i < textures.length; i++) {
190
195
  const tex = textures[i];
191
- const texDef = textureDefinitions[i];
192
- const name = texDef?.name;
193
196
  if (isLightmap(tex) && tex.getMimeType() !== "image/exr") {
194
197
  prop.setReference("lightmapTexture", tex, { channels: TextureChannel.R | TextureChannel.G | TextureChannel.B | TextureChannel.A });
195
- logger.info(`Found lightmap texture ${name}: ${tex.getMimeType()} in ${EXTENSION_NAME}`);
198
+ const texDef = textureDefinitions[i]; // @TODO: textureDefinitions and textures might not be in the same order - we need to find the texture by source index instead of assuming the order is the same
199
+ const name = tex.getName() || texDef?.name;
200
+ logger.info(`Found lightmap texture \"${name}\" (${tex.getMimeType()}) in ${EXTENSION_NAME}`);
196
201
  }
197
202
  }
198
203
  }
@@ -389,39 +394,48 @@ class GenericWriter {
389
394
  class TextureHandler {
390
395
  document;
391
396
  textureDefinition;
392
- index;
393
397
  key;
394
398
  ext;
395
399
  debug = false;
400
+ /** Reference to the gltf-transform Texture object, used to resolve the current image index at write time via imageIndexMap */
401
+ textureRef;
396
402
  assignTo(obj) {
397
403
  Object.assign(obj, this.ext);
398
404
  }
399
- constructor(doc, def, key, ext, debug) {
405
+ constructor(doc, def, key, ext, debug, textureRef) {
400
406
  this.document = doc;
401
407
  this.textureDefinition = def;
402
408
  this.key = key;
403
409
  this.ext = ext;
404
410
  this.debug = debug;
405
- this.index = this.tryGetSourceIndex(this.textureDefinition);
406
- if (debug)
407
- console.log(def);
411
+ this.textureRef = textureRef;
412
+ if (debug) {
413
+ const index = this.tryGetSourceIndex(this.textureDefinition);
414
+ console.log(index, this.textureDefinition);
415
+ }
408
416
  }
409
417
  write(ctx) {
410
- let foundTexture = false;
411
- const textureDefs = ctx.jsonDoc.json.textures || [];
412
- const imageIndex = this.index !== undefined ? this.index : this.tryGetSourceIndex(this.textureDefinition);
418
+ // Resolve the current image index from the gltf-transform graph reference.
419
+ // This is stable even after dedup/prune operations that re-index images.
420
+ const imageIndex = ctx.imageIndexMap.get(this.textureRef);
421
+ testAssert(imageIndex !== undefined || this.key === "EXT_texture_exr", `imageIndexMap missing texture ref for "${this.key}": ${this.textureRef.getName() || "<unnamed>"}`);
413
422
  if (imageIndex === undefined) {
414
- console.warn("WARN: no source index found for texture", this.key);
423
+ if (this.debug) {
424
+ console.warn("WARN: failed to resolve image index for texture via imageIndexMap:", this.key, this.textureRef.getName() || "<unnamed>");
425
+ }
426
+ return;
415
427
  }
428
+ let foundTexture = false;
429
+ const textureDefs = ctx.jsonDoc.json.textures || [];
416
430
  textureDefs?.forEach((textureDef, i) => {
417
431
  if (foundTexture)
418
432
  return;
419
433
  const textureImageIndex = this.tryGetSourceIndex(textureDef);
420
434
  if (imageIndex === textureImageIndex) {
421
- if (!textureDef.extensions || !textureDef.extensions[this.key]) {
422
- // assign texture extension again
435
+ foundTexture = true;
436
+ if (!textureDef.extensions?.[this.key]) {
437
+ // assign texture extension
423
438
  textureDef.extensions = textureDef.extensions || {};
424
- foundTexture = true;
425
439
  if (this.debug)
426
440
  console.log("Re-Assign extension:", this.key, "at", i);
427
441
  const newExt = { ...this.ext };
@@ -429,20 +443,16 @@ class TextureHandler {
429
443
  newExt.source = imageIndex;
430
444
  textureDef.extensions[this.key] = newExt;
431
445
  }
446
+ // else: extension already written by another handler for the same image
447
+ // (happens when gltf-transform dedup merges texture defs that shared the same source)
432
448
  }
433
449
  });
450
+ testAssert(foundTexture || this.key === "EXT_texture_exr", `failed to match texture for extension "${this.key}", imageIndex=${imageIndex}, textures=${textureDefs.length}, name=${this.textureRef.getName() || "<unnamed>"}, type=${this.textureRef.getMimeType()}`);
434
451
  if (foundTexture === false) {
435
- if (this.key !== "EXT_texture_exr" && this.debug)
436
- console.warn("WARN: failed to re-assign extension:", this.key, this.index, this.tryGetSourceIndex(this.textureDefinition), textureDefs.length);
437
- }
438
- // this.document
439
- // .getRoot()
440
- // .listTextures()
441
- // .forEach((texture, index) => {
442
- // if (foundTexture) return;
443
- // if (!texture.getExtension(this.key)) return;
444
- // const imageIndex = ctx.imageIndexMap.get(texture);
445
- // });
452
+ if (this.debug) {
453
+ console.warn("WARN: failed to re-assign extension:", this.key, "imageIndex", imageIndex, textureDefs.length, this.textureRef.getName() || "<unnamed>", this.textureDefinition);
454
+ }
455
+ }
446
456
  }
447
457
  tryGetSourceIndex(def) {
448
458
  let index = def.source;
@@ -857,7 +867,8 @@ class TextureIndexResolver extends PointerResolver {
857
867
  resolve(context) {
858
868
  const imgIndex = context.imageIndexMap.get(this.inputObject);
859
869
  if (imgIndex === undefined) {
860
- console.error("ERR: image is missing", imgIndex);
870
+ testAssert(false, `imageIndexMap missing texture: ${this.inputObject.getName() || "<unnamed>"}`);
871
+ console.error("ERR: image is missing");
861
872
  return -1;
862
873
  }
863
874
  // ensure we have a texture array
@@ -878,11 +889,18 @@ class TextureIndexResolver extends PointerResolver {
878
889
  // create a new texture def entry
879
890
  if (this.debug)
880
891
  console.log("Could not find texture, creating a new one: " + this._debugName);
881
- const hasExtensionWithSource = this.inputDefinition.extensions && Object.keys(this.inputDefinition.extensions).some(key => this.inputDefinition.extensions[key]["source"] !== undefined);
892
+ // Remove settings-only extensions that are consumed during processing and should not appear in the output.
893
+ let extensions = this.inputDefinition.extensions ? { ...this.inputDefinition.extensions } : undefined;
894
+ if (extensions) {
895
+ delete extensions[NEEDLE_progressive_texture_settings.EXTENSION_NAME];
896
+ if (Object.keys(extensions).length === 0)
897
+ extensions = undefined;
898
+ }
899
+ const hasExtensionWithSource = extensions && Object.keys(extensions).some(key => extensions[key]?.["source"] !== undefined);
882
900
  const textureDef = {
883
901
  source: hasExtensionWithSource ? undefined : imgIndex,
884
902
  sampler: this.findOrCreateSampler(context.jsonDoc.json),
885
- extensions: this.inputDefinition.extensions,
903
+ extensions: extensions,
886
904
  extras: this.inputDefinition.extras,
887
905
  };
888
906
  context.jsonDoc.json.textures.push(textureDef);
@@ -9,6 +9,7 @@ import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE
9
9
  import { isLOD, isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
10
  import { getOutputPath, getVersion, ioTryReadWithMissingResources, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
+ import { TestAssertionError } from '../utils/test-assert.js';
12
13
  /** Compress a glTF file
13
14
  */
14
15
  export async function packGLTF(inputFile, outputFile, options) {
@@ -64,8 +65,13 @@ export async function packGLTF(inputFile, outputFile, options) {
64
65
  'meshopt.encoder': MeshoptEncoder,
65
66
  });
66
67
  const document = await ioTryReadWithMissingResources(io, inputFile); // await io.read(inputFile);
67
- /** @ts-ignore */
68
- document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
68
+ if (options.logger) {
69
+ document.setLogger(options.logger);
70
+ }
71
+ else {
72
+ /** @ts-ignore */
73
+ document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
74
+ }
69
75
  let totalGPUTextureMemory = 0;
70
76
  /** @type {Array<import('@gltf-transform/core').Transform>} */
71
77
  const transforms = [
@@ -128,7 +134,10 @@ export async function packGLTF(inputFile, outputFile, options) {
128
134
  return true;
129
135
  }
130
136
  catch (err) {
131
- logger.error(`Packing of "${path.basename(inputFile)}" using version ${getVersion()} failed with error:\n${err.message}`);
137
+ if (err instanceof TestAssertionError)
138
+ throw err;
139
+ const shortedStackTrace = err instanceof Error && err.stack ? err.stack.split('\n').slice(0, 2).join('\n') : '';
140
+ logger.error(`Packing of "${path.basename(inputFile)}" using version ${getVersion()} failed with error:\n${err.message}\n${shortedStackTrace}`);
132
141
  if (debug)
133
142
  throw err;
134
143
  return false;
@@ -13,6 +13,7 @@ import { needle_asset, writeNeedleAsset } from './needle_asset.js';
13
13
  import { MeshoptDecoder, MeshoptEncoder, MeshoptSimplifier } from 'meshoptimizer';
14
14
  import { compressPrimitive, determineMeshCompression } from './needle_mesh_transform.js';
15
15
  import { calculateMeshDensity, getMeshInformation } from "../utils/mesh.js";
16
+ import { TestAssertionError } from '../utils/test-assert.js';
16
17
  export async function make_progressive(opts) {
17
18
  const file = opts.path;
18
19
  let success = true;
@@ -78,9 +79,14 @@ async function onProcess(file, opts) {
78
79
  });
79
80
  let document = await ioTryReadWithMissingResources(io, file); // await io.read(file);
80
81
  if (document) {
81
- const logger = document.getLogger();
82
- //@ts-ignore
83
- logger.verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
82
+ if (opts.logger) {
83
+ document.setLogger(opts.logger);
84
+ }
85
+ else {
86
+ //@ts-ignore
87
+ document.getLogger().verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
88
+ }
89
+ const logger = opts.logger || document.getLogger();
84
90
  let smallesTextureSize = 128;
85
91
  if (opts.config.usecase === "product") {
86
92
  smallesTextureSize = 512;
@@ -120,6 +126,8 @@ async function onProcess(file, opts) {
120
126
  }
121
127
  }
122
128
  catch (err) {
129
+ if (err instanceof TestAssertionError)
130
+ throw err;
123
131
  console.log("ERR: failed to process file \"" + file + "\", Reason: " + err.message, "\nStack:\n" + err.stack);
124
132
  return false;
125
133
  }
@@ -151,6 +159,7 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
151
159
  return Promise.resolve();
152
160
  }
153
161
  let index = 0;
162
+ const textureHashCache = new Map();
154
163
  for (const texture of doc.getRoot().listTextures()) {
155
164
  const textureIndex = index++;
156
165
  const name = texture.getName();
@@ -177,8 +186,10 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
177
186
  continue;
178
187
  }
179
188
  else {
189
+ if (!textureHashCache.has(texture))
190
+ textureHashCache.set(texture, getHash(texture));
180
191
  settings = {
181
- guid: generateGuid(filename + "_texture_" + index.toString()),
192
+ guid: generateGuid(textureHashCache.get(texture)),
182
193
  maxSize: _options.size[0],
183
194
  };
184
195
  logger.debug(`[${NAME}] No settings found for texture[${index}] - will use default settings (maxSize: ${settings.maxSize}, guid: ${settings.guid})`);
@@ -213,7 +224,13 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
213
224
  logger.debug(`[${NAME}] Skipping, texture[${textureIndex}] is already smaller than the requested size: ${currentSize}px <= ${settings.maxSize}px`);
214
225
  continue;
215
226
  }
216
- const textureGuid = settings.guid?.length ? settings.guid : generateGuid(filename + "_texture_" + index.toString());
227
+ if (!settings.guid?.length) {
228
+ if (!textureHashCache.has(texture))
229
+ textureHashCache.set(texture, getHash(texture));
230
+ const hash = textureHashCache.get(texture);
231
+ settings.guid = generateGuid(hash);
232
+ }
233
+ const textureGuid = settings.guid;
217
234
  /** This is the size of the texture that is embedded in the glTF. No LOD should be smaller than this */
218
235
  const maxSize = settings.maxSize;
219
236
  const LOD_LEVEL = new Array();
@@ -360,7 +377,7 @@ async function createTextureLod(args, opts) {
360
377
  // see https://linear.app/needle/issue/NE-4985
361
378
  newMaterial.setAlphaMode("BLEND");
362
379
  newMaterial.setBaseColorTexture(newTexture);
363
- continue;
380
+ break;
364
381
  }
365
382
  if (slot === "specularColorTexture") {
366
383
  logger.info("Pass-through KHR_materials_pbrSpecularGlossiness specularColorTexture");
@@ -368,7 +385,7 @@ async function createTextureLod(args, opts) {
368
385
  const prob = ext.createSpecular();
369
386
  prob.setSpecularColorTexture(newTexture);
370
387
  newMaterial.setExtension(KHRMaterialsSpecular.EXTENSION_NAME, prob);
371
- continue;
388
+ break;
372
389
  }
373
390
  else if (slot === "specularTexture") {
374
391
  logger.info("Pass-through KHR_materials_specular specularTexture");
@@ -376,7 +393,7 @@ async function createTextureLod(args, opts) {
376
393
  const prob = ext.createSpecular();
377
394
  prob.setSpecularTexture(newTexture);
378
395
  newMaterial.setExtension(KHRMaterialsSpecular.EXTENSION_NAME, prob);
379
- continue;
396
+ break;
380
397
  }
381
398
  logger.warn(`WARN: Unknown texture slot: ${slot} for texture ${textureIndex} → can not load progressively (all slots: ${slots.join(", ")})`);
382
399
  return null;
@@ -524,7 +541,7 @@ async function resizeTexture(options, texture, logger, uri, slots) {
524
541
  }
525
542
  dstWidth = Math.floor(dstWidth);
526
543
  dstHeight = Math.floor(dstHeight);
527
- logger.info(`→ Resize texture (${uri || texture.getName() || slots.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
544
+ logger.info(`→ Resize texture (${uri || texture.getName() || slots?.join(", ")}) from ${srcWidth}x${srcHeight} to ${dstWidth}x${dstHeight}px`);
528
545
  // https://sharp.pixelplumbing.com/api-resize
529
546
  const buffer = await sharp(texture.getImage())
530
547
  .resize(dstWidth, dstHeight, {
@@ -0,0 +1,8 @@
1
+ export declare class TestAssertionError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Lightweight assertion that throws under vitest and is a no-op in production.
6
+ * Use as a one-liner in pipeline code to catch regressions during tests.
7
+ */
8
+ export declare function testAssert(condition: boolean, message: string): void;
@@ -0,0 +1,18 @@
1
+ const IS_TEST = process.env.VITEST === 'true';
2
+ export class TestAssertionError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'TestAssertionError';
6
+ }
7
+ }
8
+ /**
9
+ * Lightweight assertion that throws under vitest and is a no-op in production.
10
+ * Use as a one-liner in pipeline code to catch regressions during tests.
11
+ */
12
+ export function testAssert(condition, message) {
13
+ if (condition)
14
+ return;
15
+ if (IS_TEST) {
16
+ throw new TestAssertionError(`Assertion failed: ${message}`);
17
+ }
18
+ }
@@ -1 +1 @@
1
- export declare const version = "2.14.0";
1
+ export declare const version = "2.15.0-next.0c5bc39";
@@ -1 +1 @@
1
- export const version = "2.14.0";
1
+ export const version = "2.15.0-next.0c5bc39";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.14.0",
3
+ "version": "2.15.0-next.0c5bc39",
4
4
  "description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -18,6 +18,12 @@
18
18
  "gltf-build-pipeline": "dist/cli/index.js",
19
19
  "needle-gltf": "dist/cli/index.js"
20
20
  },
21
+ "files": [
22
+ "dist",
23
+ "scripts",
24
+ "README.md",
25
+ "CHANGELOG.md"
26
+ ],
21
27
  "imports": {
22
28
  "#package.json": "./package.json"
23
29
  },
@@ -33,12 +39,18 @@
33
39
  "transform:make-progressive": "node scripts/make-progressive.mjs",
34
40
  "transform:pack-gltf": "node --no-experimental-fetch scripts/pack-gltf.mjs",
35
41
  "pack-gltf": "node --no-experimental-fetch scripts/pack-gltf.mjs",
36
- "dev:pack-gltf": "npm run compile && node scripts/pack-gltf.mjs"
42
+ "dev:pack-gltf": "npm run compile && node scripts/pack-gltf.mjs",
43
+ "test": "tsx tests/utils/download-fixtures.ts && vitest run",
44
+ "test:watch": "vitest",
45
+ "test:setup": "tsx tests/utils/download-fixtures.ts"
37
46
  },
38
47
  "devDependencies": {
39
- "@types/node": "^18.7.23",
48
+ "@types/node": "^22.19.15",
49
+ "gltf-validator": "^2.0.0-dev.3.9",
40
50
  "npm-watch": "^0.13.0",
41
- "typescript": "^4.9.5"
51
+ "tsx": "^4.19.0",
52
+ "typescript": "^4.9.5",
53
+ "vitest": "^3.1.1"
42
54
  },
43
55
  "optionalDependencies": {
44
56
  "@img/sharp-darwin-arm64": "^0.33.4",
package/tsconfig.json DELETED
@@ -1,20 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ESNext",
4
- "rootDir": "src",
5
- "outDir": "dist",
6
- "declaration": true,
7
- "moduleResolution": "NodeNext",
8
- "module": "NodeNext",
9
- "esModuleInterop": true,
10
- "skipLibCheck": true,
11
- "resolveJsonModule": true,
12
- "lib": [
13
- "ESNext",
14
- "DOM"
15
- ],
16
- },
17
- "include": [
18
- "src/index.ts",
19
- ]
20
- }