@needle-tools/gltf-build-pipeline 0.1.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.
Files changed (69) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/clear-caches.mjs +4 -0
  3. package/defaults.mjs +12 -0
  4. package/dist/cache/cache.d.ts +5 -0
  5. package/dist/cache/cache.js +82 -0
  6. package/dist/cache/index.d.ts +1 -0
  7. package/dist/cache/index.js +1 -0
  8. package/dist/extensions/NEEDLE_compression_texture/NEEDLE_compression_texture.d.ts +24 -0
  9. package/dist/extensions/NEEDLE_compression_texture/NEEDLE_compression_texture.js +54 -0
  10. package/dist/extensions/NEEDLE_compression_texture/index.d.ts +1 -0
  11. package/dist/extensions/NEEDLE_compression_texture/index.js +1 -0
  12. package/dist/extensions/NEEDLE_mesh_compression/NEEDLE_mesh_compression.d.ts +23 -0
  13. package/dist/extensions/NEEDLE_mesh_compression/NEEDLE_mesh_compression.js +43 -0
  14. package/dist/extensions/NEEDLE_mesh_compression/index.d.ts +1 -0
  15. package/dist/extensions/NEEDLE_mesh_compression/index.js +1 -0
  16. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.d.ts +20 -0
  17. package/dist/extensions/NEEDLE_opaque/NEEDLE_opaque.js +699 -0
  18. package/dist/extensions/NEEDLE_opaque/index.d.ts +3 -0
  19. package/dist/extensions/NEEDLE_opaque/index.js +18 -0
  20. package/dist/extensions/NEEDLE_progressive/NEEDLE_progressive.d.ts +23 -0
  21. package/dist/extensions/NEEDLE_progressive/NEEDLE_progressive.js +53 -0
  22. package/dist/extensions/NEEDLE_progressive/index.d.ts +1 -0
  23. package/dist/extensions/NEEDLE_progressive/index.js +1 -0
  24. package/dist/extensions/NEEDLE_progressive_texture_settings/index.d.ts +21 -0
  25. package/dist/extensions/NEEDLE_progressive_texture_settings/index.js +39 -0
  26. package/dist/extensions/index.d.ts +6 -0
  27. package/dist/extensions/index.js +16 -0
  28. package/dist/extensions/utils.d.ts +6 -0
  29. package/dist/extensions/utils.js +72 -0
  30. package/dist/functions/list-texture-slots.d.ts +1 -0
  31. package/dist/functions/list-texture-slots.js +35 -0
  32. package/dist/index.d.ts +3 -0
  33. package/dist/index.js +3 -0
  34. package/dist/transforms/index.d.ts +9 -0
  35. package/dist/transforms/index.js +9 -0
  36. package/dist/transforms/needle_animation_transform.d.ts +2 -0
  37. package/dist/transforms/needle_animation_transform.js +5 -0
  38. package/dist/transforms/needle_asset.d.ts +2 -0
  39. package/dist/transforms/needle_asset.js +28 -0
  40. package/dist/transforms/needle_common.d.ts +11 -0
  41. package/dist/transforms/needle_common.js +1 -0
  42. package/dist/transforms/needle_mesh_transform.d.ts +3 -0
  43. package/dist/transforms/needle_mesh_transform.js +83 -0
  44. package/dist/transforms/needle_progressive.d.ts +34 -0
  45. package/dist/transforms/needle_progressive.js +264 -0
  46. package/dist/transforms/needle_simplify.d.ts +3 -0
  47. package/dist/transforms/needle_simplify.js +76 -0
  48. package/dist/transforms/needle_texture_transform.d.ts +5 -0
  49. package/dist/transforms/needle_texture_transform.js +123 -0
  50. package/dist/transforms/needle_toktx.d.ts +24 -0
  51. package/dist/transforms/needle_toktx.js +144 -0
  52. package/dist/transforms/needle_webp.d.ts +13 -0
  53. package/dist/transforms/needle_webp.js +53 -0
  54. package/dist/transforms/toktx.d.ts +77 -0
  55. package/dist/transforms/toktx.js +230 -0
  56. package/dist/transforms/types/squoosh.d.ts +31 -0
  57. package/dist/transforms/types/squoosh.js +33 -0
  58. package/dist/transforms/util.d.ts +21 -0
  59. package/dist/transforms/util.js +90 -0
  60. package/dist/utils/fileutils.d.ts +2 -0
  61. package/dist/utils/fileutils.js +95 -0
  62. package/dist/utils/index.d.ts +1 -0
  63. package/dist/utils/index.js +1 -0
  64. package/fileutils.mjs +6 -0
  65. package/index.mjs +33 -0
  66. package/make-progressive.mjs +11 -0
  67. package/pack-gltf.mjs +119 -0
  68. package/package.json +60 -0
  69. package/tsconfig.json +15 -0
@@ -0,0 +1,264 @@
1
+ import { BufferUtils, Document, NodeIO, PropertyType } from '@gltf-transform/core';
2
+ import { listTextureSlots } from '@gltf-transform/functions';
3
+ import { NEEDLE_PROGRESSIVE_TEXTURE_EXTENSION_NAME, NEEDLE_progressive, generateGuid } from "../extensions/NEEDLE_progressive/index.js";
4
+ import { createOpaqueExtension, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_progressive_texture_settings, registerExtensions as registerDefaultExtensions } from '../extensions/index.js';
5
+ import path from 'path';
6
+ import { existsSync } from 'fs';
7
+ import { addToCache, getKey, tryGetFromCache } from '../cache/index.js';
8
+ import sharp from "sharp";
9
+ import { foreachGLTF } from '../utils/index.js';
10
+ import { needle_asset } from './needle_asset.js';
11
+ function registerExtensions(io) {
12
+ registerDefaultExtensions(io);
13
+ io.registerExtensions([
14
+ NEEDLE_progressive,
15
+ NEEDLE_progressive_texture_settings,
16
+ //@ts-ignore
17
+ createOpaqueExtension(NEEDLE_compression_texture.EXTENSION_NAME, PropertyType.TEXTURE),
18
+ //@ts-ignore
19
+ createOpaqueExtension(NEEDLE_mesh_compression.EXTENSION_NAME, [PropertyType.MESH, PropertyType.PRIMITIVE])
20
+ ]);
21
+ }
22
+ export async function make_progressive(opts) {
23
+ console.log("INFO: Run progressive transform: " + opts.path);
24
+ const file = opts.path;
25
+ const tasks = [];
26
+ foreachGLTF(file, f => {
27
+ const prog = process(f, opts);
28
+ tasks.push(prog);
29
+ });
30
+ await Promise.all(tasks);
31
+ }
32
+ async function process(file, opts) {
33
+ if (!file.endsWith(".glb") && !file.endsWith(".gltf")) {
34
+ console.log("ERR: file is not gltf/glb");
35
+ return;
36
+ }
37
+ if (!existsSync(file)) {
38
+ console.log("ERR: file does not exist: " + file);
39
+ }
40
+ const io = new NodeIO();
41
+ registerExtensions(io);
42
+ const document = await io.read(file).catch((err) => {
43
+ console.log("ERR: failed to read file \"" + file + "\", Reason: " + err.message);
44
+ return;
45
+ });
46
+ if (document) {
47
+ if (opts.debug === true) {
48
+ if (file.includes(".debug.")) {
49
+ console.log("Skip debug file: " + file);
50
+ return;
51
+ }
52
+ }
53
+ //@ts-ignore
54
+ document.getLogger().verbosity = 0;
55
+ await document.transform(needle_asset(), make_progressive_textures(file, { size: [256, 256] }, opts));
56
+ let filename = file;
57
+ if (opts.debug === true) {
58
+ // get extension
59
+ const extension = filename.split('.').pop();
60
+ // remove extension
61
+ filename = filename.split('.').slice(0, -1).join('.');
62
+ // add new extension
63
+ filename = filename + ".debug." + extension;
64
+ }
65
+ io.write(filename, document);
66
+ }
67
+ }
68
+ // basic idea:
69
+ // 1. iterate all images that have an extension marking them to be progressively loaded
70
+ // 2. for each image, create a new image that is a downsampled version of the original and create a new gltf that contains the high res texture
71
+ // 3. add extension to low res image containing info about where to find the high res texture
72
+ export function createTransform(name, fn) {
73
+ Object.defineProperty(fn, 'name', { value: name });
74
+ return fn;
75
+ }
76
+ const NAME = 'textureResize';
77
+ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DEFAULTS, opts) {
78
+ console.log("Make progressive: " + filePath);
79
+ const startTime = Date.now();
80
+ const options = { ...TEXTURE_RESIZE_DEFAULTS, ..._options };
81
+ const directory = path.dirname(filePath);
82
+ // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
83
+ const textureLodBasePath = directory + "/";
84
+ return createTransform(NAME, async (doc) => {
85
+ const logger = doc.getLogger();
86
+ let index = 0;
87
+ for (const texture of doc.getRoot().listTextures()) {
88
+ const textureIndex = index++;
89
+ const name = texture.getName();
90
+ const uri = texture.getURI();
91
+ const match = !options.pattern || options.pattern.test(name) || options.pattern.test(uri);
92
+ if (!match) {
93
+ logger.debug(`${NAME}: Skipping, excluded by "pattern" parameter.`);
94
+ continue;
95
+ }
96
+ if (texture.getMimeType() !== 'image/png' && texture.getMimeType() !== 'image/jpeg') {
97
+ logger.warn(`${NAME}: Skipping, unsupported texture type "${texture.getMimeType()}".`);
98
+ continue;
99
+ }
100
+ const slots = listTextureSlots(texture);
101
+ if (options.slots && !slots.some((slot) => options.slots?.test(slot))) {
102
+ logger.debug(`${NAME}: Skipping, [${slots.join(', ')}] excluded by "slots" parameter.`);
103
+ continue;
104
+ }
105
+ const settings = texture.getExtension(NEEDLE_progressive_texture_settings.EXTENSION_NAME);
106
+ if (!settings) {
107
+ logger.warn(`${NAME}: Skipping, no settings found`);
108
+ continue;
109
+ }
110
+ if (!settings.isValid) {
111
+ logger.warn(`${NAME}: Skipping, settings are invalid`);
112
+ continue;
113
+ }
114
+ options.size[0] = settings.maxSize;
115
+ options.size[1] = settings.maxSize;
116
+ const existingExtension = texture.getExtension(NEEDLE_PROGRESSIVE_TEXTURE_EXTENSION_NAME);
117
+ if (existingExtension !== undefined && existingExtension !== null) {
118
+ logger.debug(`${NAME}: Skipping, texture is already progressive`);
119
+ continue;
120
+ }
121
+ const progressiveExt = doc.createExtension(NEEDLE_progressive);
122
+ if (!progressiveExt || !progressiveExt.create) {
123
+ logger.debug(`${NAME}: Skipping because progressive extension is not available.`);
124
+ continue;
125
+ }
126
+ const textureGuid = settings.guid?.length ? settings.guid : generateGuid();
127
+ const outPath = createTextureLod(textureLodBasePath, 0, texture, slots, textureGuid, textureIndex);
128
+ if (!outPath) {
129
+ continue;
130
+ }
131
+ const relName = path.relative(directory, outPath);
132
+ // TODO: this path should be added to the NEEDLE dependencies extension
133
+ texture.setExtension(NEEDLE_PROGRESSIVE_TEXTURE_EXTENSION_NAME, progressiveExt.create({
134
+ uri: relName,
135
+ guid: textureGuid,
136
+ level: 0,
137
+ }));
138
+ const key = getKey(texture);
139
+ if (opts.useCache) {
140
+ const cached = tryGetFromCache(key);
141
+ if (cached) {
142
+ logger.debug(`Progressive image ${index}: using cached preview version (size: ${options.size}))`);
143
+ texture.setImage(cached);
144
+ continue;
145
+ }
146
+ }
147
+ await resizeTexture(options, texture, logger, uri || name, slots);
148
+ addToCache(key, texture.getImage());
149
+ }
150
+ logger.debug(`Progressive transform finished in ${((Date.now() - startTime) / 1000).toFixed(1)} sec.`);
151
+ });
152
+ }
153
+ function createTextureLod(basePath, level, texture, slots, guid, textureIndex) {
154
+ // const imageBytes = texture.getImage();
155
+ // if (imageBytes) {
156
+ // // write bytes
157
+ // const outPath = basePath + guid + "_" + level + ".png";
158
+ // writeFileSync(outPath, imageBytes);
159
+ // return outPath;
160
+ // }
161
+ const newDoc = new Document();
162
+ newDoc.createScene();
163
+ newDoc.createBuffer();
164
+ // add texture
165
+ const newTexture = newDoc.createTexture();
166
+ const img = texture.getImage();
167
+ newTexture.setImage(img);
168
+ newTexture.setURI(texture.getURI());
169
+ newTexture.setMimeType(texture.getMimeType());
170
+ // create material adding the texture to the same slot
171
+ for (const slot of slots) {
172
+ const newMaterial = newDoc.createMaterial();
173
+ switch (slot) {
174
+ case "baseColorTexture":
175
+ newMaterial.setBaseColorTexture(newTexture);
176
+ break;
177
+ case "metallicRoughnessTexture":
178
+ newMaterial.setMetallicRoughnessTexture(newTexture);
179
+ break;
180
+ case "normalTexture":
181
+ newMaterial.setNormalTexture(newTexture);
182
+ break;
183
+ case "occlusionTexture":
184
+ newMaterial.setOcclusionTexture(newTexture);
185
+ break;
186
+ case "emissiveTexture":
187
+ newMaterial.setEmissiveTexture(newTexture);
188
+ break;
189
+ default:
190
+ console.warn("WARN: Unknown texture slot: " + slot + " for texture " + textureIndex + " → can not load progressively");
191
+ return null;
192
+ }
193
+ }
194
+ const compressionSettings = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
195
+ if (compressionSettings !== undefined && compressionSettings !== null && compressionSettings.getExtensionDefinition) {
196
+ console.log("> Pass compression extension to new texture");
197
+ const newExt = newDoc.createExtension(NEEDLE_compression_texture);
198
+ const def = compressionSettings.getExtensionDefinition();
199
+ const newProp = newExt.create(def);
200
+ newTexture.setExtension(NEEDLE_compression_texture.EXTENSION_NAME, newProp);
201
+ }
202
+ // add progressive texture extension
203
+ const progressiveExt = newDoc.createExtension(NEEDLE_progressive);
204
+ newTexture.setExtension(NEEDLE_PROGRESSIVE_TEXTURE_EXTENSION_NAME, progressiveExt.create({
205
+ guid: guid,
206
+ }));
207
+ const newIo = new NodeIO();
208
+ registerExtensions(newIo);
209
+ const ext = ".glb";
210
+ const imageName = "image_" + level + "_" + guid;
211
+ const outName = basePath + imageName + ext;
212
+ newIo.write(outName, newDoc);
213
+ return outName;
214
+ }
215
+ /** Resampling filter methods. LANCZOS3 is sharper, LANCZOS2 is smoother. */
216
+ export var TextureResizeFilter;
217
+ (function (TextureResizeFilter) {
218
+ /** Lanczos3 (sharp) */
219
+ TextureResizeFilter["LANCZOS3"] = "lanczos3";
220
+ /** Lanczos2 (smooth) */
221
+ TextureResizeFilter["LANCZOS2"] = "lanczos2";
222
+ })(TextureResizeFilter || (TextureResizeFilter = {}));
223
+ export const TEXTURE_RESIZE_DEFAULTS = {
224
+ size: [2048, 2048],
225
+ filter: TextureResizeFilter.LANCZOS3,
226
+ pattern: null,
227
+ slots: null,
228
+ };
229
+ export async function resizeTextureToMax(texture, maxSize, logger) {
230
+ const options = { ...TEXTURE_RESIZE_DEFAULTS };
231
+ options.size = [maxSize, maxSize];
232
+ await resizeTexture(options, texture, logger);
233
+ }
234
+ async function resizeTexture(options, texture, logger, uri, slots) {
235
+ const [maxWidth, maxHeight] = options.size;
236
+ const [srcWidth, srcHeight] = texture.getSize();
237
+ if (srcWidth <= maxWidth && srcHeight <= maxHeight) {
238
+ logger.debug(`${NAME}: Skipping, not within size range.`);
239
+ return;
240
+ }
241
+ if (maxWidth <= 0 || maxHeight <= 0) {
242
+ logger.debug(`${uri}: Skipping, invalid size range: ${maxWidth}x${maxHeight}`);
243
+ return;
244
+ }
245
+ let dstWidth = srcWidth;
246
+ let dstHeight = srcHeight;
247
+ if (dstWidth > maxWidth) {
248
+ dstHeight = Math.floor(dstHeight * (maxWidth / dstWidth));
249
+ dstWidth = maxWidth;
250
+ }
251
+ if (dstHeight > maxHeight) {
252
+ dstWidth = Math.floor(dstWidth * (maxHeight / dstHeight));
253
+ dstHeight = maxHeight;
254
+ }
255
+ // https://sharp.pixelplumbing.com/api-resize
256
+ const buffer = await sharp(texture.getImage())
257
+ .resize(dstWidth, dstHeight, {
258
+ fit: 'inside',
259
+ kernel: "lanczos3",
260
+ withoutEnlargement: true,
261
+ })
262
+ .toBuffer();
263
+ texture.setImage(BufferUtils.toView(buffer));
264
+ }
@@ -0,0 +1,3 @@
1
+ import { Transform } from "@gltf-transform/core";
2
+ import { SimplifyOptions } from "@gltf-transform/functions";
3
+ export declare const needle_mesh_transform: (options: SimplifyOptions) => Transform;
@@ -0,0 +1,76 @@
1
+ import { Primitive } from "@gltf-transform/core";
2
+ import { simplifyPrimitive, weldPrimitive } from "@gltf-transform/functions";
3
+ import { MeshoptSimplifier } from "meshoptimizer";
4
+ import { NEEDLE_mesh_compression } from "../extensions/index.js";
5
+ const NAME = 'needle_simplify';
6
+ export const needle_mesh_transform = function (options) {
7
+ const simplifier = MeshoptSimplifier;
8
+ if (!simplifier) {
9
+ throw new Error(`${NAME}: simplifier dependency required — install "meshoptimizer".`);
10
+ }
11
+ return (document, _context) => {
12
+ const logger = document.getLogger();
13
+ options = { ...options, simplifier: simplifier };
14
+ const weldOptions = {
15
+ tolerance: 0.00000000001,
16
+ overwrite: true
17
+ };
18
+ for (const mesh of document.getRoot().listMeshes()) {
19
+ for (const prim of mesh.listPrimitives()) {
20
+ // const key = getKey(prim);
21
+ // const cached = tryGetFromCache(key);
22
+ // if (cached) {
23
+ // logger.debug(`${NAME}: Skipping primitive of mesh "${mesh.getName()}": Already cached.`);
24
+ // continue;
25
+ // }
26
+ // updatePrimitive(prim, simplifier, null, prim.getIndices());
27
+ // continue;
28
+ const ext = prim.getExtension(NEEDLE_mesh_compression.EXTENSION_NAME);
29
+ if (!ext) {
30
+ // logger.warn("Skipping primitive of mesh " + mesh.getName() + ": No NEEDLE_compression_mesh extension.");
31
+ continue;
32
+ }
33
+ let meshOptions = { ...options };
34
+ if (ext.model) {
35
+ const model = ext.model;
36
+ if (!model.useSimplifier) {
37
+ logger.debug(`${NAME}: Skipping primitive of mesh "${mesh.getName()}": useSimplifier is false.`);
38
+ continue;
39
+ }
40
+ if (model.error >= 0)
41
+ meshOptions.error = model.error;
42
+ if (model.ratio >= 0)
43
+ meshOptions.ratio = model.ratio;
44
+ meshOptions.lockBorder = model.lockBorder;
45
+ }
46
+ if (prim.getMode() !== Primitive.Mode.TRIANGLES) {
47
+ logger.warn(`${NAME}: Skipping primitive of mesh "${mesh.getName()}": Requires TRIANGLES draw mode.`);
48
+ continue;
49
+ }
50
+ logger.debug(`${NAME}: Welding primitive of mesh "${mesh.getName()}" → tolerance: ${weldOptions.tolerance}`);
51
+ weldPrimitive(document, prim, weldOptions);
52
+ logger.debug(`${NAME}: Simplifying primitive of mesh "${mesh.getName()}" → ratio: ${meshOptions.ratio}, error: ${meshOptions.error}, lockBorder: ${meshOptions.lockBorder}`);
53
+ simplifyPrimitive(document, prim, meshOptions);
54
+ }
55
+ }
56
+ };
57
+ };
58
+ // function updatePrimitive(prim: Primitive, simplifier: any, positionArray, dstIndicesArray) {
59
+ // const position = prim.getAttribute('POSITION')!;
60
+ // const srcIndices = prim.getIndices()!;
61
+ // const srcVertexCount = position.getCount();
62
+ // const [remap, unique] = simplifier.compactMesh(dstIndicesArray);
63
+ // // logger.debug(`${NAME}: ${formatDeltaOp(position.getCount(), unique)} vertices, error: ${error.toFixed(4)}.`);
64
+ // // (3) Write vertex attributes.
65
+ // for (const srcAttribute of deepListAttributes(prim)) {
66
+ // const dstAttribute = srcAttribute.clone();
67
+ // remapAttribute(dstAttribute, remap, unique);
68
+ // deepSwapAttribute(prim, srcAttribute, dstAttribute);
69
+ // if (srcAttribute.listParents().length === 1) srcAttribute.dispose();
70
+ // }
71
+ // // (4) Write indices.
72
+ // const dstIndices = srcIndices.clone();
73
+ // dstIndices.setArray(srcVertexCount <= 65534 ? new Uint16Array(dstIndicesArray) : dstIndicesArray);
74
+ // prim.setIndices(dstIndices);
75
+ // if (srcIndices.listParents().length === 1) srcIndices.dispose();
76
+ // }
@@ -0,0 +1,5 @@
1
+ import { Texture } from "@gltf-transform/core";
2
+ import { Transform } from '@gltf-transform/core';
3
+ import { NeedleTransformContext } from "./needle_common.js";
4
+ export declare function resizeTextureIfNecessary(texture: Texture): Promise<void>;
5
+ export declare const needle_texture_transform: (options: NeedleTransformContext) => Transform;
@@ -0,0 +1,123 @@
1
+ import { resizeTextureToMax } from "./needle_progressive.js";
2
+ import { NEEDLE_compression_texture } from '../extensions/index.js';
3
+ import { addToCache, getKey, tryGetFromCache } from "../cache/index.js";
4
+ import { NeedleWebPContext } from "./needle_webp.js";
5
+ import { NeedleToktxContext } from "./needle_toktx.js";
6
+ import { createTransform } from "@gltf-transform/functions";
7
+ export async function resizeTextureIfNecessary(texture) {
8
+ const ext = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
9
+ if (ext?.model) {
10
+ const mimeType = texture.getMimeType();
11
+ const isSupported = mimeType === "image/jpeg" || mimeType === "image/png";
12
+ if (isSupported) {
13
+ const settings = ext.model;
14
+ const size = texture.getSize();
15
+ if (size) {
16
+ if (settings.maxSize > 4 && (size[0] > settings.maxSize || size[1] > settings.maxSize)) {
17
+ console.log(`> Will resize texture ${texture.getName()} from ${size[0]}x${size[1]} to ${settings.maxSize}x${settings.maxSize}`);
18
+ await resizeTextureToMax(texture, settings.maxSize, console);
19
+ }
20
+ }
21
+ }
22
+ }
23
+ return undefined;
24
+ }
25
+ export const needle_texture_transform = function (options) {
26
+ const webPContext = new NeedleWebPContext({});
27
+ const toktxContext = new NeedleToktxContext({
28
+ jobs: undefined, // default to number of cores
29
+ });
30
+ const context = {
31
+ ...options,
32
+ webPContext,
33
+ toktxContext
34
+ };
35
+ return createTransform("needle_texture_transform", async (document) => {
36
+ await webPContext.prepare(document);
37
+ if (context.noToktx !== true)
38
+ await toktxContext.prepare(document);
39
+ try {
40
+ const logger = document.getLogger();
41
+ const textures = document.getRoot().listTextures();
42
+ await Promise.all(textures.map((texture, textureIndex) => {
43
+ return processTexture(texture, textureIndex, document, logger, context);
44
+ }));
45
+ }
46
+ finally {
47
+ await webPContext.finalize(document);
48
+ if (context.noToktx !== true)
49
+ await toktxContext.finalize(document);
50
+ }
51
+ });
52
+ };
53
+ async function processTexture(texture, index, document, logger, context) {
54
+ const mimeType = texture.getMimeType();
55
+ const isSupported = mimeType === "image/jpeg" || mimeType === "image/png" || mimeType === "image/webp";
56
+ // dont try to transform EXR or already processed textures
57
+ if (!isSupported)
58
+ return;
59
+ const ext = texture.getExtension(NEEDLE_compression_texture.EXTENSION_NAME);
60
+ const compressionSettings = ext?.model;
61
+ const key = context.useCache ? getKey(texture) : undefined;
62
+ let cached = context.useCache && key ? tryGetFromCache(key) : undefined;
63
+ if (cached) {
64
+ logger.debug(`> Using cached texture ${texture.getName()} [${index}] – ${(cached.length / 1024).toFixed(0)} KB`);
65
+ if (cached.length <= 0) {
66
+ logger.warn(`WARN: Cached texture ${texture.getName()} [${index}] is empty. Will reprocess.`);
67
+ cached = undefined;
68
+ }
69
+ }
70
+ let successFullyProcessed = false;
71
+ const canRunToktx = context.noToktx !== true;
72
+ if (compressionSettings && compressionSettings.mode !== "auto") {
73
+ switch (compressionSettings.mode) {
74
+ case "webp":
75
+ if (cached) {
76
+ texture.setImage(cached).setMimeType("image/webp");
77
+ return;
78
+ }
79
+ await resizeTextureIfNecessary(texture);
80
+ successFullyProcessed = await context.webPContext.process(index, texture, compressionSettings);
81
+ break;
82
+ case "ETC1S":
83
+ if (cached) {
84
+ texture.setImage(cached).setMimeType("image/ktx2");
85
+ return;
86
+ }
87
+ await resizeTextureIfNecessary(texture);
88
+ successFullyProcessed = await context.toktxContext.process(index, texture, compressionSettings);
89
+ break;
90
+ case "UASTC":
91
+ if (cached) {
92
+ texture.setImage(cached).setMimeType("image/ktx2");
93
+ return;
94
+ }
95
+ await resizeTextureIfNecessary(texture);
96
+ successFullyProcessed = await context.toktxContext.process(index, texture, compressionSettings);
97
+ break;
98
+ }
99
+ }
100
+ else {
101
+ // If we have a texture cached we can use it
102
+ // the default compression is toktx so we can safely use that for now
103
+ if (cached) {
104
+ texture.setImage(cached).setMimeType("image/ktx2");
105
+ return;
106
+ }
107
+ await resizeTextureIfNecessary(texture);
108
+ if (canRunToktx) {
109
+ // if no specific compression settings are provided we just use the toktx default settings
110
+ // if we ever change what is used for default compression we need to teach the cache to save the mime type
111
+ await context.toktxContext.process(index, texture, null);
112
+ successFullyProcessed = true;
113
+ }
114
+ else {
115
+ console.log("toktx is disallowed. Skipping texture compression.");
116
+ }
117
+ }
118
+ if (context.useCache && successFullyProcessed && key) {
119
+ const data = texture.getImage();
120
+ if (data)
121
+ addToCache(key, data);
122
+ }
123
+ }
@@ -0,0 +1,24 @@
1
+ import { Document, Texture } from "@gltf-transform/core";
2
+ import { KHRTextureBasisu } from "@gltf-transform/extensions";
3
+ import { NEEDLE_compression_texture_schema } from "../extensions/index.js";
4
+ import { INeedleTextureTransformContext } from "./needle_common.js";
5
+ export interface NeedleToktxOptions {
6
+ jobs?: number;
7
+ toktxPath?: string;
8
+ }
9
+ export declare class NeedleToktxContext implements INeedleTextureTransformContext {
10
+ limit: any;
11
+ basisuExtension: KHRTextureBasisu;
12
+ version: string;
13
+ tmpPathBase: string;
14
+ document: Document;
15
+ env: undefined | {
16
+ PATH: string;
17
+ };
18
+ numTextures: number;
19
+ private _prepareFailed;
20
+ constructor(options: NeedleToktxOptions);
21
+ prepare(document: Document): Promise<void>;
22
+ process(index: number, texture: Texture, settings: NEEDLE_compression_texture_schema | null): Promise<boolean>;
23
+ finalize(document: Document): Promise<void>;
24
+ }
@@ -0,0 +1,144 @@
1
+ import { FileUtils, ImageUtils, uuid } from "@gltf-transform/core";
2
+ import { KHRTextureBasisu } from "@gltf-transform/extensions";
3
+ import { checkKTXSoftware, createParams, ETC1S_DEFAULTS, Mode, UASTC_DEFAULTS } from "./toktx.js";
4
+ import { existsSync, mkdirSync } from "fs";
5
+ import { listTextureSlots, getTextureChannelMask } from "@gltf-transform/functions";
6
+ import { spawn, waitExit } from './util.js';
7
+ import fs from 'fs/promises';
8
+ import os from 'os';
9
+ import tmp from 'tmp';
10
+ import pLimit from 'p-limit';
11
+ const NUM_CPUS = os.cpus().length || 1; // microsoft/vscode#112122
12
+ export class NeedleToktxContext {
13
+ limit;
14
+ basisuExtension;
15
+ version;
16
+ tmpPathBase;
17
+ document;
18
+ env;
19
+ numTextures;
20
+ _prepareFailed = false;
21
+ constructor(options) {
22
+ this.limit = pLimit(options.jobs || NUM_CPUS);
23
+ // allow overriding toktx installation location / adding additional search paths
24
+ const isUsingWindows = process.platform === 'win32';
25
+ this.env = {
26
+ PATH: process.env.PATH ?? ""
27
+ };
28
+ if (options?.toktxPath?.length && this.env) {
29
+ this.env.PATH = `${options.toktxPath};${this.env.PATH}`;
30
+ }
31
+ // Use global env variable
32
+ if (process.env.NEEDLE_TOKTX?.length) {
33
+ if (!existsSync(process.env.NEEDLE_TOKTX)) {
34
+ console.log("WARN: NEEDLE_TOKTX is set to a non-existing path: \"" + process.env.NEEDLE_TOKTX + "\"");
35
+ }
36
+ else {
37
+ console.log("INFO: Adding NEEDLE_TOKTX to PATH: \"" + process.env.NEEDLE_TOKTX + "\"");
38
+ this.env.PATH = `${process.env.NEEDLE_TOKTX};${this.env.PATH}`;
39
+ }
40
+ }
41
+ else {
42
+ console.log("INFO: Environment variable 'NEEDLE_TOKTX' not set. Use 'NEEDLE_TOKTX' to specify the directory of the toktx installation (path/to/bin)");
43
+ }
44
+ // fallback default installation directory:
45
+ if (isUsingWindows) {
46
+ const defaultPath = "C:\\Program Files\\KTX-Software\\bin";
47
+ if (existsSync(defaultPath))
48
+ this.env.PATH = `${this.env.PATH};${defaultPath}`;
49
+ }
50
+ // append default system paths
51
+ if (isUsingWindows && process.env.Path?.length)
52
+ this.env.PATH = `${this.env.PATH};${process.env.Path}`;
53
+ if (process.argv?.includes("debug")) {
54
+ const ktxPaths = this.env.PATH.split(";").filter(p => p.toLocaleLowerCase().includes("ktx"));
55
+ console.log("INFO: Found these paths including ktx:\n\t" + ktxPaths.join("\n\t") + "");
56
+ }
57
+ }
58
+ async prepare(document) {
59
+ try {
60
+ this.document = document;
61
+ const logger = document.getLogger();
62
+ this.version = await checkKTXSoftware(logger, this.env);
63
+ this.basisuExtension = document.createExtension(KHRTextureBasisu).setRequired(true);
64
+ const tmpDir = `${tmp.tmpdir}/gltf-transform`;
65
+ if (!(existsSync(tmpDir)))
66
+ mkdirSync(tmpDir);
67
+ this.tmpPathBase = tmpDir + "/" + uuid();
68
+ this.numTextures = document.getRoot().listTextures().length;
69
+ }
70
+ catch (e) {
71
+ console.error(e);
72
+ this._prepareFailed = true;
73
+ }
74
+ }
75
+ async process(index, texture, settings) {
76
+ const task = async () => {
77
+ if (this._prepareFailed)
78
+ return false;
79
+ const doc = this.document;
80
+ const logger = doc.getLogger();
81
+ const slots = listTextureSlots(texture);
82
+ const channels = getTextureChannelMask(texture);
83
+ const textureLabel = texture.getURI() ||
84
+ texture.getName() ||
85
+ `${index + 1}/${doc.getRoot().listTextures().length}`;
86
+ const prefix = `toktx:texture(${textureLabel})`;
87
+ if (slots.length > 0)
88
+ logger.debug(`${prefix}: Slots → [${slots.join(', ')}]`);
89
+ const image = texture.getImage();
90
+ const size = texture.getSize();
91
+ if (!image || !size) {
92
+ logger.warn(`${prefix}: Skipping, unreadable texture.`);
93
+ return;
94
+ }
95
+ const extension = texture.getURI()
96
+ ? FileUtils.extension(texture.getURI())
97
+ : ImageUtils.mimeTypeToExtension(texture.getMimeType());
98
+ const tmpName = this.tmpPathBase + "_" + index;
99
+ const inPath = tmpName + '.' + extension;
100
+ const outPath = tmpName + '.ktx2';
101
+ // const inBytes = image.byteLength;
102
+ await fs.writeFile(inPath, Buffer.from(image));
103
+ //@ts-ignore
104
+ const defaults = (!settings || settings.mode === "ETC1S") ? ETC1S_DEFAULTS : UASTC_DEFAULTS;
105
+ defaults.mode = (!settings || settings.mode === "ETC1S") ? Mode.ETC1S : Mode.UASTC;
106
+ //@ts-ignore
107
+ const toktxParams = createParams(slots, channels, size, logger, this.numTextures, { ...defaults }, this.version);
108
+ const params = [
109
+ ...toktxParams,
110
+ outPath,
111
+ inPath,
112
+ ];
113
+ logger.debug(`${prefix}: Spawning → toktx ${params.join(' ')}`);
114
+ try {
115
+ // COMPRESS: Run `toktx` CLI tool.
116
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
117
+ //@ts-ignore
118
+ const [status, stdout, stderr] = await waitExit(spawn('toktx', params, { env: this.env }));
119
+ if (status !== 0) {
120
+ logger.error(`${prefix}: Failed → \n\n${stderr.toString()}`);
121
+ }
122
+ else {
123
+ const result = await fs.readFile(outPath);
124
+ texture.setImage(result).setMimeType('image/ktx2');
125
+ return true;
126
+ }
127
+ }
128
+ catch (err) {
129
+ console.error("Error running toktx", err);
130
+ }
131
+ return false;
132
+ };
133
+ return this.limit(task);
134
+ }
135
+ async finalize(document) {
136
+ const usesKTX2 = document
137
+ .getRoot()
138
+ .listTextures()
139
+ .some((t) => t.getMimeType() === 'image/ktx2');
140
+ if (!usesKTX2) {
141
+ this.basisuExtension?.dispose();
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,13 @@
1
+ import { Document, ILogger, Texture } from '@gltf-transform/core';
2
+ import { NEEDLE_compression_texture_schema } from '../extensions/index.js';
3
+ import { INeedleTextureTransformContext } from './needle_common.js';
4
+ export interface NeedleWebPOptions {
5
+ }
6
+ export declare class NeedleWebPContext implements INeedleTextureTransformContext {
7
+ options: NeedleWebPOptions;
8
+ logger: ILogger;
9
+ constructor(options: NeedleWebPOptions);
10
+ prepare(document: Document): Promise<void>;
11
+ process(index: number, texture: Texture, settings: NEEDLE_compression_texture_schema): Promise<boolean>;
12
+ finalize(document: Document): Promise<void>;
13
+ }