@needle-tools/gltf-build-pipeline 2.15.2 → 2.15.4
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/dist/scripts/pack-gltf.js +3 -3
- package/dist/transforms/needle_mesh_transform.js +6 -6
- package/dist/transforms/needle_pmrem.d.ts +5 -0
- package/dist/transforms/needle_pmrem.js +53 -17
- package/dist/transforms/needle_progressive.js +2 -2
- package/dist/transforms/needle_texture_transform.js +1 -1
- package/dist/utils/validate.d.ts +2 -1
- package/dist/utils/validate.js +3 -3
- package/dist/utils/version.gen.d.ts +1 -1
- package/dist/utils/version.gen.js +1 -1
- package/package.json +1 -1
|
@@ -49,7 +49,7 @@ export async function packGLTF(inputFile, outputFile, options) {
|
|
|
49
49
|
outputFile = getOutputPath(inputFile, outputFile);
|
|
50
50
|
}
|
|
51
51
|
const prevSizeInMB = statSync(inputFile).size / 1024 / 1024;
|
|
52
|
-
logger.
|
|
52
|
+
logger.debug(`→ Compressing ${prevSizeInMB.toFixed(1)} MB: ${inputFile}`);
|
|
53
53
|
const startTime = Date.now();
|
|
54
54
|
const io = new NodeIO()
|
|
55
55
|
.registerExtensions(ALL_EXTENSIONS)
|
|
@@ -123,7 +123,7 @@ export async function packGLTF(inputFile, outputFile, options) {
|
|
|
123
123
|
}));
|
|
124
124
|
}
|
|
125
125
|
await document.transform(...transforms);
|
|
126
|
-
logger.
|
|
126
|
+
logger.debug(`← Writing to ${outputFile}`);
|
|
127
127
|
await writeNodeIO(io, outputFile, document);
|
|
128
128
|
if (useCache && cacheKey != undefined && existsSync(outputFile))
|
|
129
129
|
addToCache(cacheKey, readFileSync(outputFile));
|
|
@@ -131,7 +131,7 @@ export async function packGLTF(inputFile, outputFile, options) {
|
|
|
131
131
|
const sizeInMB = statSync(outputFile).size / 1024 / 1024;
|
|
132
132
|
const filename = path.basename(outputFile);
|
|
133
133
|
const gpuMemoryInMB = totalGPUTextureMemory / 1024 / 1024;
|
|
134
|
-
logger.info(`←
|
|
134
|
+
logger.info(`← Compressed '${filename}' ${prevSizeInMB.toFixed(1)} MB → ${sizeInMB.toFixed(1)} MB (GPU: ${gpuMemoryInMB.toFixed(1)} MB textures) in ${(duration / 1000).toFixed(1)} sec`);
|
|
135
135
|
if (stats) {
|
|
136
136
|
stats.totalFilesProcessed++;
|
|
137
137
|
stats.totalFileSizeInMB += sizeInMB;
|
|
@@ -41,7 +41,7 @@ export function determineMeshCompression(document, filePath) {
|
|
|
41
41
|
}
|
|
42
42
|
// TODO: make this more robus with progressive loading: when progressive loading is enabled we need to run this check on the root mesh and enforce the same mesh compression for all children. Until we can do that we can not use the code for choosing compression based on blendshape size
|
|
43
43
|
if (sizeOfBlendshapes > 0) {
|
|
44
|
-
document.getLogger().
|
|
44
|
+
document.getLogger().debug(`[${NAME}]: will apply meshopt mesh compression because the mesh contains blendshapes`);
|
|
45
45
|
return new NEEDLE_mesh_compression_model_root("meshopt");
|
|
46
46
|
}
|
|
47
47
|
// consider meshopt if blendshape size is > 256 KB
|
|
@@ -52,7 +52,7 @@ export function determineMeshCompression(document, filePath) {
|
|
|
52
52
|
// /** 25% of the size are blendshapres */
|
|
53
53
|
// const ratioThreshold = 0.1;
|
|
54
54
|
// if (blendshapeRatio >= ratioThreshold) {
|
|
55
|
-
// document.getLogger().
|
|
55
|
+
// document.getLogger().debug(`[${NAME}]: will apply meshopt mesh compression because blendshapes are ${(blendshapeRatio * 100).toFixed(2)}% of the mesh size`);
|
|
56
56
|
// const transform = meshopt({ encoder: MeshoptEncoder });
|
|
57
57
|
// return transform(document, transform_context);
|
|
58
58
|
// }
|
|
@@ -73,10 +73,10 @@ async function applyMeshCompression(document, transform_context, context) {
|
|
|
73
73
|
// const rootModel = rootExtension.model as NEEDLE_mesh_compression_model_root;
|
|
74
74
|
if (compressionModel.compression) {
|
|
75
75
|
if (compressionModel.compression === "none") {
|
|
76
|
-
document.getLogger().
|
|
76
|
+
document.getLogger().debug(`[${NAME}]: NONE → will not apply mesh compression`);
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
|
-
document.getLogger().
|
|
79
|
+
document.getLogger().debug(`[${NAME}]: ${compressionModel.compression.toUpperCase()} → will apply ${compressionModel.compression} mesh compression`);
|
|
80
80
|
if (compressionModel.compression === "draco") {
|
|
81
81
|
const transform = draco({
|
|
82
82
|
encodeSpeed: 8,
|
|
@@ -92,7 +92,7 @@ async function applyMeshCompression(document, transform_context, context) {
|
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
// by default we still want to run draco if the root doesnt have a compression model configured
|
|
95
|
-
document.getLogger().
|
|
95
|
+
document.getLogger().debug(`[${NAME}]: → will apply draco mesh compression (default)`);
|
|
96
96
|
const transform = draco({
|
|
97
97
|
encodeSpeed: 8,
|
|
98
98
|
decodeSpeed: 8,
|
|
@@ -103,7 +103,7 @@ async function applyMeshCompression(document, transform_context, context) {
|
|
|
103
103
|
async function simplifierTransform(document, _context, meshContext) {
|
|
104
104
|
const simplifier = MeshoptSimplifier;
|
|
105
105
|
if (!simplifier) {
|
|
106
|
-
|
|
106
|
+
document.getLogger().warn(`WARN: [${NAME}] simplifier dependency required — install "meshoptimizer".`);
|
|
107
107
|
return false;
|
|
108
108
|
}
|
|
109
109
|
const logger = document.getLogger();
|
|
@@ -10,7 +10,12 @@ export declare class NeedlePmremContext implements INeedleTextureTransformContex
|
|
|
10
10
|
private available;
|
|
11
11
|
private logger;
|
|
12
12
|
private readonly convertedTextures;
|
|
13
|
+
/** In-memory dedup: keyed by EXR content hash, resolves to KTX2 bytes */
|
|
14
|
+
private readonly processingPromises;
|
|
13
15
|
prepare(document: Document): Promise<boolean>;
|
|
14
16
|
process(index: number, texture: Texture, _settings: NEEDLE_compression_texture_schema | null, context: NeedleTransformContext): Promise<boolean>;
|
|
17
|
+
/** Get or process EXR bytes into KTX2, with in-memory dedup and disk caching */
|
|
18
|
+
private getOrProcessExr;
|
|
19
|
+
private processExrToKtx2;
|
|
15
20
|
finalize(document: Document): Promise<void>;
|
|
16
21
|
}
|
|
@@ -4,7 +4,9 @@ import { join, dirname } from 'path';
|
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from 'url';
|
|
5
5
|
import { execFile } from 'child_process';
|
|
6
6
|
import { v4 as uuid } from 'uuid';
|
|
7
|
+
import { createHash } from 'crypto';
|
|
7
8
|
import tmp from 'tmp';
|
|
9
|
+
import { addToCache, tryGetFromCache } from '../cache/index.js';
|
|
8
10
|
/** Resolve the tools/pmrem/ directory relative to this file.
|
|
9
11
|
* At runtime this file is at dist/transforms/needle_pmrem.js,
|
|
10
12
|
* so tools/pmrem/ is at ../../tools/pmrem/ */
|
|
@@ -46,6 +48,8 @@ export class NeedlePmremContext {
|
|
|
46
48
|
available = false;
|
|
47
49
|
logger;
|
|
48
50
|
convertedTextures = [];
|
|
51
|
+
/** In-memory dedup: keyed by EXR content hash, resolves to KTX2 bytes */
|
|
52
|
+
processingPromises = new Map();
|
|
49
53
|
async prepare(document) {
|
|
50
54
|
this.logger = document.getLogger();
|
|
51
55
|
this.processed = 0;
|
|
@@ -97,25 +101,13 @@ export class NeedlePmremContext {
|
|
|
97
101
|
if (!image)
|
|
98
102
|
return false;
|
|
99
103
|
const name = texture.getName() || `texture_${index}`;
|
|
100
|
-
const id = uuid();
|
|
101
104
|
const wasExternal = !!texture.getURI();
|
|
102
|
-
// 1. Run PMREM on the EXR bytes
|
|
103
|
-
this.logger.info(`pmrem:texture[${index}] ${name}: Running PMREM...`);
|
|
104
105
|
const exrBytes = new Uint8Array(image.buffer, image.byteOffset, image.byteLength);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// 3. Encode to KTX2 HDR via basisu
|
|
111
|
-
this.logger.debug(`pmrem:texture[${index}] ${name}: Encoding KTX2 HDR...`);
|
|
112
|
-
const variant = 'hdr4x4'; // 6x6 needs an update in Needle
|
|
113
|
-
await encodeKTX2WithBasisu(this.basisuExe, tempExr, tempKtx2, variant, true);
|
|
114
|
-
if (!existsSync(tempKtx2)) {
|
|
115
|
-
throw new Error(`pmrem:texture[${index}] ${name}: basisu did not produce output`);
|
|
116
|
-
}
|
|
117
|
-
// 4. Read KTX2 and update texture
|
|
118
|
-
const ktx2Bytes = readFileSync(tempKtx2);
|
|
106
|
+
// Hash the EXR content for dedup and caching
|
|
107
|
+
const contentHash = createHash('md5').update(exrBytes).digest('hex');
|
|
108
|
+
const cacheKey = `pmrem-${contentHash}`;
|
|
109
|
+
// Get KTX2 bytes — deduplicated and optionally cached
|
|
110
|
+
const ktx2Bytes = await this.getOrProcessExr(cacheKey, exrBytes, index, name, context.useCache);
|
|
119
111
|
// Remove EXT_texture_exr property since the texture is now KTX2
|
|
120
112
|
const exrProp = texture.getExtension('EXT_texture_exr');
|
|
121
113
|
if (exrProp) {
|
|
@@ -157,6 +149,50 @@ export class NeedlePmremContext {
|
|
|
157
149
|
this.processed++;
|
|
158
150
|
return true;
|
|
159
151
|
}
|
|
152
|
+
/** Get or process EXR bytes into KTX2, with in-memory dedup and disk caching */
|
|
153
|
+
getOrProcessExr(cacheKey, exrBytes, index, name, useCache) {
|
|
154
|
+
// In-memory dedup: if we're already processing identical EXR content, reuse the promise
|
|
155
|
+
const existing = this.processingPromises.get(cacheKey);
|
|
156
|
+
if (existing) {
|
|
157
|
+
this.logger.info(`pmrem:texture[${index}] ${name}: reusing in-flight result (identical content)`);
|
|
158
|
+
return existing;
|
|
159
|
+
}
|
|
160
|
+
const promise = this.processExrToKtx2(cacheKey, exrBytes, index, name, useCache);
|
|
161
|
+
this.processingPromises.set(cacheKey, promise);
|
|
162
|
+
return promise;
|
|
163
|
+
}
|
|
164
|
+
async processExrToKtx2(cacheKey, exrBytes, index, name, useCache) {
|
|
165
|
+
// Disk cache check
|
|
166
|
+
if (useCache) {
|
|
167
|
+
const cached = tryGetFromCache(cacheKey);
|
|
168
|
+
if (cached && cached.length > 0) {
|
|
169
|
+
this.logger.info(`pmrem:texture[${index}] ${name}: loaded from cache`);
|
|
170
|
+
return cached;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// 1. Run PMREM on the EXR bytes
|
|
174
|
+
this.logger.info(`pmrem:texture[${index}] ${name}: Running PMREM...`);
|
|
175
|
+
const pmremExrBytes = this.wasmModule.pmrem_exr(exrBytes);
|
|
176
|
+
// 2. Write PMREM EXR to temp file
|
|
177
|
+
const id = uuid();
|
|
178
|
+
const tempExr = join(this.tmpDir, `${id}.pmrem.exr`);
|
|
179
|
+
const tempKtx2 = join(this.tmpDir, `${id}.ktx2`);
|
|
180
|
+
writeFileSync(tempExr, Buffer.from(pmremExrBytes));
|
|
181
|
+
// 3. Encode to KTX2 HDR via basisu
|
|
182
|
+
this.logger.debug(`pmrem:texture[${index}] ${name}: Encoding KTX2 HDR...`);
|
|
183
|
+
const variant = 'hdr4x4'; // 6x6 needs an update in Needle
|
|
184
|
+
await encodeKTX2WithBasisu(this.basisuExe, tempExr, tempKtx2, variant, true);
|
|
185
|
+
if (!existsSync(tempKtx2)) {
|
|
186
|
+
throw new Error(`pmrem:texture[${index}] ${name}: basisu did not produce output`);
|
|
187
|
+
}
|
|
188
|
+
// 4. Read KTX2 result
|
|
189
|
+
const ktx2Bytes = readFileSync(tempKtx2);
|
|
190
|
+
// Store in disk cache
|
|
191
|
+
if (useCache) {
|
|
192
|
+
addToCache(cacheKey, ktx2Bytes);
|
|
193
|
+
}
|
|
194
|
+
return new Uint8Array(ktx2Bytes);
|
|
195
|
+
}
|
|
160
196
|
async finalize(document) {
|
|
161
197
|
if (this.processed > 0) {
|
|
162
198
|
// Register NEEDLE_pmrem extension and mark converted textures
|
|
@@ -213,7 +213,7 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
|
|
|
213
213
|
logger.debug(`[${NAME}] Skipping because progressive extension is not available. ${textureIndex}`);
|
|
214
214
|
continue;
|
|
215
215
|
}
|
|
216
|
-
const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
|
|
216
|
+
const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage(), logger);
|
|
217
217
|
if (mimeTypeResult?.error) {
|
|
218
218
|
logger.warn(`[${NAME}] Texture[${index}] has invalid mime type! Reported \"${texture.getMimeType()}\" but is actually \"${mimeTypeResult.mimeType}\": auto-fix`);
|
|
219
219
|
texture.setMimeType(mimeTypeResult.mimeType);
|
|
@@ -514,7 +514,7 @@ export async function resizeTextureToMax(texture, maxSize, logger) {
|
|
|
514
514
|
await resizeTexture(options, texture, logger);
|
|
515
515
|
}
|
|
516
516
|
async function resizeTexture(options, texture, logger, uri, slots) {
|
|
517
|
-
const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
|
|
517
|
+
const mimeTypeResult = validateImageMimeType(texture.getMimeType(), texture.getImage(), logger);
|
|
518
518
|
if (mimeTypeResult?.error) {
|
|
519
519
|
logger.warn(`[${NAME}] Texture[${uri}] has invalid mime type: ${mimeTypeResult.error}: auto-fix`);
|
|
520
520
|
texture.setMimeType(mimeTypeResult.mimeType);
|
|
@@ -88,7 +88,7 @@ export const needle_texture_transform = function (options) {
|
|
|
88
88
|
});
|
|
89
89
|
};
|
|
90
90
|
async function processTexture(texture, index, document, logger, context) {
|
|
91
|
-
const validationResult = validateImageMimeType(texture.getMimeType(), texture.getImage());
|
|
91
|
+
const validationResult = validateImageMimeType(texture.getMimeType(), texture.getImage(), logger);
|
|
92
92
|
if (validationResult?.error) {
|
|
93
93
|
logger.warn(`WARN: Texture ${texture.getName()} [${index}] has an incorrect mime type \"${texture.getMimeType()}\" → auto-correcting to ${validationResult.mimeType}`);
|
|
94
94
|
texture.setMimeType(validationResult.mimeType);
|
package/dist/utils/validate.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import { ILogger } from "@gltf-transform/core";
|
|
2
|
+
export declare function validateImageMimeType(mimetype: string, image: Uint8Array, logger: ILogger): {
|
|
2
3
|
error: boolean;
|
|
3
4
|
message: string;
|
|
4
5
|
mimeType: string;
|
package/dist/utils/validate.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { ImageUtils } from "@gltf-transform/core";
|
|
2
|
-
export function validateImageMimeType(mimetype, image) {
|
|
2
|
+
export function validateImageMimeType(mimetype, image, logger) {
|
|
3
3
|
const actualMimeType = ImageUtils.getMimeType(image);
|
|
4
4
|
if (actualMimeType != null && actualMimeType != mimetype) {
|
|
5
5
|
const msg = `Image reported wrong mime type \"${mimetype}\" but is actually \"${actualMimeType}\"`;
|
|
6
|
-
|
|
6
|
+
logger.warn(msg);
|
|
7
7
|
return {
|
|
8
8
|
error: true,
|
|
9
9
|
message: msg,
|
|
@@ -11,7 +11,7 @@ export function validateImageMimeType(mimetype, image) {
|
|
|
11
11
|
};
|
|
12
12
|
}
|
|
13
13
|
else if (actualMimeType === null) {
|
|
14
|
-
|
|
14
|
+
logger.debug(`Could not validate image type: \"${mimetype}\"`);
|
|
15
15
|
}
|
|
16
16
|
return null;
|
|
17
17
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const version = "2.15.
|
|
1
|
+
export declare const version = "2.15.4";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const version = "2.15.
|
|
1
|
+
export const version = "2.15.4";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@needle-tools/gltf-build-pipeline",
|
|
3
|
-
"version": "2.15.
|
|
3
|
+
"version": "2.15.4",
|
|
4
4
|
"description": "Pipeline and tools for optimizing gltf files using gltf-transform and compression settings within glTF extensions",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|