@needle-tools/gltf-build-pipeline 2.16.0-next.85db3ce → 2.16.0-next.8ceb45e

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 (38) hide show
  1. package/README.md +29 -2
  2. package/dist/cache/cache.d.ts +1 -0
  3. package/dist/cache/cache.js +35 -15
  4. package/dist/cache/job-cache.d.ts +2 -0
  5. package/dist/cache/job-cache.js +2 -0
  6. package/dist/cli/index.js +138 -7
  7. package/dist/config/index.d.ts +11 -0
  8. package/dist/config/index.js +9 -4
  9. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.d.ts +2 -2
  10. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.js +30 -0
  11. package/dist/extensions/NEEDLE_opaque/index.d.ts +1 -2
  12. package/dist/scripts/pack-gltf.d.ts +2 -0
  13. package/dist/scripts/pack-gltf.js +39 -4
  14. package/dist/transforms/index.d.ts +3 -0
  15. package/dist/transforms/index.js +3 -0
  16. package/dist/transforms/needle_audio.d.ts +12 -0
  17. package/dist/transforms/needle_audio.js +104 -0
  18. package/dist/transforms/needle_audio_ffmpeg.d.ts +14 -0
  19. package/dist/transforms/needle_audio_ffmpeg.js +51 -0
  20. package/dist/transforms/needle_audio_registry.d.ts +38 -0
  21. package/dist/transforms/needle_audio_registry.js +47 -0
  22. package/dist/transforms/needle_pmrem.d.ts +27 -7
  23. package/dist/transforms/needle_pmrem.js +104 -95
  24. package/dist/transforms/needle_progressive.d.ts +9 -1
  25. package/dist/transforms/needle_progressive.js +30 -14
  26. package/dist/transforms/needle_texture_transform.js +4 -0
  27. package/dist/transforms/toktx.js +23 -36
  28. package/dist/utils/index.d.ts +0 -1
  29. package/dist/utils/index.js +0 -1
  30. package/dist/utils/stats.js +12 -4
  31. package/dist/utils/validate.d.ts +4 -1
  32. package/dist/utils/validate.js +7 -0
  33. package/dist/utils/version.gen.d.ts +1 -1
  34. package/dist/utils/version.gen.js +1 -1
  35. package/package.json +1 -1
  36. package/tools/pmrem/pkg/pmrem_wasm_bg.wasm +0 -0
  37. package/dist/utils/merge.d.ts +0 -12
  38. package/dist/utils/merge.js +0 -31
package/README.md CHANGED
@@ -14,10 +14,10 @@ Includes support for generating LOD quality level for textures and meshes. Use w
14
14
  In general all files that have been processed by this package are valid glTF/GLB files.
15
15
  The glTF Build Pipeline package is meant to be used as a last step before delivery.
16
16
  That means:
17
- - files are meant to be loaded e.g. in a web runtime like three.js
17
+ - files are meant to be loaded by [Needle Engine](https://needle.tools) (and other three.js based runtimes)
18
18
  - files are not to be opened in Blender again to be further edited
19
19
 
20
- To load progressively enhanced glTF files in any three.js based engine see examples at [@needle-tools/gltf-progressive](https://www.npmjs.com/package/@needle-tools/gltf-progressive)
20
+ To load progressively enhanced glTF files in Needle Engine or another three.js based runtime, see examples at [@needle-tools/gltf-progressive](https://www.npmjs.com/package/@needle-tools/gltf-progressive)
21
21
 
22
22
  ### Installation
23
23
 
@@ -26,6 +26,33 @@ To load progressively enhanced glTF files in any three.js based engine see examp
26
26
  ### Commands
27
27
  - `help` - use help to view options
28
28
 
29
+ ### Configuration
30
+
31
+ The pipeline reads an optional `needle.config.json` from the working directory (or any parent directory). Configure pipeline behaviour under the `gltf` key:
32
+
33
+ ```json
34
+ {
35
+ "gltf": {
36
+ "usecase": "product",
37
+ "textures": { "lods": true },
38
+ "meshes": { "lods": true },
39
+ "audio": { "enabled": true, "bitrate": 64 },
40
+ "exr": { "enabled": true }
41
+ }
42
+ }
43
+ ```
44
+
45
+ The `gltf` value may also be a string shorthand for `usecase` — `"gltf": "product"` is equivalent to `"gltf": { "usecase": "product" }`.
46
+
47
+ | Key | Type | Default | Description |
48
+ |-----|------|---------|-------------|
49
+ | `usecase` | `"default" \| "product" \| "world"` | `"default"` | High-level optimisation profile. |
50
+ | `textures.lods` | `boolean` | `true` | Generate progressive texture LODs. |
51
+ | `meshes.lods` | `boolean` | `true` | Generate progressive mesh LODs. |
52
+ | `audio.enabled` | `boolean` | `true` | Compress externally referenced `.wav` audio files to Opus in an `.ogg` container, rewriting the URIs in the glTF. Lossy formats (`.mp3`, `.ogg`, `.m4a`, `.aac`) pass through unchanged to avoid compounding compression artifacts. Requires `ffmpeg` with `libopus` on `PATH`; when missing the build logs a warning and leaves the audio files untouched. |
53
+ | `audio.bitrate` | `number` (kbps) | `128` | Opus encoding bitrate. 96 is near-transparent for most music; 128 matches YouTube's music tier and is the safe "won't sound bad" default. Drop to 48–64 for known mono/UI-only assets. |
54
+ | `exr.enabled` | `boolean` | `true` | Convert EXR textures to KTX2 HDR via PMREM. When `false`, EXR textures (bundled and externally referenced) are left as-is so a downstream step can handle them. |
55
+
29
56
  # Contact
30
57
 
31
58
  For licensing questions please contact us.
@@ -2,6 +2,7 @@
2
2
  /// <reference types="node" resolution-mode="require"/>
3
3
  import { ExtensibleProperty } from "@gltf-transform/core";
4
4
  import { Logger } from "@donmccurdy/caporal";
5
+ export declare function ensureHashReady(): Promise<void>;
5
6
  /**
6
7
  * Clear the cache.
7
8
  * This will remove all files in the cache directory.
@@ -6,9 +6,16 @@ import { getVersion } from "../utils/version.js";
6
6
  // import * as CHECKDISCSPACE from 'check-disk-space'
7
7
  import xxhash from "xxhash-wasm";
8
8
  let xxHashModule = null;
9
- xxhash().then(module => {
10
- xxHashModule = module;
11
- }).catch(() => { });
9
+ // Single shared promise that resolves once the WASM module is loaded (or has failed).
10
+ // Callers should `await ensureHashReady()` once before any synchronous hash call to
11
+ // avoid a race where early calls use the JS fallback and later calls use WASM —
12
+ // producing different cache keys for the same input within one process.
13
+ const xxHashReady = xxhash()
14
+ .then(module => { xxHashModule = module; })
15
+ .catch(() => { });
16
+ export function ensureHashReady() {
17
+ return xxHashReady;
18
+ }
12
19
  /**
13
20
  * Get the available space on the cache directory in bytes.
14
21
  */
@@ -179,6 +186,17 @@ export function getKeyWithHash(property) {
179
186
  const key = `${prefix}-${hash}`;
180
187
  return { key, hash };
181
188
  }
189
+ /**
190
+ * Combine two 32-bit hashes with mixing. Plain `a + b` is commutative and
191
+ * has poor diffusion — swapping two attribute buffers collides. This uses a
192
+ * boost::hash_combine-style mix: xor in the new bits, then bit-shift mix so
193
+ * order matters and unrelated changes don't cancel out.
194
+ */
195
+ function combineHash(seed, value) {
196
+ // 0x9e3779b9 = golden-ratio constant used by boost / fnv variants
197
+ seed = (seed ^ (value + 0x9e3779b9 + ((seed << 6) | 0) + (seed >>> 2))) | 0;
198
+ return seed;
199
+ }
182
200
  /**
183
201
  * Get the hash for a property or an object.
184
202
  * @param property The property to get the hash for.
@@ -186,27 +204,26 @@ export function getKeyWithHash(property) {
186
204
  * @returns The hash as a number.
187
205
  */
188
206
  export function getHash(property, level = 0) {
189
- let hashStr = "";
190
207
  let hash = 0;
191
208
  if (property instanceof ExtensibleProperty) {
192
209
  const extensions = property.listExtensions();
193
210
  const extras = property.getExtras();
194
- hash += hashObject({
211
+ hash = combineHash(hash, hashObject({
195
212
  extensions,
196
213
  extras,
197
- });
214
+ }));
198
215
  if (property instanceof Texture) {
199
216
  const image = property.getImage();
200
217
  const bytes = image?.buffer;
201
218
  if (bytes)
202
- hash += hashBuffer(bytes, image.byteOffset, image.byteLength);
219
+ hash = combineHash(hash, hashBuffer(bytes, image.byteOffset, image.byteLength));
203
220
  }
204
221
  else if (property instanceof Mesh) {
205
222
  const weights = property.getWeights();
206
- hash += hashObject(weights);
223
+ hash = combineHash(hash, hashObject(weights));
207
224
  const primitives = property.listPrimitives();
208
225
  for (const prim of primitives) {
209
- hash += hashString(getHash(prim, level + 1));
226
+ hash = combineHash(hash, hashString(getHash(prim, level + 1)));
210
227
  }
211
228
  }
212
229
  else if (property instanceof Primitive) {
@@ -220,32 +237,35 @@ export function getHash(property, level = 0) {
220
237
  name: s,
221
238
  };
222
239
  });
223
- hash += hashObject({
240
+ hash = combineHash(hash, hashObject({
224
241
  indices,
225
242
  attributes,
226
243
  targets,
227
244
  semantics: semanticsNames
228
- });
245
+ }));
229
246
  if (indices) {
230
247
  const indexBuffer = indices.getArray();
231
248
  if (indexBuffer) {
232
- hash += hashBuffer(indexBuffer.buffer, indexBuffer.byteOffset, indexBuffer.byteLength);
249
+ hash = combineHash(hash, hashBuffer(indexBuffer.buffer, indexBuffer.byteOffset, indexBuffer.byteLength));
233
250
  }
234
251
  }
235
252
  // We can not pass the whole buffer into hashObject since very large meshes will cause errors when trying to stringify
236
253
  // See https://linear.app/needle/issue/NE-6414
254
+ // Mix the semantic name into each attribute hash so swapping two attributes (e.g. POSITION ↔ NORMAL)
255
+ // doesn't collide — without this, the commutative combine would erase attribute identity.
237
256
  for (const sem of semantics) {
238
257
  const arr = property.getAttribute(sem)?.getArray();
239
258
  if (arr) {
240
- hash += hashBuffer(arr.buffer, arr.byteOffset, arr.byteLength);
259
+ hash = combineHash(hash, hashString(sem));
260
+ hash = combineHash(hash, hashBuffer(arr.buffer, arr.byteOffset, arr.byteLength));
241
261
  }
242
262
  }
243
263
  }
244
264
  }
245
265
  else {
246
- hash += hashObject(property);
266
+ hash = combineHash(hash, hashObject(property));
247
267
  }
248
- return hashStr + hash;
268
+ return String(hash);
249
269
  }
250
270
  const cleanBuildPipelineVersion = getVersion(false).replace(/[^a-zA-Z0-9]/g, "_");
251
271
  function getCacheDirectory(root) {
@@ -15,6 +15,8 @@ export declare function computeJobKey(options: JobCacheOptions, inputFilePath: s
15
15
  /**
16
16
  * Save the result of a completed job to the cache.
17
17
  * Stores the content of each output file and a manifest linking them.
18
+ * The caller should include all output files including external sidecar files
19
+ * (e.g. .pmrem.ktx2) collected via PackGLTFOptions.externalFiles.
18
20
  */
19
21
  export declare function saveJobResult(jobKey: string, outputFiles: string[], logger: ILogger): void;
20
22
  /**
@@ -29,6 +29,8 @@ export function computeJobKey(options, inputFilePath) {
29
29
  /**
30
30
  * Save the result of a completed job to the cache.
31
31
  * Stores the content of each output file and a manifest linking them.
32
+ * The caller should include all output files including external sidecar files
33
+ * (e.g. .pmrem.ktx2) collected via PackGLTFOptions.externalFiles.
32
34
  */
33
35
  export function saveJobResult(jobKey, outputFiles, logger) {
34
36
  const entries = [];
package/dist/cli/index.js CHANGED
@@ -5,15 +5,26 @@ import { printStats, writeFileStatsToFile } from '../utils/stats.js';
5
5
  import { getVersion } from '../utils/version.js';
6
6
  import { UsecaseOptions, createConfig, getConfig } from '../config/index.js';
7
7
  import { isLOD, make_progressive } from '../transforms/needle_progressive.js';
8
- import { cacheSizeLimit, clearCache, limitCacheSize } from '../cache/cache.js';
8
+ import { cacheSizeLimit, clearCache, ensureHashReady, limitCacheSize } from '../cache/cache.js';
9
9
  import { computeJobKey, saveJobResult, tryRestoreJobResult } from '../cache/job-cache.js';
10
- import { existsSync, statSync } from 'fs';
10
+ import { convertExrBytesToPmremKtx2, createPmremRuntime, disposePmremRuntime } from '../transforms/needle_pmrem.js';
11
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs';
11
12
  import { ensureIsDirectory, foreachGLTF, isDirectory } from '../utils/fileutils.js';
12
- import path, { dirname, resolve } from 'path';
13
+ import path, { basename, dirname, resolve } from 'path';
13
14
  import { ERROR_CODES } from '../constants.js';
14
15
  import { trackPipelineStart, trackPipelineEnd, trackError } from '../utils/analytics.js';
15
16
  // For testing / dev you can run `npm link` in the package directory
16
17
  // For removing the link run `npm rm --global @needle-tools/gltf-build-pipeline`
18
+ // Disable caporal's autoCast for positional arguments.
19
+ //
20
+ // caporal's autoCast uses /^true|false$/ (precedence bug — should be /^(true|false)$/),
21
+ // which matches any string ending with "false" or starting with "true". An <output>
22
+ // path like ".../out-shellfalse" gets coerced to the boolean false, then stringified
23
+ // to "false", and the pipeline writes to "<inputDir>/false" instead of the requested
24
+ // directory. Same shape applies to paths ending with "on" or starting with "yes"/"1".
25
+ // Disabling autoCast at the program level forces positionals through String(...);
26
+ // options with explicit BOOLEAN/STRING validators are unaffected.
27
+ program.cast(false);
17
28
  program
18
29
  .command("version", "Print current version")
19
30
  .action(async ({ logger }) => {
@@ -35,6 +46,9 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
35
46
  .option("--verbose", "Enable verbose output", { validator: program.BOOLEAN, default: false })
36
47
  .action(async ({ logger, args, options }) => {
37
48
  logger.level = options.verbose ? "debug" : "info";
49
+ // Wait for the xxHash WASM module so any hashing inside calculateStats sees the
50
+ // same hash function on every call (otherwise the first calls use the JS fallback).
51
+ await ensureHashReady();
38
52
  logger.info("Calculating stats for \"" + args.input + "\"");
39
53
  const input = args.input.toString();
40
54
  const filestats = new Array();
@@ -60,8 +74,14 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
60
74
  This will produce multiple versions of the input file, each with a different level of optimization.
61
75
  Each version will be compressed and written to the output directory.
62
76
  `)
63
- .argument('<input>', "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed")
64
- .argument('<output>', "The output file or directory. It can be an absolute path or a relative path to the input file. If not provided, the input file will be overwritten.", { default: null })
77
+ // Explicit STRING validators prevent caporal's autoCast() from silently coercing
78
+ // a path that ends with "false" / "on" (or starts with "true" / "yes" / "1") into
79
+ // a boolean — caporal's autoCast regex is missing parentheses and matches as
80
+ // /^true | false$/ rather than /^(true|false)$/. Without these validators, an
81
+ // output path like "out-shellfalse" gets cast to the boolean false → stringified
82
+ // → file written to "<inputDir>/false" instead of the named directory.
83
+ .argument('<input>', "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed", { validator: program.STRING })
84
+ .argument('<output>', "The output file or directory. It can be an absolute path or a relative path to the input file. If not provided, the input file will be overwritten.", { default: null, validator: program.STRING })
65
85
  .option("--compress", `When enabled files will be compressed`, { validator: program.BOOLEAN, default: true })
66
86
  .option("--progressive", `When enabled files will processed to be progressively loaded`, { validator: program.BOOLEAN, default: true })
67
87
  .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}]`, { validator: program.STRING })
@@ -76,6 +96,10 @@ Each version will be compressed and written to the output directory.
76
96
  logger.error(`[Needle Build Pipeline] v${getVersion()} You need to enable at least one of the options: --compress or --progressive`);
77
97
  return;
78
98
  }
99
+ // Wait for the xxHash WASM module before any hashing happens. Without this the
100
+ // first hash calls in a process use the JS fallback while later ones use WASM,
101
+ // producing different cache keys for the same input within a single run.
102
+ await ensureHashReady();
79
103
  logger.info(`[Needle Build Pipeline] v${getVersion()} — Transform '${args.input}'`);
80
104
  const pipelineMode = [options.progressive && 'progressive', options.compress && 'compress'].filter(Boolean).join('+');
81
105
  trackPipelineStart({ mode: pipelineMode, usecase: options.usecase?.toString() });
@@ -164,7 +188,7 @@ Each version will be compressed and written to the output directory.
164
188
  stats.totalFileSizeInMBBefore += inputFileSize;
165
189
  }
166
190
  }
167
- logger.info(`→ [CACHE] Restored ${restored.length} file(s)for job ${jobKey}`);
191
+ logger.info(`→ [CACHE] Restored ${restored.length} file(s) for job ${jobKey}`);
168
192
  return;
169
193
  }
170
194
  }
@@ -192,6 +216,7 @@ Each version will be compressed and written to the output directory.
192
216
  console.log("Progressive results:\n", progressive_results);
193
217
  }
194
218
  }
219
+ const externalFiles = [];
195
220
  if (options.compress === true) {
196
221
  const opts = {
197
222
  config,
@@ -204,6 +229,7 @@ Each version will be compressed and written to the output directory.
204
229
  // When progressive ran first, inputFile points to the progressive output copy.
205
230
  // Pass the original source path so extensions can resolve sibling assets (e.g. .exr files).
206
231
  sourceFile: progressive_results.length > 0 ? file : undefined,
232
+ externalFiles,
207
233
  };
208
234
  // If we have produced progressive assets then the array already contains the input
209
235
  if (progressive_results.length > 0) {
@@ -228,7 +254,7 @@ Each version will be compressed and written to the output directory.
228
254
  const allOutputFiles = progressive_results.length > 0
229
255
  ? progressive_results
230
256
  : [output || file];
231
- saveJobResult(jobKey, allOutputFiles, logger);
257
+ saveJobResult(jobKey, [...allOutputFiles, ...externalFiles], logger);
232
258
  }
233
259
  });
234
260
  printStats(stats, logger, config);
@@ -243,7 +269,112 @@ Each version will be compressed and written to the output directory.
243
269
  });
244
270
  if (useCache)
245
271
  limitCacheSize();
272
+ })
273
+ .command("pmrem", "Convert EXR files to PMREM-processed KTX2 HDR (Needle PMREM)")
274
+ .help(`Runs the PMREM WASM + basisu encoder on .exr files, producing <name>.pmrem.ktx2.
275
+ This is the same processing the Needle build pipeline applies to EXR textures inside
276
+ GLB/GLTF files, exposed as a standalone command for raw .exr inputs.
277
+ `)
278
+ .argument('<input>', "Path to an .exr file OR a directory containing .exr files (recursive)", { validator: program.STRING })
279
+ .argument('<output>', "Output file (when input is a single .exr) or output directory. Defaults to writing next to the input file.", { default: null, validator: program.STRING })
280
+ .option("--cache", "Use \"--cache False\" to disable caching.", { validator: program.BOOLEAN, default: true })
281
+ .option("--debug", "Enable verbose output with \"--debug True\"", { validator: program.BOOLEAN, default: false })
282
+ .option("--verbose", "Enable verbose output with \"--verbose True\"", { validator: program.BOOLEAN, default: false })
283
+ .hide()
284
+ .action(async ({ logger, args, options }) => {
285
+ logger.level = (options.debug || options.verbose) ? "debug" : "info";
286
+ await ensureHashReady();
287
+ const input = args.input.toString();
288
+ if (!existsSync(input)) {
289
+ logger.error(`Input does not exist: ${input}`);
290
+ process.exit(ERROR_CODES.INVALID_ARGS);
291
+ }
292
+ const exrFiles = [];
293
+ const inputIsDirectory = isDirectory(input);
294
+ if (inputIsDirectory) {
295
+ collectExrFiles(input, exrFiles);
296
+ }
297
+ else if (input.toLowerCase().endsWith('.exr')) {
298
+ exrFiles.push(input);
299
+ }
300
+ else {
301
+ logger.error(`Input is not an .exr file or directory: ${input}`);
302
+ process.exit(ERROR_CODES.INVALID_ARGS);
303
+ }
304
+ if (exrFiles.length === 0) {
305
+ logger.warn(`No .exr files found in ${input}`);
306
+ return;
307
+ }
308
+ // caporal's program.cast(false) forces String(null) → "null" for the
309
+ // missing positional default, so treat that literal as "no output given".
310
+ const rawOutput = args.output?.toString() || null;
311
+ let outputArg = (rawOutput && rawOutput !== 'null') ? rawOutput : null;
312
+ if (outputArg && !path.isAbsolute(outputArg)) {
313
+ const base = inputIsDirectory ? input : dirname(input);
314
+ outputArg = resolve(base, outputArg);
315
+ }
316
+ const useCache = options.cache !== false;
317
+ logger.info(`[Needle Build Pipeline] v${getVersion()} — pmrem: ${exrFiles.length} file(s)`);
318
+ const runtime = await createPmremRuntime(logger);
319
+ if (!runtime) {
320
+ logger.error('pmrem runtime is not available (WASM or basisu binary missing). Run `npm run build:pmrem` in the package directory.');
321
+ process.exit(ERROR_CODES.PACKING_FAILED);
322
+ }
323
+ try {
324
+ for (const exrPath of exrFiles) {
325
+ const outPath = resolvePmremOutputPath(exrPath, input, inputIsDirectory, outputArg);
326
+ logger.info(`→ pmrem ${exrPath} → ${outPath}`);
327
+ const exrBytes = readFileSync(exrPath);
328
+ const ktx2Bytes = await convertExrBytesToPmremKtx2(runtime, new Uint8Array(exrBytes.buffer, exrBytes.byteOffset, exrBytes.byteLength), { useCache, logLabel: `pmrem ${basename(exrPath)}` });
329
+ writeFileSync(outPath, ktx2Bytes);
330
+ }
331
+ }
332
+ finally {
333
+ await disposePmremRuntime(runtime);
334
+ }
335
+ if (useCache)
336
+ limitCacheSize();
246
337
  });
338
+ function collectExrFiles(dir, out) {
339
+ for (const entry of readdirSync(dir)) {
340
+ if (entry === 'node_modules' || entry.startsWith('.'))
341
+ continue;
342
+ const full = path.join(dir, entry);
343
+ const st = statSync(full);
344
+ if (st.isDirectory()) {
345
+ collectExrFiles(full, out);
346
+ }
347
+ else if (entry.toLowerCase().endsWith('.exr')) {
348
+ out.push(full);
349
+ }
350
+ }
351
+ }
352
+ function resolvePmremOutputPath(exrPath, input, inputIsDirectory, outputArg) {
353
+ const outName = basename(exrPath).replace(/\.exr$/i, '.pmrem.ktx2');
354
+ if (!outputArg) {
355
+ // Default: write next to the input file
356
+ return path.join(dirname(exrPath), outName);
357
+ }
358
+ if (inputIsDirectory) {
359
+ // Mirror the directory layout under outputArg, treating it as a directory
360
+ const rel = path.relative(input, exrPath);
361
+ const relDir = dirname(rel);
362
+ const outDir = relDir === '.' ? outputArg : path.join(outputArg, relDir);
363
+ ensureIsDirectory(outDir);
364
+ return path.join(outDir, outName);
365
+ }
366
+ // Single-file input. outputArg may be an existing directory, or an explicit file path.
367
+ if (existsSync(outputArg) && isDirectory(outputArg)) {
368
+ return path.join(outputArg, outName);
369
+ }
370
+ if (outputArg.toLowerCase().endsWith('.ktx2')) {
371
+ ensureIsDirectory(dirname(outputArg));
372
+ return outputArg;
373
+ }
374
+ // Treat as a directory that does not exist yet
375
+ ensureIsDirectory(outputArg);
376
+ return path.join(outputArg, outName);
377
+ }
247
378
  program.run().catch(err => {
248
379
  // Since we still invoke this package with paths when running npm scripts we need to ignore this error
249
380
  // We might want to move this code here into an extra package.
@@ -13,6 +13,17 @@ export type Config = {
13
13
  meshes?: {
14
14
  lods: boolean;
15
15
  };
16
+ audio?: {
17
+ /** Enable Opus/Ogg compression of externally referenced audio files. Default: true. */
18
+ enabled?: boolean;
19
+ /** Opus encoding bitrate in kbps. Default: 64. */
20
+ bitrate?: number;
21
+ };
22
+ exr?: {
23
+ /** Enable EXR -> KTX2 HDR conversion via PMREM. When false, EXR textures (bundled or
24
+ * externally referenced) are left untouched. Default: true. */
25
+ enabled?: boolean;
26
+ };
16
27
  };
17
28
  /**
18
29
  * Create a config object with the given usecase.
@@ -70,8 +70,11 @@ export function getConfig(configPath, fallbackSearchDirectory, opts) {
70
70
  throw new Error(`Failed parsing config at \"${configPath}\"`);
71
71
  }
72
72
  }
73
+ // Always return a fresh shallow copy. Callers (notably the CLI) mutate the
74
+ // returned object (`config.usecase = ...`); returning the cache by reference
75
+ // would let those mutations poison subsequent getConfig() calls.
73
76
  if (_cachedConfig !== undefined) {
74
- return _cachedConfig;
77
+ return { ..._cachedConfig };
75
78
  }
76
79
  // Resolve the config once and cache it
77
80
  // Check if a needle.config.json exists in the directory from which the script is run (or any parent directory)
@@ -82,12 +85,14 @@ export function getConfig(configPath, fallbackSearchDirectory, opts) {
82
85
  logger.info(`Using config with usecase: ${_cachedConfig.usecase}`);
83
86
  }
84
87
  }
85
- // If no config was found, use the default config
88
+ // If no config was found, use the default config. Store a copy in the cache
89
+ // so we never alias defaultConfig directly — that way even if a downstream
90
+ // bug ever returned the cache by reference, defaultConfig itself stays clean.
86
91
  if (!_cachedConfig) {
87
92
  logger.debug("No config found. Using default config.");
88
- _cachedConfig = defaultConfig;
93
+ _cachedConfig = { ...defaultConfig };
89
94
  }
90
- return _cachedConfig;
95
+ return { ..._cachedConfig };
91
96
  }
92
97
  let _cachedConfig = undefined;
93
98
  export function searchConfig(directory, logger) {
@@ -1,4 +1,4 @@
1
- import { WriterContext, PropertyType } from '@gltf-transform/core';
1
+ import { Extension, WriterContext, PropertyType } from '@gltf-transform/core';
2
2
  declare interface IExtensibleProperty {
3
3
  }
4
4
  type DebugBreakPoint = boolean | "pointer:read";
@@ -23,5 +23,5 @@ export interface IExtensionWriter {
23
23
  write(context: WriterContext, prop: IExtensibleProperty): void;
24
24
  }
25
25
  export declare let currentGenerator: string;
26
- export declare function createOpaqueExtension(name: string, types: PropertyType | PropertyType[], opts?: Options): IOpaqueExtension;
26
+ export declare function createOpaqueExtension(name: string, types: PropertyType | PropertyType[], opts?: Options): typeof Extension;
27
27
  export {};
@@ -5,6 +5,7 @@ import { NEEDLE_progressive_texture_settings } from '../NEEDLE_progressive_textu
5
5
  import { testAssert } from '../../utils/test-assert.js';
6
6
  import { existsSync, readFileSync } from 'fs';
7
7
  import { dirname, join } from 'path';
8
+ import { isAudioUri, isRelativeAudioUri, listAudioRefs, registerAudioRef } from '../../transforms/needle_audio_registry.js';
8
9
  const ALL_PROPERTY_TYPES = [];
9
10
  for (const key in PropertyType) {
10
11
  ALL_PROPERTY_TYPES.push(PropertyType[key]);
@@ -627,6 +628,25 @@ class JsonPointerHandler {
627
628
  else
628
629
  console.warn("WARN: failed registering pointer", fullPath, value);
629
630
  }
631
+ // External audio reference (e.g. "click.wav" or "music.mp3" on a NEEDLE_components AudioSource).
632
+ // Audio is never embedded in the GLB — it stays as a sidecar file. We register the
633
+ // (obj, key) location so `needle_audio_transform` can compress the file and so the
634
+ // write-time pass can rewrite the JSON string to the new URI.
635
+ if (!isTexturePointer && isAudioUri(value) && isRelativeAudioUri(value)) {
636
+ const inputDir = getExtensionInputFile() ? dirname(getExtensionInputFile()) : '';
637
+ const audioPath = inputDir ? join(inputDir, value) : '';
638
+ if (audioPath && existsSync(audioPath)) {
639
+ registerAudioRef({ obj, key, sourcePath: audioPath, sourceUri: value });
640
+ if (this.debug)
641
+ console.log(`Registered external audio "${value}" at ${fullPath}`);
642
+ }
643
+ else if (audioPath) {
644
+ // Non-fatal: leave the URI untouched and warn. Mirrors EXR error path
645
+ // but uses warn instead of testAssert since missing audio shouldn't
646
+ // hard-fail the build.
647
+ this.document.getLogger().warn(`NEEDLE_opaque: external audio not found: ${audioPath}`);
648
+ }
649
+ }
630
650
  // External EXR file reference (e.g. "StudioHDRI_ferndale_studio_04_1k.exr" in a ReflectionProbe)
631
651
  // Inject the file into the document as a Texture so PMREM can process it
632
652
  if (!isTexturePointer && value.match(/\.exr$/i)) {
@@ -816,6 +836,16 @@ class JsonPointerHandler {
816
836
  if (ptr.resolve(context, step))
817
837
  ptr.write();
818
838
  });
839
+ // Resolve external audio pointers: rewrite the JSON string from the original URI
840
+ // (e.g. "click.wav") to the compressed URI (e.g. "click.opus.ogg"). Refs whose
841
+ // `newUri` was never set (ffmpeg missing, compression skipped) are left untouched.
842
+ for (const ref of listAudioRefs()) {
843
+ if (ref.newUri && ref.obj[ref.key] === ref.sourceUri) {
844
+ if (this.debug)
845
+ console.log(`< Resolved external audio: ${ref.sourceUri} → ${ref.newUri}`);
846
+ ref.obj[ref.key] = ref.newUri;
847
+ }
848
+ }
819
849
  // Resolve external EXR pointers: update component data with the texture's current URI
820
850
  for (const { obj, key, texture } of this.externalExrPointers) {
821
851
  const uri = texture.getURI() || texture.getName() || '';
@@ -1,3 +1,2 @@
1
- import { Extension } from "@gltf-transform/core";
2
1
  export * from "./NEEDLE_opaque.js";
3
- export declare const ALL_EXTENSIONS: (typeof Extension)[];
2
+ export declare const ALL_EXTENSIONS: typeof import("@gltf-transform/core").Extension[];
@@ -11,6 +11,8 @@ export type PackGLTFOptions = {
11
11
  verbose?: boolean;
12
12
  /** Original source file path, used to resolve external assets (e.g. .exr files) when inputFile differs from the source (e.g. after progressive transform) */
13
13
  sourceFile?: string;
14
+ /** Populated by packGLTF with absolute paths to external sidecar files produced during processing (e.g. .pmrem.ktx2) */
15
+ externalFiles?: string[];
14
16
  };
15
17
  /** Compress a glTF file
16
18
  */
@@ -6,8 +6,8 @@ import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
6
6
  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
- import { isLOD, isMeshLOD, needle_animation_transform, needle_asset, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
- import { copyExternalResources, getOutputPath, getVersion, ioTryReadWithMissingResources, writeNodeIO } from "../utils/index.js";
9
+ import { clearAudioRegistry, isLOD, isMeshLOD, listAudioRefs, needle_animation_transform, needle_asset, needle_audio_transform, needle_mesh_transform, needle_texture_transform, } from '../transforms/index.js';
10
+ import { copyExternalResources, getOutputPath, getVersion, isMaterialOnlyGLB, 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
@@ -70,6 +70,7 @@ export async function packGLTF(inputFile, outputFile, options) {
70
70
  // Use sourceFile when available (e.g. when inputFile is a progressive output copy)
71
71
  const assetSourceFile = options.sourceFile ?? inputFile;
72
72
  setExtensionInputFile(assetSourceFile);
73
+ clearAudioRegistry();
73
74
  const document = await ioTryReadWithMissingResources(io, inputFile);
74
75
  if (options.logger) {
75
76
  document.setLogger(options.logger);
@@ -96,6 +97,7 @@ export async function packGLTF(inputFile, outputFile, options) {
96
97
  config,
97
98
  }),
98
99
  needle_mesh_transform({ file: inputFile, config }),
100
+ needle_audio_transform({ outfile: outputFile, useCache, config }),
99
101
  needle_animation_transform({}),
100
102
  resample({
101
103
  tolerance: 1e-6, // NOTE: lowered from 1e-4 to 1e-6 to fix https://linear.app/needle/issue/NE-6872
@@ -105,8 +107,11 @@ export async function packGLTF(inputFile, outputFile, options) {
105
107
  dedup({ propertyTypes: [PropertyType.MESH, PropertyType.MATERIAL] }),
106
108
  ];
107
109
  const isProgressiveAsset = isLOD(inputFile);
108
- // TODO: progressive mesh LODs should not be pruned right now because it will remove e.g. UVs from meshes (and probably blend shapes too) (so we just not do it for any progressive asset)
109
- if (!isProgressiveAsset) {
110
+ const materialOnly = isMaterialOnlyGLB(document);
111
+ // TODO: progressive mesh LODs should not be pruned right now because it will remove e.g. UVs from meshes (and probably blend shapes too) (so we just not do it for any progressive asset)
112
+ // Material-only GLBs should not be pruned at all — they have no meshes so
113
+ // prune would strip materials, textures, and buffers, producing an empty file.
114
+ if (!isProgressiveAsset && !materialOnly) {
110
115
  transforms.push(prune({
111
116
  // Pruning animations is not supported yet (e.g. if the animation is only referenced by a component)
112
117
  propertyTypes: [
@@ -123,6 +128,36 @@ export async function packGLTF(inputFile, outputFile, options) {
123
128
  }));
124
129
  }
125
130
  await document.transform(...transforms);
131
+ // Collect external sidecar files (e.g. .pmrem.ktx2) produced by transforms
132
+ if (options.externalFiles) {
133
+ const outDir = path.dirname(outputFile);
134
+ const root = document.getRoot();
135
+ for (const tex of root.listTextures()) {
136
+ const uri = tex.getURI();
137
+ if (uri && !uri.startsWith('data:')) {
138
+ const absPath = path.resolve(outDir, uri);
139
+ if (existsSync(absPath)) {
140
+ options.externalFiles.push(absPath);
141
+ }
142
+ }
143
+ }
144
+ for (const buf of root.listBuffers()) {
145
+ const uri = buf.getURI();
146
+ if (uri && !uri.startsWith('data:')) {
147
+ const absPath = path.resolve(outDir, uri);
148
+ if (existsSync(absPath)) {
149
+ options.externalFiles.push(absPath);
150
+ }
151
+ }
152
+ }
153
+ for (const ref of listAudioRefs()) {
154
+ const uri = ref.newUri ?? ref.sourceUri;
155
+ const absPath = path.resolve(outDir, uri);
156
+ if (existsSync(absPath)) {
157
+ options.externalFiles.push(absPath);
158
+ }
159
+ }
160
+ }
126
161
  logger.debug(`← Writing to ${outputFile}`);
127
162
  await writeNodeIO(io, outputFile, document);
128
163
  if (useCache && cacheKey != undefined && existsSync(outputFile))
@@ -7,3 +7,6 @@ export * from "./needle_mesh_transform.js";
7
7
  export * from "./needle_common.js";
8
8
  export * from "./needle_texture_transform.js";
9
9
  export * from "./needle_animation_transform.js";
10
+ export * from "./needle_audio.js";
11
+ export * from "./needle_audio_registry.js";
12
+ export * from "./needle_audio_ffmpeg.js";
@@ -7,3 +7,6 @@ export * from "./needle_mesh_transform.js";
7
7
  export * from "./needle_common.js";
8
8
  export * from "./needle_texture_transform.js";
9
9
  export * from "./needle_animation_transform.js";
10
+ export * from "./needle_audio.js";
11
+ export * from "./needle_audio_registry.js";
12
+ export * from "./needle_audio_ffmpeg.js";
@@ -0,0 +1,12 @@
1
+ import { Transform } from '@gltf-transform/core';
2
+ import { Config } from '../config/index.js';
3
+ export type AudioTransformOptions = {
4
+ /** The path the GLB will be written to. Audio sidecars are placed alongside. */
5
+ outfile: string;
6
+ /** Disk cache toggle */
7
+ useCache: boolean;
8
+ config: Config;
9
+ };
10
+ export declare function needle_audio_transform(options: AudioTransformOptions): Transform;
11
+ /** "click.wav" → "click.opus.ogg". Preserves the basename casing. */
12
+ export declare function computeOpusOggUri(sourceUri: string): string;