@needle-tools/gltf-build-pipeline 2.15.11 → 2.16.0-alpha.bc3af72

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.
@@ -54,3 +54,5 @@ export declare function getKeyWithHash(property: ExtensibleProperty): {
54
54
  * @returns The hash as a number.
55
55
  */
56
56
  export declare function getHash(property: ExtensibleProperty | object, level?: number): string;
57
+ export declare function hashBuffer(buffer: ArrayBufferLike, offset?: number, length?: number): number;
58
+ export declare function hashString(str: string): number;
@@ -307,7 +307,7 @@ function hashObject(obj, depth = 0) {
307
307
  return fallbackHash;
308
308
  }
309
309
  }
310
- function hashBuffer(buffer, offset, length) {
310
+ export function hashBuffer(buffer, offset, length) {
311
311
  if (xxHashModule) {
312
312
  const view = new Uint8Array(buffer, offset, length);
313
313
  return xxHashModule.h32Raw(view);
@@ -320,7 +320,7 @@ function hashBuffer(buffer, offset, length) {
320
320
  }
321
321
  return hash;
322
322
  }
323
- function hashString(str) {
323
+ export function hashString(str) {
324
324
  let hash = 0;
325
325
  for (let i = 0; i < str.length; i++) {
326
326
  hash = ((hash << 5) - hash) + str.charCodeAt(i);
@@ -1 +1,2 @@
1
1
  export * from "./cache.js";
2
+ export * from "./job-cache.js";
@@ -1 +1,2 @@
1
1
  export * from "./cache.js";
2
+ export * from "./job-cache.js";
@@ -0,0 +1,28 @@
1
+ import { ILogger } from '@gltf-transform/core';
2
+ import { Config } from '../config/index.js';
3
+ interface JobCacheOptions {
4
+ compress: boolean;
5
+ progressive: boolean;
6
+ config: Config;
7
+ }
8
+ /**
9
+ * Compute a job cache key from pipeline options and input file content.
10
+ * The key uniquely identifies a specific input + configuration combination.
11
+ * Currently only supports .glb files (hashes the full file content).
12
+ * @returns The job key string, or null if the file format is not supported.
13
+ */
14
+ export declare function computeJobKey(options: JobCacheOptions, inputFilePath: string): string | null;
15
+ /**
16
+ * Save the result of a completed job to the cache.
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.
20
+ */
21
+ export declare function saveJobResult(jobKey: string, outputFiles: string[], logger: ILogger): void;
22
+ /**
23
+ * Try to restore a previously cached job result.
24
+ * Loads all cached files into memory first, then writes them only if all are available.
25
+ * Returns the list of restored file paths, or null if the cache is incomplete.
26
+ */
27
+ export declare function tryRestoreJobResult(jobKey: string, outputDir: string, logger: ILogger): string[] | null;
28
+ export {};
@@ -0,0 +1,86 @@
1
+ // TODO: avoid storing LOD files twice in cache. Currently LODs are stored by the per-asset cache
2
+ // (e.g. "image-{hash}", "packed-{hash}") AND again by the job cache ("job-{key}-fN").
3
+ // Instead, progressive_results should carry { path, cacheKey? } so the job manifest can reference
4
+ // existing per-asset cache keys for LODs and only store the main GLB under a job-specific key.
5
+ // This requires threading cache key collection through make_progressive and packGLTF.
6
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
7
+ import path from 'path';
8
+ import { addMetaToCache, addToCache, tryGetFromCache, tryGetMetaFromCache, hashBuffer, hashString } from './cache.js';
9
+ /**
10
+ * Compute a job cache key from pipeline options and input file content.
11
+ * The key uniquely identifies a specific input + configuration combination.
12
+ * Currently only supports .glb files (hashes the full file content).
13
+ * @returns The job key string, or null if the file format is not supported.
14
+ */
15
+ // TODO: add support for .gltf files (need to hash referenced .bin files as well)
16
+ export function computeJobKey(options, inputFilePath) {
17
+ if (!inputFilePath.endsWith('.glb'))
18
+ return null;
19
+ const fileBytes = readFileSync(inputFilePath);
20
+ const fileHash = hashBuffer(fileBytes.buffer, fileBytes.byteOffset, fileBytes.byteLength);
21
+ const optionsStr = JSON.stringify({
22
+ compress: options.compress,
23
+ progressive: options.progressive,
24
+ config: options.config,
25
+ });
26
+ const optionsHash = hashString(optionsStr);
27
+ return `job-${optionsHash}-${fileHash}`;
28
+ }
29
+ /**
30
+ * Save the result of a completed job to the cache.
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.
34
+ */
35
+ export function saveJobResult(jobKey, outputFiles, logger) {
36
+ const entries = [];
37
+ for (let i = 0; i < outputFiles.length; i++) {
38
+ const filePath = outputFiles[i];
39
+ if (!existsSync(filePath))
40
+ continue;
41
+ const cacheKey = `${jobKey}-f${i}`;
42
+ const content = readFileSync(filePath);
43
+ addToCache(cacheKey, content);
44
+ entries.push({
45
+ fileName: path.basename(filePath),
46
+ cacheKey,
47
+ });
48
+ }
49
+ if (entries.length === 0)
50
+ return;
51
+ const manifest = { entries };
52
+ addMetaToCache(jobKey, manifest);
53
+ logger.debug(`[Job Cache] Saved ${entries.length} file(s) for job ${jobKey}`);
54
+ }
55
+ /**
56
+ * Try to restore a previously cached job result.
57
+ * Loads all cached files into memory first, then writes them only if all are available.
58
+ * Returns the list of restored file paths, or null if the cache is incomplete.
59
+ */
60
+ export function tryRestoreJobResult(jobKey, outputDir, logger) {
61
+ const manifest = tryGetMetaFromCache(jobKey);
62
+ if (!manifest?.entries?.length)
63
+ return null;
64
+ // Load all entries into memory — fail fast if any are missing
65
+ const loaded = [];
66
+ for (const entry of manifest.entries) {
67
+ const cached = tryGetFromCache(entry.cacheKey);
68
+ if (!cached) {
69
+ logger.debug(`[Job Cache] Cache miss: missing entry ${entry.cacheKey}`);
70
+ return null;
71
+ }
72
+ loaded.push({ entry, data: cached });
73
+ }
74
+ // All entries present — write to output directory
75
+ if (!existsSync(outputDir)) {
76
+ mkdirSync(outputDir, { recursive: true });
77
+ }
78
+ const restoredPaths = [];
79
+ for (const { entry, data } of loaded) {
80
+ const outPath = path.join(outputDir, entry.fileName);
81
+ writeFileSync(outPath, data);
82
+ restoredPaths.push(outPath);
83
+ }
84
+ logger.info(`[Job Cache] Restored ${restoredPaths.length} file(s) from cache`);
85
+ return restoredPaths;
86
+ }
package/dist/cli/index.js CHANGED
@@ -6,9 +6,10 @@ 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
8
  import { cacheSizeLimit, clearCache, limitCacheSize } from '../cache/cache.js';
9
- import { existsSync } from 'fs';
9
+ import { computeJobKey, saveJobResult, tryRestoreJobResult } from '../cache/job-cache.js';
10
+ import { existsSync, statSync } from 'fs';
10
11
  import { ensureIsDirectory, foreachGLTF, isDirectory } from '../utils/fileutils.js';
11
- import path, { resolve } from 'path';
12
+ import path, { dirname, resolve } from 'path';
12
13
  import { ERROR_CODES } from '../constants.js';
13
14
  import { trackPipelineStart, trackPipelineEnd, trackError } from '../utils/analytics.js';
14
15
  // For testing / dev you can run `npm link` in the package directory
@@ -52,6 +53,7 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
52
53
  }
53
54
  logger.info("Writing stats to: " + output);
54
55
  writeFileStatsToFile(filestats, output);
56
+ process.exit(0);
55
57
  })
56
58
  .command("transform", "Transform (progressive loading) and compress glTF, GLB or VRM files")
57
59
  .help(`
@@ -140,6 +142,32 @@ Each version will be compressed and written to the output directory.
140
142
  logger.debug(`→ Ignore existing LOD file at ${file}`);
141
143
  return;
142
144
  }
145
+ // Job-level cache: skip all work if the same input + options were already processed
146
+ const jobKey = useCache ? computeJobKey({
147
+ compress: options.compress === true,
148
+ progressive: options.progressive === true,
149
+ config,
150
+ }, file) : undefined;
151
+ if (jobKey) {
152
+ const outputDir = output
153
+ ? (existsSync(output) && isDirectory(output) ? output : dirname(output))
154
+ : dirname(file);
155
+ const restored = tryRestoreJobResult(jobKey, outputDir, logger);
156
+ if (restored) {
157
+ const inputFileSize = statSync(file).size / 1024 / 1024;
158
+ for (const f of restored) {
159
+ set.add(f);
160
+ if (stats) {
161
+ const sizeInMB = statSync(f).size / 1024 / 1024;
162
+ stats.totalFilesProcessed++;
163
+ stats.totalFileSizeInMB += sizeInMB;
164
+ stats.totalFileSizeInMBBefore += inputFileSize;
165
+ }
166
+ }
167
+ logger.info(`→ [CACHE] Restored ${restored.length} file(s)for job ${jobKey}`);
168
+ return;
169
+ }
170
+ }
143
171
  logger.info(`→ Transform ${file}`);
144
172
  const progressive_results = new Array();
145
173
  if (options.progressive === true) {
@@ -164,6 +192,7 @@ Each version will be compressed and written to the output directory.
164
192
  console.log("Progressive results:\n", progressive_results);
165
193
  }
166
194
  }
195
+ const externalFiles = [];
167
196
  if (options.compress === true) {
168
197
  const opts = {
169
198
  config,
@@ -176,6 +205,7 @@ Each version will be compressed and written to the output directory.
176
205
  // When progressive ran first, inputFile points to the progressive output copy.
177
206
  // Pass the original source path so extensions can resolve sibling assets (e.g. .exr files).
178
207
  sourceFile: progressive_results.length > 0 ? file : undefined,
208
+ externalFiles,
179
209
  };
180
210
  // If we have produced progressive assets then the array already contains the input
181
211
  if (progressive_results.length > 0) {
@@ -195,6 +225,13 @@ Each version will be compressed and written to the output directory.
195
225
  }
196
226
  }
197
227
  }
228
+ // Save job result to cache for future runs
229
+ if (jobKey) {
230
+ const allOutputFiles = progressive_results.length > 0
231
+ ? progressive_results
232
+ : [output || file];
233
+ saveJobResult(jobKey, [...allOutputFiles, ...externalFiles], logger);
234
+ }
198
235
  });
199
236
  printStats(stats, logger, config);
200
237
  trackPipelineEnd({
@@ -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
  */
@@ -7,7 +7,7 @@ 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
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";
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
@@ -105,8 +105,11 @@ export async function packGLTF(inputFile, outputFile, options) {
105
105
  dedup({ propertyTypes: [PropertyType.MESH, PropertyType.MATERIAL] }),
106
106
  ];
107
107
  const isProgressiveAsset = isLOD(inputFile);
108
- // TODO: progressive mesh LODs should not be pruned right now because it will remove e.g. UVs from meshes (and probably blend shapes too) (so we just not do it for any progressive asset)
109
- if (!isProgressiveAsset) {
108
+ const materialOnly = isMaterialOnlyGLB(document);
109
+ // 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)
110
+ // Material-only GLBs should not be pruned at all — they have no meshes so
111
+ // prune would strip materials, textures, and buffers, producing an empty file.
112
+ if (!isProgressiveAsset && !materialOnly) {
110
113
  transforms.push(prune({
111
114
  // Pruning animations is not supported yet (e.g. if the animation is only referenced by a component)
112
115
  propertyTypes: [
@@ -123,6 +126,29 @@ export async function packGLTF(inputFile, outputFile, options) {
123
126
  }));
124
127
  }
125
128
  await document.transform(...transforms);
129
+ // Collect external sidecar files (e.g. .pmrem.ktx2) produced by transforms
130
+ if (options.externalFiles) {
131
+ const outDir = path.dirname(outputFile);
132
+ const root = document.getRoot();
133
+ for (const tex of root.listTextures()) {
134
+ const uri = tex.getURI();
135
+ if (uri && !uri.startsWith('data:')) {
136
+ const absPath = path.resolve(outDir, uri);
137
+ if (existsSync(absPath)) {
138
+ options.externalFiles.push(absPath);
139
+ }
140
+ }
141
+ }
142
+ for (const buf of root.listBuffers()) {
143
+ const uri = buf.getURI();
144
+ if (uri && !uri.startsWith('data:')) {
145
+ const absPath = path.resolve(outDir, uri);
146
+ if (existsSync(absPath)) {
147
+ options.externalFiles.push(absPath);
148
+ }
149
+ }
150
+ }
151
+ }
126
152
  logger.debug(`← Writing to ${outputFile}`);
127
153
  await writeNodeIO(io, outputFile, document);
128
154
  if (useCache && cacheKey != undefined && existsSync(outputFile))
@@ -1,4 +1,7 @@
1
- import { ILogger } from "@gltf-transform/core";
1
+ import { Document, ILogger } from "@gltf-transform/core";
2
+ /** Returns true when the document contains materials/textures but no meshes, nodes, or scenes.
3
+ * These are standalone material assets that should not be pruned. */
4
+ export declare function isMaterialOnlyGLB(document: Document): boolean;
2
5
  export declare function validateImageMimeType(mimetype: string, image: Uint8Array, logger: ILogger): {
3
6
  error: boolean;
4
7
  message: string;
@@ -1,4 +1,11 @@
1
1
  import { ImageUtils } from "@gltf-transform/core";
2
+ /** Returns true when the document contains materials/textures but no meshes, nodes, or scenes.
3
+ * These are standalone material assets that should not be pruned. */
4
+ export function isMaterialOnlyGLB(document) {
5
+ const root = document.getRoot();
6
+ return root.listMeshes().length === 0
7
+ && root.listMaterials().length > 0;
8
+ }
2
9
  export function validateImageMimeType(mimetype, image, logger) {
3
10
  const actualMimeType = ImageUtils.getMimeType(image);
4
11
  if (actualMimeType != null && actualMimeType != mimetype) {
@@ -1 +1 @@
1
- export declare const version = "2.15.11";
1
+ export declare const version = "2.16.0-alpha.bc3af72";
@@ -1 +1 @@
1
- export const version = "2.15.11";
1
+ export const version = "2.16.0-alpha.bc3af72";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.15.11",
3
+ "version": "2.16.0-alpha.bc3af72",
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",