@needle-tools/gltf-build-pipeline 3.0.0-next.73f1606 → 3.0.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,53 @@ All notable changes to this package will be documented in this file.
4
4
  The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
5
5
  and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.0.0] - 2026-06-23
8
+
9
+ First stable 3.0 release. Summary of the headline changes across the 3.0 alpha/beta cycle.
10
+
11
+ ### Breaking
12
+ - The CLI now requires JWT authentication via `--auth-token`. A missing, malformed, or expired token aborts the run with a clear message that includes the pipeline version.
13
+ - The package ships as a CLI only.
14
+
15
+ ### Added
16
+ - **Job cache**: a file-level cache that skips an entire transform when the same input + config was already processed.
17
+ - **Audio compression**: external audio references are re-encoded to Opus/OGG. Supported input formats are WAV, FLAC, and AIFF; already-lossy formats pass through unchanged.
18
+ - **PMREM CLI command**: `needle-gltf pmrem <input>` converts EXR and Radiance HDR files to PMREM KTX2.
19
+ - **Multiple file inputs**: `transform` accepts comma-separated paths (e.g. `needle-gltf transform a.glb,b.glb out/`).
20
+ - **Diagnostic logging**: authentication failures (with secrets redacted) and pipeline errors — compression failures and unhandled exceptions — are written to a log file to make issues easier to diagnose.
21
+
22
+ ### Fixed
23
+ - VRM thumbnails (and other textures referenced only through an opaque extension) no longer dangle to the wrong image after prune/dedup re-indexing. A stale `KHR_texture_basisu.source` could point past the end of the image array and crash three.js on load (`Cannot read properties of undefined (reading 'uri')`), or silently resolve to a neighbouring image. Re-running the pipeline on an already-compressed VRM is now covered by tests.
24
+ - Audio URIs use forward slashes on every platform; on Windows a `path.join`-built clip path produced backslashes that loaders could not resolve (only affected audio in a subfolder).
25
+ - Cache restore now also restores external files (KTX2, PMREM) alongside the main GLB.
26
+ - PMREM NaN generation that caused artifacts in certain EXR files.
27
+ - xxHash WASM init race condition, now gated by `ensureHashReady`.
28
+ - LOD file detection no longer matches against directory names.
29
+ - toktx now probes the `ktx` binary to match what the compression step actually runs.
30
+ - `getConfig` returns a fresh copy instead of a shared mutable reference.
31
+ - `stats` tolerates undefined errors/warnings in `addFrom` and exits with code 0.
32
+ - Material-only GLBs are no longer incorrectly pruned.
33
+ - `createOpaqueExtension` return type fixed, downstream casts removed.
34
+
35
+ ## [3.0.0-beta.3] - 2026-06-10
36
+
37
+ ### Added
38
+ - Pipeline errors (including compression failures and unhandled exceptions) are now written to the log file to aid diagnosis.
39
+
40
+ ## [3.0.0-beta.2] - 2026-06-10
41
+
42
+ ### Added
43
+ - Authentication failures are now written to a log file (with secrets redacted) to make auth issues easier to diagnose.
44
+
45
+ ### Changed
46
+ - The "Auth failed" message now includes the pipeline version.
47
+
48
+ ## [3.0.0-beta.1] - 2026-06-10
49
+
50
+ ### Fixed
51
+ - VRM thumbnail (and other textures referenced only through an opaque extension) no longer dangle to the wrong image after prune/dedup re-indexing. When an image was deduped, an extension-carried `KHR_texture_basisu.source` was copied verbatim and left stale — the thumbnail's source could point past the end of the image array and crash three.js on load (`Cannot read properties of undefined (reading 'uri')`), while other affected textures silently resolved to a neighbouring image. Re-running the pipeline on an already-compressed VRM is now covered by tests.
52
+ - Audio URIs now use forward slashes on every platform. On Windows the rewritten clip path was built with `path.join`, producing backslashes that loaders cannot resolve (only affected audio assets in a subfolder).
53
+
7
54
  ## [3.0.0-alpha] - 2026-05-26
8
55
 
9
56
  ### Breaking
package/dist/cli/index.js CHANGED
@@ -8,7 +8,7 @@ import path5 from "path";
8
8
  import { Logger, NodeIO as NodeIO2, PropertyType as PropertyType12, Verbosity as Verbosity2 } from "@gltf-transform/core";
9
9
  import { ALL_EXTENSIONS as ALL_EXTENSIONS3 } from "@gltf-transform/extensions";
10
10
  import draco3d2 from "draco3dgltf";
11
- import { existsSync as existsSync11, readFileSync as readFileSync8, statSync as statSync5, writeFileSync as writeFileSync6 } from "fs";
11
+ import { existsSync as existsSync12, readFileSync as readFileSync8, statSync as statSync6, writeFileSync as writeFileSync6 } from "fs";
12
12
  import { MeshoptDecoder as MeshoptDecoder2, MeshoptEncoder as MeshoptEncoder3 } from "meshoptimizer";
13
13
  import { dedup as dedup2, metalRough as metalRough2, prune as prune2, resample } from "@gltf-transform/functions";
14
14
 
@@ -689,7 +689,11 @@ var GenericWriter = class {
689
689
  }
690
690
  this.debug && console.log("Could not find texture, creating a new one: " + this._debugName);
691
691
  let extensions = this.inputDefinition.extensions ? { ...this.inputDefinition.extensions } : void 0;
692
- extensions && (delete extensions[NEEDLE_progressive_texture_settings.EXTENSION_NAME], extensions.EXT_texture_exr && this.inputObject.getMimeType() !== "image/exr" && delete extensions.EXT_texture_exr, Object.keys(extensions).length === 0 && (extensions = void 0));
692
+ if (extensions && (delete extensions[NEEDLE_progressive_texture_settings.EXTENSION_NAME], extensions.EXT_texture_exr && this.inputObject.getMimeType() !== "image/exr" && delete extensions.EXT_texture_exr, Object.keys(extensions).length === 0 && (extensions = void 0)), extensions)
693
+ for (let key of Object.keys(extensions)) {
694
+ let extDef = extensions[key];
695
+ extDef?.source !== void 0 && (extensions[key] = { ...extDef, source: imgIndex });
696
+ }
693
697
  let textureDef = {
694
698
  source: extensions && Object.keys(extensions).some((key) => extensions[key]?.source !== void 0) ? void 0 : imgIndex,
695
699
  sampler: this.findOrCreateSampler(context.jsonDoc.json),
@@ -1190,7 +1194,7 @@ function registerExtensions(node) {
1190
1194
  import { createTransform } from "@gltf-transform/functions";
1191
1195
 
1192
1196
  // src/utils/version.gen.ts
1193
- var version = "3.0.0-next.73f1606";
1197
+ var version = "3.0.0";
1194
1198
 
1195
1199
  // src/utils/version.ts
1196
1200
  function getVersion(_marklocal = !0) {
@@ -3600,15 +3604,38 @@ function needle_audio_transform(options) {
3600
3604
  }
3601
3605
  function computeOpusOggUri(sourceUri) {
3602
3606
  let dir = dirname6(sourceUri), ext = extname(sourceUri), newName = `${basename3(sourceUri, ext)}.opus.ogg`;
3603
- return dir === "." || dir === "" ? newName : join6(dir, newName);
3607
+ return (dir === "." || dir === "" ? newName : `${dir}/${newName}`).replaceAll("\\", "/");
3608
+ }
3609
+
3610
+ // src/utils/logfile.ts
3611
+ import { appendFileSync, existsSync as existsSync11, mkdirSync as mkdirSync8, renameSync, statSync as statSync5 } from "fs";
3612
+ import { join as join7 } from "path";
3613
+ import { homedir } from "os";
3614
+ var LOG_DIR = join7(homedir(), ".needle", "optimization-pipeline", "logs"), LOG_FILE = join7(LOG_DIR, "pipeline.log"), PREV_LOG_FILE = join7(LOG_DIR, "pipeline.prev.log"), MAX_LOG_BYTES = 1024 * 1024;
3615
+ function redactSecrets(message) {
3616
+ return message.replace(/([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1").replace(/\b[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "[REDACTED_JWT]").replace(/\bnc_[A-Za-z0-9_-]{6,}\b/g, "[REDACTED_TOKEN]");
3617
+ }
3618
+ function logToFile(message) {
3619
+ try {
3620
+ mkdirSync8(LOG_DIR, { recursive: !0 });
3621
+ try {
3622
+ existsSync11(LOG_FILE) && statSync5(LOG_FILE).size > MAX_LOG_BYTES && renameSync(LOG_FILE, PREV_LOG_FILE);
3623
+ } catch {
3624
+ }
3625
+ appendFileSync(LOG_FILE, `[${(/* @__PURE__ */ new Date()).toISOString()}] ${redactSecrets(message)}
3626
+ `);
3627
+ } catch {
3628
+ }
3604
3629
  }
3605
3630
 
3606
3631
  // src/scripts/pack-gltf.ts
3607
3632
  async function packGLTF(inputFile, outputFile, options) {
3608
3633
  options.logger || (options.logger = new Logger(Verbosity2.INFO));
3609
3634
  let { debug, stats, packedFiles, useCache, logger } = options;
3610
- if (!existsSync11(inputFile))
3611
- return logger.error(`WARN: can not pack, file does not exist: "${inputFile}"`), !1;
3635
+ if (!existsSync12(inputFile)) {
3636
+ let message = `WARN: can not pack, file does not exist: "${inputFile}"`;
3637
+ return logger.error(message), logToFile(message), !1;
3638
+ }
3612
3639
  let cacheKey;
3613
3640
  if (useCache && isMeshLOD(inputFile)) {
3614
3641
  let existing = readFileSync8(inputFile);
@@ -3624,7 +3651,7 @@ async function packGLTF(inputFile, outputFile, options) {
3624
3651
  try {
3625
3652
  let config = options.config;
3626
3653
  outputFile ? outputFile = getOutputPath(inputFile, outputFile) : outputFile = inputFile;
3627
- let prevSizeInMB = statSync5(inputFile).size / 1024 / 1024;
3654
+ let prevSizeInMB = statSync6(inputFile).size / 1024 / 1024;
3628
3655
  logger.debug(`\u2192 Compressing ${prevSizeInMB.toFixed(1)} MB: ${inputFile}`);
3629
3656
  let startTime = Date.now(), io = new NodeIO2().registerExtensions(ALL_EXTENSIONS3).registerExtensions(ALL_EXTENSIONS).registerExtensions([
3630
3657
  NEEDLE_compression_texture,
@@ -3686,32 +3713,32 @@ async function packGLTF(inputFile, outputFile, options) {
3686
3713
  let uri = tex.getURI();
3687
3714
  if (uri && !uri.startsWith("data:")) {
3688
3715
  let absPath = path5.resolve(outDir, uri);
3689
- existsSync11(absPath) && options.externalFiles.push(absPath);
3716
+ existsSync12(absPath) && options.externalFiles.push(absPath);
3690
3717
  }
3691
3718
  }
3692
3719
  for (let buf of root.listBuffers()) {
3693
3720
  let uri = buf.getURI();
3694
3721
  if (uri && !uri.startsWith("data:")) {
3695
3722
  let absPath = path5.resolve(outDir, uri);
3696
- existsSync11(absPath) && options.externalFiles.push(absPath);
3723
+ existsSync12(absPath) && options.externalFiles.push(absPath);
3697
3724
  }
3698
3725
  }
3699
3726
  for (let ref of listAudioRefs()) {
3700
3727
  let uri = ref.newUri ?? ref.sourceUri, absPath = path5.resolve(outDir, uri);
3701
- existsSync11(absPath) && options.externalFiles.push(absPath);
3728
+ existsSync12(absPath) && options.externalFiles.push(absPath);
3702
3729
  }
3703
3730
  }
3704
- logger.debug(`\u2190 Writing to ${outputFile}`), await writeNodeIO(io, outputFile, document), useCache && cacheKey != null && existsSync11(outputFile) && addToCache(cacheKey, readFileSync8(outputFile));
3705
- let duration = Date.now() - startTime, sizeInMB = statSync5(outputFile).size / 1024 / 1024, filename = path5.basename(outputFile), gpuMemoryInMB = totalGPUTextureMemory / 1024 / 1024;
3731
+ logger.debug(`\u2190 Writing to ${outputFile}`), await writeNodeIO(io, outputFile, document), useCache && cacheKey != null && existsSync12(outputFile) && addToCache(cacheKey, readFileSync8(outputFile));
3732
+ let duration = Date.now() - startTime, sizeInMB = statSync6(outputFile).size / 1024 / 1024, filename = path5.basename(outputFile), gpuMemoryInMB = totalGPUTextureMemory / 1024 / 1024;
3706
3733
  return logger.info(`\u2190 Compressed '${filename}' ${prevSizeInMB.toFixed(1)} MB \u2192 ${sizeInMB.toFixed(1)} MB (GPU: ${gpuMemoryInMB.toFixed(1)} MB textures) in ${(duration / 1e3).toFixed(1)} sec`), stats && (stats.totalFilesProcessed++, stats.totalFileSizeInMB += sizeInMB, stats.totalFileSizeInMBBefore += prevSizeInMB, stats.totalGPUMemoryInMB += gpuMemoryInMB), !0;
3707
3734
  } catch (err) {
3708
3735
  if (err instanceof TestAssertionError) throw err;
3709
3736
  let shortedStackTrace = err instanceof Error && err.stack ? err.stack.split(`
3710
3737
  `).slice(0, 2).join(`
3711
- `) : "";
3712
- if (logger.error(`Packing of "${path5.basename(inputFile)}" using version ${getVersion()} failed with error:
3738
+ `) : "", message = `Packing of "${path5.basename(inputFile)}" using version ${getVersion()} failed with error:
3713
3739
  ${err instanceof Error ? err.message : String(err)}
3714
- ${shortedStackTrace}`), debug) throw err;
3740
+ ${shortedStackTrace}`;
3741
+ if (logger.error(message), logToFile(message), debug) throw err;
3715
3742
  return !1;
3716
3743
  }
3717
3744
  }
@@ -3733,7 +3760,7 @@ async function calculateStats(inputfile, options) {
3733
3760
 
3734
3761
  // src/config/index.ts
3735
3762
  import { Logger as Logger3, Verbosity as Verbosity4 } from "@gltf-transform/core";
3736
- import { existsSync as existsSync12, readFileSync as readFileSync9, statSync as statSync6 } from "fs";
3763
+ import { existsSync as existsSync13, readFileSync as readFileSync9, statSync as statSync7 } from "fs";
3737
3764
  import path6, { dirname as dirname7, resolve as resolve2 } from "path";
3738
3765
  var UsecaseOptions = ["product", "world"], defaultConfig = {
3739
3766
  usecase: "default"
@@ -3748,9 +3775,9 @@ function createConfig(usecase) {
3748
3775
  function getConfig(configPath, fallbackSearchDirectory, opts) {
3749
3776
  let logger = opts?.logger;
3750
3777
  if (logger || (logger = new Logger3(Verbosity4.WARN)), configPath != null) {
3751
- if (configPath = resolve2(process.cwd(), configPath), !existsSync12(configPath))
3778
+ if (configPath = resolve2(process.cwd(), configPath), !existsSync13(configPath))
3752
3779
  throw new Error(`No config found at "${configPath}"`);
3753
- if (statSync6(configPath).isDirectory()) {
3780
+ if (statSync7(configPath).isDirectory()) {
3754
3781
  let res2 = searchConfig(configPath, logger);
3755
3782
  if (res2)
3756
3783
  return res2;
@@ -3771,9 +3798,9 @@ function getConfig(configPath, fallbackSearchDirectory, opts) {
3771
3798
  }
3772
3799
  var _cachedConfig;
3773
3800
  function searchConfig(directory, logger) {
3774
- for (statSync6(directory).isFile() && (directory = dirname7(directory)), directory = directory.replaceAll("\\", "/"); directory.length > 0; ) {
3801
+ for (statSync7(directory).isFile() && (directory = dirname7(directory)), directory = directory.replaceAll("\\", "/"); directory.length > 0; ) {
3775
3802
  let configPath = path6.join(directory, "needle.config.json");
3776
- if (existsSync12(configPath)) {
3803
+ if (existsSync13(configPath)) {
3777
3804
  let content = readFileSync9(configPath, "utf8"), config = resolveConfig(configPath, content, logger);
3778
3805
  if (config)
3779
3806
  return config;
@@ -3805,7 +3832,7 @@ function resolveConfig(filename, content, logger) {
3805
3832
  }
3806
3833
 
3807
3834
  // src/cli/index.ts
3808
- import { existsSync as existsSync13, readFileSync as readFileSync10, readdirSync as readdirSync3, statSync as statSync7, writeFileSync as writeFileSync7 } from "fs";
3835
+ import { existsSync as existsSync15, readFileSync as readFileSync11, readdirSync as readdirSync3, statSync as statSync8, writeFileSync as writeFileSync7 } from "fs";
3809
3836
  import path7, { basename as basename4, dirname as dirname8, resolve as resolve3 } from "path";
3810
3837
 
3811
3838
  // src/constants.ts
@@ -3871,6 +3898,13 @@ function trackError(error, context) {
3871
3898
  return;
3872
3899
  }
3873
3900
  recentErrors.set(key, { time: now, count: (cached?.count || 0) + 1 });
3901
+ let ctx = [
3902
+ context?.mode && `mode=${context.mode}`,
3903
+ context?.usecase && `usecase=${context.usecase}`,
3904
+ context?.file && `file=${context.file}`
3905
+ ].filter(Boolean).join(", ");
3906
+ logToFile(`[error] ${errorName}: ${message}${ctx ? ` (${ctx})` : ""}${stack ? `
3907
+ ${stack}` : ""}`);
3874
3908
  let properties = {
3875
3909
  version: getVersion(),
3876
3910
  error_name: errorName + (cached && cached.count > 1 ? ` x${cached.count}` : ""),
@@ -3922,23 +3956,42 @@ var PROD_KEYS = [
3922
3956
  var trusted = [...PROD_KEYS], TRUSTED_KEYS = trusted;
3923
3957
 
3924
3958
  // src/auth/verify.ts
3959
+ import { readFileSync as readFileSync10, existsSync as existsSync14 } from "fs";
3960
+ import { join as join9 } from "path";
3961
+ import { homedir as homedir2 } from "os";
3925
3962
  var AuthError = class extends Error {
3926
3963
  constructor(message) {
3927
3964
  super(message), this.name = "AuthError";
3928
3965
  }
3929
3966
  }, LOCAL_FETCH_TIMEOUT_MS = 2e3;
3967
+ function getLocalServerToken() {
3968
+ try {
3969
+ let file = join9(homedir2(), ".needle", "local-server-token");
3970
+ if (!existsSync14(file)) return null;
3971
+ let data = JSON.parse(readFileSync10(file, "utf-8"));
3972
+ return typeof data.token == "string" ? data.token : null;
3973
+ } catch (err) {
3974
+ return logToFile(`[auth] failed reading local-server-token (${err instanceof Error ? err.name : "unknown error"})`), null;
3975
+ }
3976
+ }
3930
3977
  async function tryFetchLocalToken(org) {
3978
+ let baseUrl = process.env.NEEDLE_LICENSE_SERVER_URL || "http://localhost:8424/api/license", safeServer = "local license server";
3931
3979
  try {
3932
- let baseUrl = process.env.NEEDLE_LICENSE_SERVER_URL || "http://localhost:8424/api/license", url = new URL(baseUrl);
3933
- org && url.searchParams.set("org", org);
3980
+ let url = new URL(baseUrl);
3981
+ org && url.searchParams.set("org", org), safeServer = `${url.origin}${url.pathname}`;
3982
+ let headers = {}, serverToken = getLocalServerToken();
3983
+ serverToken && (headers.Authorization = `Bearer ${serverToken}`);
3934
3984
  let res = await fetch(url, {
3935
- signal: AbortSignal.timeout(LOCAL_FETCH_TIMEOUT_MS)
3985
+ signal: AbortSignal.timeout(LOCAL_FETCH_TIMEOUT_MS),
3986
+ headers
3936
3987
  });
3937
- if (!res.ok) return null;
3988
+ if (!res.ok)
3989
+ return logToFile(`[auth] license server (${safeServer}) returned HTTP ${res.status} ${res.statusText}${org ? ` \u2014 verify the --org value "${org}"` : ""}`), null;
3938
3990
  let jwt = (await res.json()).needle_license_jwt;
3939
- return typeof jwt == "string" && jwt.length > 0 ? jwt : null;
3940
- } catch {
3941
- return null;
3991
+ return typeof jwt == "string" && jwt.length > 0 ? jwt : (logToFile(`[auth] license server (${safeServer}) responded without a license token${org ? ` for org "${org}"` : ""}`), null);
3992
+ } catch (err) {
3993
+ let msg = err instanceof Error ? err.message : String(err);
3994
+ return logToFile(`[auth] could not reach license server (${safeServer})${org ? ` for org "${org}"` : ""}: ${msg}`), null;
3942
3995
  }
3943
3996
  }
3944
3997
  async function verifyWithKey(token, key) {
@@ -3983,21 +4036,31 @@ async function requireAuth(cliToken, org) {
3983
4036
  let token = typeof cliToken == "string" && cliToken.length > 0 ? cliToken : process.env.NEEDLE_CLOUD_TOKEN;
3984
4037
  if ((!token || typeof token == "string" && token.length === 0) && (token = await tryFetchLocalToken(org ?? process.env.NEEDLE_CLOUD_ORG)), !token)
3985
4038
  throw new AuthError(
3986
- "No valid auth token found. Provide one via --auth-token, NEEDLE_CLOUD_TOKEN env, or log in to the needle-cloud CLI (npx needle-cloud@version-1 start --restart). If you are logged in but still see this error, try updating: npx needle-cloud@version-1 start --restart"
4039
+ "No valid auth token found. Provide one via --auth-token, NEEDLE_CLOUD_TOKEN env, or log in to the needle-cloud CLI (npx needle-cloud@version-2 start --restart). If you are logged in but still see this error, try updating: npx needle-cloud@version-2 start --restart"
3987
4040
  );
3988
4041
  await verifyAuthToken(token);
3989
4042
  }
3990
4043
 
3991
4044
  // src/cli/index.ts
4045
+ function warnUnknownOptions(options, logger) {
4046
+ if (!process.env.NEEDLE_WORKER) return;
4047
+ let knownKeys = new Set(Object.keys(options));
4048
+ for (let arg of process.argv) {
4049
+ if (!arg.startsWith("--")) continue;
4050
+ let raw = arg.split("=")[0].replace(/^--/, ""), key = raw.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
4051
+ knownKeys.has(key) || logger.warn(`[Needle Build Pipeline] v${getVersion()} Unknown option --${raw}, ignoring.`);
4052
+ }
4053
+ }
3992
4054
  async function gateAuth(authToken, org, logger) {
3993
4055
  try {
3994
4056
  await requireAuth(authToken, typeof org == "string" ? org : void 0);
3995
4057
  } catch (err) {
3996
4058
  let message = err instanceof AuthError || err instanceof Error ? err.message : String(err);
3997
- logger.error(`[Needle Build Pipeline] Auth failed: ${message}`), process.exit(ERROR_CODES.AUTH_FAILED);
4059
+ logger.error(`[Needle Build Pipeline] v${getVersion()} Auth failed: ${message}`), logToFile(`[Needle Build Pipeline] v${getVersion()} Auth failed: ${message}`), process.exit(ERROR_CODES.AUTH_FAILED);
3998
4060
  }
3999
4061
  }
4000
4062
  program.cast(!1);
4063
+ process.env.NEEDLE_WORKER && program.strict(!1);
4001
4064
  program.command("version", "Print current version").action(async ({ logger }) => {
4002
4065
  logger.info(getVersion());
4003
4066
  }).command("clear-caches", "Clear all caches").help(`Clears all cached files
@@ -4005,8 +4068,8 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
4005
4068
  `).action(async ({ logger }) => {
4006
4069
  logger.info("Clearing caches"), clearCache(logger);
4007
4070
  }).command("stats", "Calculate glTF stats").help(`
4008
- `).argument("<input>", "Path to a glTF, GLB or VRM file OR a directory").argument("<output>", "Output path for the stats", { default: null, validator: program.STRING }).option("--verbose", "Enable verbose output", { validator: program.BOOLEAN, default: !1 }).option("--auth-token <jwt>", "Required. A JWT minted by needle-cloud (alternatively set NEEDLE_CLOUD_TOKEN). Note: an `nc_*` needle-cloud access token is NOT a JWT and will be rejected.", { validator: program.STRING }).option("--org <org>", "Organization ID for local license-server fallback (or set NEEDLE_CLOUD_ORG).", { validator: program.STRING }).action(async ({ logger, args, options }) => {
4009
- await gateAuth(options.authToken, options.org, logger), logger.level = options.verbose ? "debug" : "info", await ensureHashReady(), logger.info('Calculating stats for "' + args.input + '"');
4071
+ `).argument("<input>", "Path to a glTF, GLB or VRM file OR a directory").argument("<output>", "Output path for the stats", { default: "", validator: program.STRING }).option("--verbose", "Enable verbose output", { validator: program.BOOLEAN, default: !1 }).option("--auth-token <jwt>", "Required. A JWT minted by needle-cloud (alternatively set NEEDLE_CLOUD_TOKEN). Note: an `nc_*` needle-cloud access token is NOT a JWT and will be rejected.", { validator: program.STRING }).option("--org <org>", "Organization ID for local license-server fallback (or set NEEDLE_CLOUD_ORG).", { validator: program.STRING }).action(async ({ logger, args, options }) => {
4072
+ warnUnknownOptions(options, logger), await gateAuth(options.authToken, options.org, logger), logger.level = options.verbose ? "debug" : "info", await ensureHashReady(), logger.info('Calculating stats for "' + args.input + '"');
4010
4073
  let input = args.input.toString(), filestats = new Array();
4011
4074
  await foreachGLTF(input, async (file) => {
4012
4075
  let stats = await calculateStats(file, { verbose: !!options.verbose });
@@ -4017,9 +4080,9 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
4017
4080
  }).command("transform", "Transform (progressive loading) and compress glTF, GLB or VRM files").help(`
4018
4081
  This will produce multiple versions of the input file, each with a different level of optimization.
4019
4082
  Each version will be compressed and written to the output directory.
4020
- `).argument("<input>", "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed. Multiple files can be separated by commas.", { validator: program.STRING }).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 }).option("--compress", "When enabled files will be compressed", { validator: program.BOOLEAN, default: !0 }).option("--progressive", "When enabled files will processed to be progressively loaded", { validator: program.BOOLEAN, default: !0 }).option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}]`, { validator: program.STRING }).option("--config <config>", 'The path to a JSON file containing the compression settings. This can also be a needle.config.json file with a "compression" object or usecase string.', { validator: program.STRING }).option("--cache", `Use "--cache False" to disable caching.
4083
+ `).argument("<input>", "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed. Multiple files can be separated by commas.", { validator: program.STRING }).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: "", validator: program.STRING }).option("--compress", "When enabled files will be compressed", { validator: program.BOOLEAN, default: !0 }).option("--progressive", "When enabled files will processed to be progressively loaded", { validator: program.BOOLEAN, default: !0 }).option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}]`, { validator: program.STRING }).option("--config <config>", 'The path to a JSON file containing the compression settings. This can also be a needle.config.json file with a "compression" object or usecase string.', { validator: program.STRING }).option("--cache", `Use "--cache False" to disable caching.
4021
4084
  When caching is enabled then previously processed files will be detected and loaded from disc cache instead of being processed again if settings or the file contents have not changed.`, { validator: program.BOOLEAN, default: !0 }).option("--debug", 'Enable for more verbose outout with "--debug True"', { validator: program.BOOLEAN, default: !1 }).option("--verbose", 'Enable for more verbose outout with "--verbose True"', { validator: program.BOOLEAN, default: !1 }).option("--auth-token <jwt>", "Required. A JWT minted by needle-cloud (alternatively set NEEDLE_CLOUD_TOKEN). Note: an `nc_*` needle-cloud access token is NOT a JWT and will be rejected.", { validator: program.STRING }).option("--org <org>", "Organization ID for local license-server fallback (or set NEEDLE_CLOUD_ORG).", { validator: program.STRING }).action(async ({ logger, args, options }) => {
4022
- if (await gateAuth(options.authToken, options.org, logger), logger.level = options.debug || options.verbose ? "debug" : "info", options.compress === !1 && options.progressive === !1) {
4085
+ if (warnUnknownOptions(options, logger), await gateAuth(options.authToken, options.org, logger), logger.level = options.debug || options.verbose ? "debug" : "info", options.compress === !1 && options.progressive === !1) {
4023
4086
  logger.error(`[Needle Build Pipeline] v${getVersion()} You need to enable at least one of the options: --compress or --progressive`);
4024
4087
  return;
4025
4088
  }
@@ -4047,7 +4110,7 @@ When caching is enabled then previously processed files will be detected and loa
4047
4110
  }
4048
4111
  let set = /* @__PURE__ */ new Set(), useCache = options.cache !== !1, debug = options.debug === !0, verbose = options.verbose === !0;
4049
4112
  await foreachGLTF(inputPaths, async (file) => {
4050
- if (existsSync13(file)) {
4113
+ if (existsSync15(file)) {
4051
4114
  if (file != input && options.progressive === !0 && isLOD(file)) {
4052
4115
  logger.debug(`\u2192 Ignore existing LOD file at ${file}`);
4053
4116
  return;
@@ -4063,12 +4126,12 @@ When caching is enabled then previously processed files will be detected and loa
4063
4126
  config
4064
4127
  }, file) : void 0;
4065
4128
  if (jobKey) {
4066
- let outputDir = output ? existsSync13(output) && isDirectory(output) ? output : dirname8(output) : dirname8(file), restored = tryRestoreJobResult(jobKey, outputDir, logger);
4129
+ let outputDir = output ? existsSync15(output) && isDirectory(output) ? output : dirname8(output) : dirname8(file), restored = tryRestoreJobResult(jobKey, outputDir, logger);
4067
4130
  if (restored) {
4068
- let inputFileSize = statSync7(file).size / 1024 / 1024;
4131
+ let inputFileSize = statSync8(file).size / 1024 / 1024;
4069
4132
  for (let f of restored)
4070
4133
  if (set.add(f), stats) {
4071
- let sizeInMB = statSync7(f).size / 1024 / 1024;
4134
+ let sizeInMB = statSync8(f).size / 1024 / 1024;
4072
4135
  stats.totalFilesProcessed++, stats.totalFileSizeInMB += sizeInMB, stats.totalFileSizeInMBBefore += inputFileSize;
4073
4136
  }
4074
4137
  logger.info(`\u2192 [CACHE] Restored ${restored.length} file(s) for job ${jobKey}`);
@@ -4136,16 +4199,16 @@ When caching is enabled then previously processed files will be detected and loa
4136
4199
  producing <name>.pmrem.ktx2. This is the same processing the Needle build pipeline
4137
4200
  applies to EXR textures inside GLB/GLTF files, exposed as a standalone command for
4138
4201
  raw .exr / .hdr inputs.
4139
- `).argument("<input>", "Path to a .exr/.hdr file OR a directory containing .exr/.hdr files (recursive)", { validator: program.STRING }).argument("<output>", "Output file (when input is a single .exr/.hdr) or output directory. Defaults to writing next to the input file.", { default: null, validator: program.STRING }).option("--cache", 'Use "--cache False" to disable caching.', { validator: program.BOOLEAN, default: !0 }).option("--debug", 'Enable verbose output with "--debug True"', { validator: program.BOOLEAN, default: !1 }).option("--verbose", 'Enable verbose output with "--verbose True"', { validator: program.BOOLEAN, default: !1 }).option("--auth-token <jwt>", "Required. A JWT minted by needle-cloud (alternatively set NEEDLE_CLOUD_TOKEN). Note: an `nc_*` needle-cloud access token is NOT a JWT and will be rejected.", { validator: program.STRING }).option("--org <org>", "Organization ID for local license-server fallback (or set NEEDLE_CLOUD_ORG).", { validator: program.STRING }).hide().action(async ({ logger, args, options }) => {
4140
- await gateAuth(options.authToken, options.org, logger), logger.level = options.debug || options.verbose ? "debug" : "info", await ensureHashReady();
4202
+ `).argument("<input>", "Path to a .exr/.hdr file OR a directory containing .exr/.hdr files (recursive)", { validator: program.STRING }).argument("<output>", "Output file (when input is a single .exr/.hdr) or output directory. Defaults to writing next to the input file.", { default: "", validator: program.STRING }).option("--cache", 'Use "--cache False" to disable caching.', { validator: program.BOOLEAN, default: !0 }).option("--debug", 'Enable verbose output with "--debug True"', { validator: program.BOOLEAN, default: !1 }).option("--verbose", 'Enable verbose output with "--verbose True"', { validator: program.BOOLEAN, default: !1 }).option("--auth-token <jwt>", "Required. A JWT minted by needle-cloud (alternatively set NEEDLE_CLOUD_TOKEN). Note: an `nc_*` needle-cloud access token is NOT a JWT and will be rejected.", { validator: program.STRING }).option("--org <org>", "Organization ID for local license-server fallback (or set NEEDLE_CLOUD_ORG).", { validator: program.STRING }).hide().action(async ({ logger, args, options }) => {
4203
+ warnUnknownOptions(options, logger), await gateAuth(options.authToken, options.org, logger), logger.level = options.debug || options.verbose ? "debug" : "info", await ensureHashReady();
4141
4204
  let input = args.input.toString();
4142
- existsSync13(input) || (logger.error(`Input does not exist: ${input}`), process.exit(ERROR_CODES.INVALID_ARGS));
4205
+ existsSync15(input) || (logger.error(`Input does not exist: ${input}`), process.exit(ERROR_CODES.INVALID_ARGS));
4143
4206
  let inputFiles = [], inputIsDirectory = isDirectory(input);
4144
4207
  if (inputIsDirectory ? collectPmremInputFiles(input, inputFiles) : isPmremInputFile(input) ? inputFiles.push(input) : (logger.error(`Input is not an .exr/.hdr file or directory: ${input}`), process.exit(ERROR_CODES.INVALID_ARGS)), inputFiles.length === 0) {
4145
4208
  logger.warn(`No .exr/.hdr files found in ${input}`);
4146
4209
  return;
4147
4210
  }
4148
- let rawOutput = args.output?.toString() || null, outputArg = rawOutput && rawOutput !== "null" ? rawOutput : null;
4211
+ let outputArg = args.output?.toString() || null;
4149
4212
  if (outputArg && !path7.isAbsolute(outputArg)) {
4150
4213
  let base = inputIsDirectory ? input : dirname8(input);
4151
4214
  outputArg = resolve3(base, outputArg);
@@ -4158,7 +4221,7 @@ raw .exr / .hdr inputs.
4158
4221
  for (let inPath of inputFiles) {
4159
4222
  let outPath = resolvePmremOutputPath(inPath, input, inputIsDirectory, outputArg);
4160
4223
  logger.info(`\u2192 pmrem ${inPath} \u2192 ${outPath}`);
4161
- let srcBytes = readFileSync10(inPath), bytes = new Uint8Array(srcBytes.buffer, srcBytes.byteOffset, srcBytes.byteLength), ktx2Bytes = await (inPath.toLowerCase().endsWith(".hdr") ? convertHdrBytesToPmremKtx2 : convertExrBytesToPmremKtx2)(
4224
+ let srcBytes = readFileSync11(inPath), bytes = new Uint8Array(srcBytes.buffer, srcBytes.byteOffset, srcBytes.byteLength), ktx2Bytes = await (inPath.toLowerCase().endsWith(".hdr") ? convertHdrBytesToPmremKtx2 : convertExrBytesToPmremKtx2)(
4162
4225
  runtime,
4163
4226
  bytes,
4164
4227
  { useCache, logLabel: `pmrem ${basename4(inPath)}` }
@@ -4178,7 +4241,7 @@ function collectPmremInputFiles(dir, out) {
4178
4241
  for (let entry of readdirSync3(dir)) {
4179
4242
  if (entry === "node_modules" || entry.startsWith(".")) continue;
4180
4243
  let full = path7.join(dir, entry);
4181
- statSync7(full).isDirectory() ? collectPmremInputFiles(full, out) : isPmremInputFile(entry) && out.push(full);
4244
+ statSync8(full).isDirectory() ? collectPmremInputFiles(full, out) : isPmremInputFile(entry) && out.push(full);
4182
4245
  }
4183
4246
  }
4184
4247
  function resolvePmremOutputPath(srcPath, input, inputIsDirectory, outputArg) {
@@ -4189,7 +4252,7 @@ function resolvePmremOutputPath(srcPath, input, inputIsDirectory, outputArg) {
4189
4252
  let rel = path7.relative(input, srcPath), relDir = dirname8(rel), outDir = relDir === "." ? outputArg : path7.join(outputArg, relDir);
4190
4253
  return ensureIsDirectory(outDir), path7.join(outDir, outName);
4191
4254
  }
4192
- return existsSync13(outputArg) && isDirectory(outputArg) ? path7.join(outputArg, outName) : outputArg.toLowerCase().endsWith(".ktx2") ? (ensureIsDirectory(dirname8(outputArg)), outputArg) : (ensureIsDirectory(outputArg), path7.join(outputArg, outName));
4255
+ return existsSync15(outputArg) && isDirectory(outputArg) ? path7.join(outputArg, outName) : outputArg.toLowerCase().endsWith(".ktx2") ? (ensureIsDirectory(dirname8(outputArg)), outputArg) : (ensureIsDirectory(outputArg), path7.join(outputArg, outName));
4193
4256
  }
4194
4257
  program.run().catch((err) => {
4195
4258
  if (!(err.message.includes("Unknown command") && !err.message.includes("Did you mean")))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "3.0.0-next.73f1606",
3
+ "version": "3.0.0",
4
4
  "description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
5
5
  "type": "module",
6
6
  "exports": {