@needle-tools/gltf-build-pipeline 2.15.8 → 2.15.9-next.aba648c

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.
@@ -3,6 +3,8 @@ import { existsSync, readFileSync } from 'fs';
3
3
  import { join, dirname } from 'path';
4
4
  import { isLightmap } from '../../utils/texture.js';
5
5
  import { getExtensionInputFile } from '../index.js';
6
+ import { findSource, getTexture } from '../utils.js';
7
+ import { testAssert } from '../../utils/test-assert.js';
6
8
  const EXTENSION_NAME = 'NEEDLE_lightmaps';
7
9
  /**
8
10
  * Opaque-like ExtensionProperty that holds NEEDLE_lightmaps data on the Root.
@@ -66,7 +68,8 @@ export class NEEDLE_lightmaps_ext extends Extension {
66
68
  continue;
67
69
  const exrPath = inputDir ? join(inputDir, entry.pointer) : '';
68
70
  if (!exrPath || !existsSync(exrPath)) {
69
- this.document.getLogger().warn(`NEEDLE_lightmaps: External EXR not found: ${exrPath || entry.pointer}`);
71
+ this.document.getLogger().error(`NEEDLE_lightmaps: External EXR not found: ${exrPath || entry.pointer}`);
72
+ testAssert(false, `NEEDLE_lightmaps: External EXR not found: ${exrPath || entry.pointer}`);
70
73
  continue;
71
74
  }
72
75
  // Read EXR bytes and inject into JSON so the core reader creates a Texture
@@ -113,12 +116,7 @@ export class NEEDLE_lightmaps_ext extends Extension {
113
116
  const texDef = textureDefs[texIndex];
114
117
  if (texDef) {
115
118
  inputTextureDef = texDef;
116
- const imageIndex = texDef.source
117
- ?? texDef.extensions?.['EXT_texture_exr']?.source
118
- ?? texDef.extensions?.['KHR_texture_basisu']?.source;
119
- if (imageIndex !== undefined) {
120
- texture = context.textures[imageIndex] ?? null;
121
- }
119
+ texture = getTexture(context, texDef) ?? null;
122
120
  }
123
121
  }
124
122
  else if (entry.pointer.match(/\.exr$/i)) {
@@ -126,7 +124,19 @@ export class NEEDLE_lightmaps_ext extends Extension {
126
124
  const imageIndex = this.injectedImageIndices.get(entry.pointer);
127
125
  if (imageIndex !== undefined) {
128
126
  texture = context.textures[imageIndex] ?? null;
129
- inputTextureDef = textureDefs.find(t => t.source === imageIndex);
127
+ inputTextureDef = textureDefs.find(t => findSource(t) === imageIndex);
128
+ }
129
+ }
130
+ else if (!entry.pointer.startsWith('/')) {
131
+ // Already-processed external pointer (e.g. "sunrise.pmrem.ktx2")
132
+ // Find the texture by matching the URI in the existing images
133
+ const images = json.images ?? [];
134
+ for (let i = 0; i < images.length; i++) {
135
+ if (images[i].uri === entry.pointer) {
136
+ texture = context.textures[i] ?? null;
137
+ inputTextureDef = textureDefs.find(t => findSource(t) === i);
138
+ break;
139
+ }
130
140
  }
131
141
  }
132
142
  if (texture) {
@@ -141,7 +151,8 @@ export class NEEDLE_lightmaps_ext extends Extension {
141
151
  }
142
152
  }
143
153
  else {
144
- logger.warn(`NEEDLE_lightmaps: Could not resolve texture for pointer "${entry.pointer}"`);
154
+ logger.error(`NEEDLE_lightmaps: Could not resolve texture for pointer "${entry.pointer}"`);
155
+ testAssert(false, `NEEDLE_lightmaps: Could not resolve texture for pointer "${entry.pointer}"`);
145
156
  }
146
157
  }
147
158
  // Lightmap workaround: prevent compression on lightmap textures (non-EXR)
@@ -217,11 +228,7 @@ export class NEEDLE_lightmaps_ext extends Extension {
217
228
  const textures = context.jsonDoc.json.textures = context.jsonDoc.json.textures || [];
218
229
  // Try to find an existing texture def pointing to the same image
219
230
  for (let i = 0; i < textures.length; i++) {
220
- const texDef = textures[i];
221
- const source = texDef.source
222
- ?? texDef.extensions?.['KHR_texture_basisu']?.source
223
- ?? texDef.extensions?.['NEEDLE_pmrem']?.source;
224
- if (source === imageIndex) {
231
+ if (findSource(textures[i]) === imageIndex) {
225
232
  return i;
226
233
  }
227
234
  }
@@ -1,5 +1,5 @@
1
1
  import { Extension, ExtensionProperty, PropertyType, Node, TextureChannel } from '@gltf-transform/core';
2
- import { getTexture, tryGetInArray, writeExtension } from '../utils.js';
2
+ import { findSource, getTexture, tryGetInArray, writeExtension } from '../utils.js';
3
3
  import { isLightmap } from '../../utils/texture.js';
4
4
  import { NEEDLE_progressive_texture_settings } from '../NEEDLE_progressive_texture_settings/index.js';
5
5
  import { testAssert } from '../../utils/test-assert.js';
@@ -655,7 +655,8 @@ class JsonPointerHandler {
655
655
  this.externalExrPointers.push({ obj, key, texture });
656
656
  }
657
657
  else if (exrPath) {
658
- console.warn(`WARN: External EXR not found: ${exrPath}`);
658
+ this.document.getLogger().error(`NEEDLE_opaque: External EXR not found: ${exrPath}`);
659
+ testAssert(false, `NEEDLE_opaque: External EXR not found: ${exrPath}`);
659
660
  }
660
661
  }
661
662
  if (value.startsWith("/materials/")) {
@@ -999,22 +1000,6 @@ class TextureIndexResolver extends PointerResolver {
999
1000
  return json.samplers.length - 1;
1000
1001
  }
1001
1002
  }
1002
- function findSource(texDef) {
1003
- if (texDef.source !== undefined) {
1004
- return texDef.source;
1005
- }
1006
- if (texDef.extensions) {
1007
- const keys = Object.keys(texDef.extensions);
1008
- for (let i = 0; i < keys.length; i++) {
1009
- const key = keys[i];
1010
- const ext = texDef.extensions[key];
1011
- if (ext["source"] !== undefined) {
1012
- return ext["source"];
1013
- }
1014
- }
1015
- }
1016
- return undefined;
1017
- }
1018
1003
  class MaterialIndexResolver extends PointerResolver {
1019
1004
  init(context) {
1020
1005
  }
@@ -3,4 +3,7 @@ import { GLTF } from '@gltf-transform/core';
3
3
  export declare function tryGetInArray<T>(arr: Array<T> | undefined | null, index: number | undefined): T | null;
4
4
  export declare function writeExtension(prop: GLTF.IProperty | null, name: string, ext: any): void;
5
5
  export declare function getTexture(context: ReaderContext, tex: GLTF.ITexture): Texture;
6
+ /** Resolve the image source index from a texture definition, checking `texDef.source`
7
+ * and then iterating all extension keys for a `.source` property. */
8
+ export declare function findSource(texDef: GLTF.ITexture): number | undefined;
6
9
  export declare function getTextureDefinition(context: WriterContext | ReaderContext, tex: Texture): GLTF.ITexture | null;
@@ -21,19 +21,26 @@ export function writeExtension(prop, name, ext) {
21
21
  export function getTexture(context, tex) {
22
22
  if (!context.jsonDoc.json.images)
23
23
  return null;
24
- if (tex.source !== undefined) {
25
- return context.textures[tex.source];
24
+ const source = findSource(tex);
25
+ if (source !== undefined) {
26
+ return context.textures[source];
26
27
  }
27
- else {
28
- for (const key of Object.keys(tex.extensions)) {
29
- const ext = tex.extensions[key];
30
- if (ext && typeof ext["source"] === "number") {
31
- return context.textures[ext["source"]];
32
- }
28
+ console.log("Could not find texture", tex);
29
+ return null;
30
+ }
31
+ /** Resolve the image source index from a texture definition, checking `texDef.source`
32
+ * and then iterating all extension keys for a `.source` property. */
33
+ export function findSource(texDef) {
34
+ if (texDef.source !== undefined)
35
+ return texDef.source;
36
+ if (texDef.extensions) {
37
+ for (const key of Object.keys(texDef.extensions)) {
38
+ const ext = texDef.extensions[key];
39
+ if (ext && ext["source"] !== undefined)
40
+ return ext["source"];
33
41
  }
34
42
  }
35
- console.log("Could not fnd texture", tex);
36
- return null;
43
+ return undefined;
37
44
  }
38
45
  export function getTextureDefinition(context, tex) {
39
46
  const textureDefinitions = context.jsonDoc.json.textures;
@@ -7,7 +7,7 @@ import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
7
7
  import { dedup, metalRough, prune, resample } from '@gltf-transform/functions';
8
8
  import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_pmrem, NEEDLE_lightmaps_ext, setExtensionInputFile } from '../extensions/index.js';
9
9
  import { isLOD, isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
- import { getOutputPath, getVersion, ioTryReadWithMissingResources, writeNodeIO } from "../utils/index.js";
10
+ import { copyExternalResources, getOutputPath, getVersion, ioTryReadWithMissingResources, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
12
  import { TestAssertionError } from '../utils/test-assert.js';
13
13
  /** Compress a glTF file
@@ -78,6 +78,7 @@ export async function packGLTF(inputFile, outputFile, options) {
78
78
  /** @ts-ignore */
79
79
  document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
80
80
  }
81
+ copyExternalResources(document, assetSourceFile, outputFile);
81
82
  let totalGPUTextureMemory = 0;
82
83
  /** @type {Array<import('@gltf-transform/core').Transform>} */
83
84
  const transforms = [
@@ -8,6 +8,7 @@ import { v4 as uuid } from 'uuid';
8
8
  import { createHash } from 'crypto';
9
9
  import tmp from 'tmp';
10
10
  import { addToCache, tryGetFromCache } from '../cache/index.js';
11
+ import { testAssert } from '../utils/test-assert.js';
11
12
  /** Resolve the tools/pmrem/ directory relative to this file.
12
13
  * At runtime this file is at dist/transforms/needle_pmrem.js,
13
14
  * so tools/pmrem/ is at ../../tools/pmrem/ */
@@ -61,12 +62,14 @@ export class NeedlePmremContext {
61
62
  const wasmBinPath = join(this.pmremDir, 'pkg', 'pmrem_wasm_bg.wasm');
62
63
  if (!existsSync(wasmJsPath) || !existsSync(wasmBinPath)) {
63
64
  this.logger.warn('pmrem: WASM not found — EXR textures will not be processed');
65
+ testAssert(false, 'pmrem WASM files are missing. Please run `npm run build:pmrem` to build the tools/pmrem/ WASM module.');
64
66
  return false;
65
67
  }
66
68
  // Check basisu binary
67
69
  this.basisuExe = resolveBasisuExe(this.pmremDir);
68
70
  if (!existsSync(this.basisuExe)) {
69
71
  this.logger.warn(`pmrem: basisu binary not found at ${this.basisuExe} — EXR textures will not be processed`);
72
+ testAssert(false, `basisu executable is missing for platform ${process.platform}. Please run \`npm run build:pmrem\` to build the tools/pmrem/ binaries.`);
70
73
  return false;
71
74
  }
72
75
  // Set executable permission
@@ -85,6 +88,7 @@ export class NeedlePmremContext {
85
88
  }
86
89
  catch (err) {
87
90
  this.logger.warn(`pmrem: Failed to load WASM — ${err.message}`);
91
+ testAssert(false, `Failed to load pmrem WASM module: ${err.message}`);
88
92
  return false;
89
93
  }
90
94
  // Create temp directory (per-instance to avoid parallel cleanup races)
@@ -109,6 +113,7 @@ export class NeedlePmremContext {
109
113
  const contentHash = createHash('md5').update(exrBytes).digest('hex');
110
114
  const cacheKey = `pmrem-${contentHash}`;
111
115
  // Get KTX2 bytes — deduplicated and optionally cached
116
+ const wasDeduplicated = this.processingPromises.has(cacheKey);
112
117
  const ktx2Bytes = await this.getOrProcessExr(cacheKey, exrBytes, index, name, context.useCache);
113
118
  // Remove EXT_texture_exr property since the texture is now KTX2
114
119
  const exrProp = texture.getExtension('EXT_texture_exr');
@@ -133,20 +138,25 @@ export class NeedlePmremContext {
133
138
  texture.setName(ktx2FileName);
134
139
  // Clear image data so gltf-transform doesn't embed it in the GLB
135
140
  texture.setImage(null);
136
- this.logger.info(`pmrem:texture[${index}] ${name}: external → ${ktx2FileName}`);
141
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: external → ${ktx2FileName}`);
137
142
  }
138
143
  else {
139
144
  // Bundled: embed the KTX2 in the GLB
140
145
  texture.setImage(new Uint8Array(ktx2Bytes));
141
146
  }
142
- // Report size
143
- const sizeStr = image.byteLength < 100 * 1024
144
- ? `${(image.byteLength / 1024).toFixed(0)} KB`
145
- : `${(image.byteLength / (1024 * 1024)).toFixed(2)} MB`;
146
- const dstSizestr = ktx2Bytes.length < 100 * 1024
147
- ? `${(ktx2Bytes.length / 1024).toFixed(0)} KB`
148
- : `${(ktx2Bytes.length / (1024 * 1024)).toFixed(2)} MB`;
149
- this.logger.info(`pmrem:texture[${index}] ${name}: ${sizeStr} EXR → ${dstSizestr} KTX2`);
147
+ // Report size — skip info log for deduplicated textures to avoid confusion
148
+ if (wasDeduplicated) {
149
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: reused (identical content) → ${texture.getURI() || texture.getName() || name}`);
150
+ }
151
+ else {
152
+ const sizeStr = image.byteLength < 100 * 1024
153
+ ? `${(image.byteLength / 1024).toFixed(0)} KB`
154
+ : `${(image.byteLength / (1024 * 1024)).toFixed(2)} MB`;
155
+ const dstSizestr = ktx2Bytes.length < 100 * 1024
156
+ ? `${(ktx2Bytes.length / 1024).toFixed(0)} KB`
157
+ : `${(ktx2Bytes.length / (1024 * 1024)).toFixed(2)} MB`;
158
+ this.logger.info(`NEEDLE_pmrem[${index}] ${name}: ${sizeStr} EXR → ${dstSizestr} KTX2 (${texture.getURI() || texture.getName() || name})`);
159
+ }
150
160
  this.convertedTextures.push(texture);
151
161
  this.processed++;
152
162
  return true;
@@ -156,7 +166,7 @@ export class NeedlePmremContext {
156
166
  // In-memory dedup: if we're already processing identical EXR content, reuse the promise
157
167
  const existing = this.processingPromises.get(cacheKey);
158
168
  if (existing) {
159
- this.logger.info(`pmrem:texture[${index}] ${name}: reusing in-flight result (identical content)`);
169
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: reusing in-flight result (identical content)`);
160
170
  return existing;
161
171
  }
162
172
  const promise = this.processExrToKtx2(cacheKey, exrBytes, index, name, useCache);
@@ -168,24 +178,32 @@ export class NeedlePmremContext {
168
178
  if (useCache) {
169
179
  const cached = tryGetFromCache(cacheKey);
170
180
  if (cached && cached.length > 0) {
171
- this.logger.info(`pmrem:texture[${index}] ${name}: loaded from cache`);
181
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: loaded from cache`);
172
182
  return cached;
173
183
  }
174
184
  }
175
- // 1. Run PMREM on the EXR bytes
176
- this.logger.info(`pmrem:texture[${index}] ${name}: Running PMREM...`);
185
+ // 1. Run PMREM on the EXR bytes (synchronous WASM — may take a while)
186
+ const inputSize = exrBytes.byteLength < 100 * 1024
187
+ ? `${(exrBytes.byteLength / 1024).toFixed(0)} KB`
188
+ : `${(exrBytes.byteLength / (1024 * 1024)).toFixed(2)} MB`;
189
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: Running PMREM WASM (${inputSize} EXR) — this may take a while...`);
190
+ // Yield to allow log to flush before blocking WASM call
191
+ await new Promise(r => setTimeout(r, 0));
192
+ const t0 = Date.now();
177
193
  const pmremExrBytes = this.wasmModule.pmrem_exr(exrBytes);
194
+ const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
195
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: PMREM done in ${elapsed}s (${(pmremExrBytes.byteLength / 1024 / 1024).toFixed(1)} MB)`);
178
196
  // 2. Write PMREM EXR to temp file
179
197
  const id = uuid();
180
198
  const tempExr = join(this.tmpDir, `${id}.pmrem.exr`);
181
199
  const tempKtx2 = join(this.tmpDir, `${id}.ktx2`);
182
200
  writeFileSync(tempExr, Buffer.from(pmremExrBytes));
183
201
  // 3. Encode to KTX2 HDR via basisu
184
- this.logger.debug(`pmrem:texture[${index}] ${name}: Encoding KTX2 HDR...`);
202
+ this.logger.debug(`NEEDLE_pmrem[${index}] ${name}: Encoding KTX2 HDR...`);
185
203
  const variant = 'hdr4x4'; // 6x6 needs an update in Needle
186
204
  await encodeKTX2WithBasisu(this.basisuExe, tempExr, tempKtx2, variant, true);
187
205
  if (!existsSync(tempKtx2)) {
188
- throw new Error(`pmrem:texture[${index}] ${name}: basisu did not produce output`);
206
+ throw new Error(`NEEDLE_pmrem[${index}] ${name}: basisu did not produce output`);
189
207
  }
190
208
  // 4. Read KTX2 result
191
209
  const ktx2Bytes = readFileSync(tempKtx2);
@@ -8,7 +8,7 @@ import path, { basename, resolve } from 'path';
8
8
  import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
9
9
  import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
10
10
  import sharp from "sharp";
11
- import { detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, ioTryReadWithMissingResources, validateImageMimeType, writeNodeIO } from '../utils/index.js';
11
+ import { copyExternalResources, detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, ioTryReadWithMissingResources, validateImageMimeType, writeNodeIO } from '../utils/index.js';
12
12
  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';
@@ -88,6 +88,9 @@ async function onProcess(file, opts) {
88
88
  document.getLogger().verbosity = (opts.debug || opts.verbose) ? Verbosity.DEBUG : Verbosity.WARN;
89
89
  }
90
90
  const logger = opts.logger || document.getLogger();
91
+ if (opts.outpath) {
92
+ copyExternalResources(document, file, path.join(opts.outpath, path.basename(file)));
93
+ }
91
94
  let smallesTextureSize = 128;
92
95
  if (opts.config.usecase === "product") {
93
96
  smallesTextureSize = 512;
@@ -4,3 +4,8 @@ import { NodeIO, Document, PlatformIO } from "@gltf-transform/core";
4
4
  */
5
5
  export declare function writeNodeIO(nodeio: NodeIO, filename: string, document: Document): Promise<string>;
6
6
  export declare function ioTryReadWithMissingResources(io: PlatformIO, filepath: string, iteration?: number): Promise<Document>;
7
+ /**
8
+ * Copy external resources (images/buffers with URIs) referenced by a Document
9
+ * from the input directory to the output directory. No-op if input and output resolve to the same directory.
10
+ */
11
+ export declare function copyExternalResources(document: Document, inputFile: string, outputFile: string): void;
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync } from "fs";
1
+ import { cpSync, existsSync, mkdirSync } from "fs";
2
2
  import { writeFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import sharp from "sharp";
@@ -84,3 +84,33 @@ export async function ioTryReadWithMissingResources(io, filepath, iteration = 0)
84
84
  }
85
85
  return document;
86
86
  }
87
+ /**
88
+ * Copy external resources (images/buffers with URIs) referenced by a Document
89
+ * from the input directory to the output directory. No-op if input and output resolve to the same directory.
90
+ */
91
+ export function copyExternalResources(document, inputFile, outputFile) {
92
+ const inputDir = path.resolve(path.dirname(inputFile));
93
+ const outputDir = path.resolve(path.dirname(outputFile));
94
+ if (inputDir === outputDir)
95
+ return;
96
+ for (const texture of document.getRoot().listTextures()) {
97
+ const uri = texture.getURI();
98
+ if (!uri || uri.startsWith('data:'))
99
+ continue;
100
+ const src = path.join(inputDir, uri);
101
+ const dst = path.join(outputDir, uri);
102
+ if (!existsSync(dst) && existsSync(src)) {
103
+ cpSync(src, dst);
104
+ }
105
+ }
106
+ for (const buffer of document.getRoot().listBuffers()) {
107
+ const uri = buffer.getURI();
108
+ if (!uri || uri.startsWith('data:'))
109
+ continue;
110
+ const src = path.join(inputDir, uri);
111
+ const dst = path.join(outputDir, uri);
112
+ if (!existsSync(dst) && existsSync(src)) {
113
+ cpSync(src, dst);
114
+ }
115
+ }
116
+ }
@@ -1 +1 @@
1
- export declare const version = "2.15.8";
1
+ export declare const version = "2.15.9-next.aba648c";
@@ -1 +1 @@
1
- export const version = "2.15.8";
1
+ export const version = "2.15.9-next.aba648c";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.15.8",
3
+ "version": "2.15.9-next.aba648c",
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",