@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 +47 -0
- package/dist/cli/index.js +108 -45
- package/package.json +1 -1
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
|
|
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
|
|
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 :
|
|
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 (!
|
|
3611
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
3705
|
-
let duration = Date.now() - startTime, sizeInMB =
|
|
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}
|
|
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
|
|
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), !
|
|
3778
|
+
if (configPath = resolve2(process.cwd(), configPath), !existsSync13(configPath))
|
|
3752
3779
|
throw new Error(`No config found at "${configPath}"`);
|
|
3753
|
-
if (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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-
|
|
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:
|
|
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:
|
|
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 (
|
|
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 ?
|
|
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 =
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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": {
|