@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.
- package/README.md +29 -2
- package/dist/cache/cache.d.ts +1 -0
- package/dist/cache/cache.js +35 -15
- package/dist/cache/job-cache.d.ts +2 -0
- package/dist/cache/job-cache.js +2 -0
- package/dist/cli/index.js +138 -7
- package/dist/config/index.d.ts +11 -0
- package/dist/config/index.js +9 -4
- package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.d.ts +2 -2
- package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.js +30 -0
- package/dist/extensions/NEEDLE_opaque/index.d.ts +1 -2
- package/dist/scripts/pack-gltf.d.ts +2 -0
- package/dist/scripts/pack-gltf.js +39 -4
- package/dist/transforms/index.d.ts +3 -0
- package/dist/transforms/index.js +3 -0
- package/dist/transforms/needle_audio.d.ts +12 -0
- package/dist/transforms/needle_audio.js +104 -0
- package/dist/transforms/needle_audio_ffmpeg.d.ts +14 -0
- package/dist/transforms/needle_audio_ffmpeg.js +51 -0
- package/dist/transforms/needle_audio_registry.d.ts +38 -0
- package/dist/transforms/needle_audio_registry.js +47 -0
- package/dist/transforms/needle_pmrem.d.ts +27 -7
- package/dist/transforms/needle_pmrem.js +104 -95
- package/dist/transforms/needle_progressive.d.ts +9 -1
- package/dist/transforms/needle_progressive.js +30 -14
- package/dist/transforms/needle_texture_transform.js +4 -0
- package/dist/transforms/toktx.js +23 -36
- package/dist/utils/index.d.ts +0 -1
- package/dist/utils/index.js +0 -1
- package/dist/utils/stats.js +12 -4
- package/dist/utils/validate.d.ts +4 -1
- package/dist/utils/validate.js +7 -0
- package/dist/utils/version.gen.d.ts +1 -1
- package/dist/utils/version.gen.js +1 -1
- package/package.json +1 -1
- package/tools/pmrem/pkg/pmrem_wasm_bg.wasm +0 -0
- package/dist/utils/merge.d.ts +0 -12
- 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
|
|
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
|
|
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.
|
package/dist/cache/cache.d.ts
CHANGED
|
@@ -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.
|
package/dist/cache/cache.js
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
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
|
|
223
|
+
hash = combineHash(hash, hashObject(weights));
|
|
207
224
|
const primitives = property.listPrimitives();
|
|
208
225
|
for (const prim of primitives) {
|
|
209
|
-
hash
|
|
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
|
|
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
|
|
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
|
|
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
|
|
266
|
+
hash = combineHash(hash, hashObject(property));
|
|
247
267
|
}
|
|
248
|
-
return
|
|
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
|
/**
|
package/dist/cache/job-cache.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
64
|
-
|
|
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.
|
package/dist/config/index.d.ts
CHANGED
|
@@ -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.
|
package/dist/config/index.js
CHANGED
|
@@ -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):
|
|
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() || '';
|
|
@@ -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
|
-
|
|
109
|
-
|
|
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";
|
package/dist/transforms/index.js
CHANGED
|
@@ -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;
|