@needle-tools/gltf-build-pipeline 2.2.1-alpha.7 → 2.3.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,15 @@ 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
+ ## [2.3.0-alpha] - 2023-06-22
8
+ - add: flatten and join for `product` usecase
9
+
10
+ ## [2.2.1-alpha.8] - 2023-06-22
11
+ - add: CLI <output> support
12
+ - fix: register webp extension for valid glTF files. [issue 5122](https://linear.app/needle/issue/NE-5122)
13
+ - fix: issue where VRM material would be pruned
14
+ - change: VRM use meshopt compression where any blendshapes are found
15
+
7
16
  ## [2.2.1-alpha.7] - 2023-06-19
8
17
  - fix: WARN if image validation can not run
9
18
  - change: compress lightmap textures with webp
package/dist/cli/index.js CHANGED
@@ -6,9 +6,8 @@ import { getVersion } from '../utils/version.js';
6
6
  import { UsecaseOptions, createConfig, getConfig } from '../config/index.js';
7
7
  import { make_progressive } from '../transforms/needle_progressive.js';
8
8
  import { cacheSizeLimit, clearCache, limitCacheSize } from '../cache/cache.js';
9
- import { statSync } from 'fs';
10
- import { foreachGLTF, isDirectory } from '../utils/fileutils.js';
11
- import path, { dirname } from 'path';
9
+ import { ensureIsDirectory, foreachGLTF, isDirectory } from '../utils/fileutils.js';
10
+ import path, { resolve } from 'path';
12
11
  import { ERROR_CODES } from '../constants.js';
13
12
  // For testing / dev you can run `npm link` in the package directory
14
13
  // For removing the link run `npm rm --global @needle-tools/gltf-build-pipeline`
@@ -57,7 +56,7 @@ This will produce multiple versions of the input file, each with a different lev
57
56
  Each version will be compressed and written to the output directory.
58
57
  `)
59
58
  .argument('<input>', "The input glTF, GLB or VRM file that should be compressed OR a directory that contains files to be compressed")
60
- // .argument('<output>', "The output file. If not provided, the input file will be overwritten.", { default: "" })
59
+ .argument('<output>', "The output file or directory. It can be an absolute path or a relative path to the input file. If not provided, the input file will be overwritten.", { default: null })
61
60
  .option("--compress", `When enabled files will be compressed`, { validator: program.BOOLEAN, default: true })
62
61
  .option("--progressive", `When enabled files will processed to be progressively loaded`, { validator: program.BOOLEAN, default: true })
63
62
  .option("--usecase <usecase>", `The usecase for the compression. This will set the compression settings. Possible options: [${UsecaseOptions.join(", ")}]`, { validator: program.STRING })
@@ -78,8 +77,8 @@ Each version will be compressed and written to the output directory.
78
77
  totalFileSizeInMB: 0,
79
78
  totalFileSizeInMBBefore: 0,
80
79
  };
81
- const file = args.input.toString();
82
- let config = getConfig(options.config?.toString(), file, {
80
+ const input = args.input.toString();
81
+ let config = getConfig(options.config?.toString(), input, {
83
82
  logger
84
83
  });
85
84
  if (options.usecase) {
@@ -92,18 +91,29 @@ Each version will be compressed and written to the output directory.
92
91
  config.usecase = options.usecase.toString();
93
92
  }
94
93
  }
95
- const output = options.output?.toString() || file;
96
- const outputIsDirectory = statSync(output).isDirectory();
97
- const outDir = outputIsDirectory ? output : dirname(output);
94
+ let output = args.output?.toString() || input;
95
+ const inputIsDirectory = isDirectory(input);
96
+ const outputIsDirectory = isDirectory(output);
97
+ if (inputIsDirectory && !outputIsDirectory) {
98
+ logger.error("When <input> is a directory then <output> must also be a directory");
99
+ process.exit(ERROR_CODES.INVALID_ARGS);
100
+ }
101
+ // Handle the output bath being relative to the input path
102
+ if (path.isAbsolute(output) === false) {
103
+ const base = ensureIsDirectory(input);
104
+ output = resolve(base, output);
105
+ }
98
106
  const set = new Set();
99
107
  const useCache = options.cache !== false;
100
108
  const debug = options.debug === true;
101
- await foreachGLTF(file, async (file) => {
109
+ const verbose = options.verbose === true;
110
+ await foreachGLTF(input, async (file) => {
102
111
  logger.info("→ Transform " + file);
103
112
  const progressive_results = new Array();
104
113
  if (options.progressive === true) {
105
114
  if (await make_progressive({
106
115
  path: file,
116
+ outpath: output,
107
117
  results: progressive_results,
108
118
  config,
109
119
  debug,
@@ -121,16 +131,24 @@ Each version will be compressed and written to the output directory.
121
131
  stats,
122
132
  packedFiles: set,
123
133
  debug,
134
+ verbose,
124
135
  useCache,
125
136
  logger: logger,
126
137
  };
127
- for (const res of progressive_results) {
128
- if (await packGLTF(res, res, opts) === false) {
129
- process.exit(ERROR_CODES.PACKING_FAILED);
138
+ // If we have produced progressive assets then the array already contains the input
139
+ if (progressive_results.length > 0) {
140
+ for (const res of progressive_results) {
141
+ if (await packGLTF(res, res, opts) === false) {
142
+ process.exit(ERROR_CODES.PACKING_FAILED);
143
+ }
130
144
  }
131
145
  }
132
- if (await packGLTF(file, file, opts) === false) {
133
- process.exit(ERROR_CODES.PACKING_FAILED);
146
+ // If we have not produced progressive assets then we just need to compress the input
147
+ // We can not just check if the file is in the list of results because the output path might have changed the name
148
+ else {
149
+ if (await packGLTF(file, output, opts) === false) {
150
+ process.exit(ERROR_CODES.PACKING_FAILED);
151
+ }
134
152
  }
135
153
  }
136
154
  });
@@ -2,4 +2,5 @@ export declare const ERROR_CODES: {
2
2
  UNKNOWN_ERROR: number;
3
3
  PACKING_FAILED: number;
4
4
  PROGRESSIVE_FAILED: number;
5
+ INVALID_ARGS: number;
5
6
  };
package/dist/constants.js CHANGED
@@ -2,4 +2,5 @@ export const ERROR_CODES = {
2
2
  UNKNOWN_ERROR: 1,
3
3
  PACKING_FAILED: 2,
4
4
  PROGRESSIVE_FAILED: 3,
5
+ INVALID_ARGS: 4,
5
6
  };
@@ -524,6 +524,7 @@ class JsonPointerHandler {
524
524
  map = new Map();
525
525
  debug = false;
526
526
  breakPoint = false;
527
+ VRM_0_MaterialIndexRegex = new RegExp(/materialProperties\/(?<material_index>\d+)\//g);
527
528
  constructor(document, id) {
528
529
  this.document = document;
529
530
  this.id = id;
@@ -671,7 +672,7 @@ class JsonPointerHandler {
671
672
  // key texture is matching for example for VRM meta/texture (AvatarSample_A)
672
673
  key === "texture"
673
674
  // VRM 0.0 material properties
674
- || fullPath.match(/materialProperties\/\d+\/textureProperties/g)
675
+ || fullPath.match(/materialProperties\/(?<material_index>\d+)\/textureProperties/g)
675
676
  // VRM 1.0 meta
676
677
  || fullPath.match(/meta\/thumbnailImage/g)) {
677
678
  const index = value;
@@ -705,6 +706,23 @@ class JsonPointerHandler {
705
706
  this.map.set(dictKey, pointer);
706
707
  prop.setReference(dictKey, object, { channels: TextureChannel.R | TextureChannel.G | TextureChannel.B | TextureChannel.A });
707
708
  }
709
+ const materialMatch = this.VRM_0_MaterialIndexRegex.exec(fullPath);
710
+ if (materialMatch) {
711
+ const materialIndex = parseInt(materialMatch.groups.material_index);
712
+ const material = context.materials[materialIndex];
713
+ prop.setReference(`/materials/${materialIndex}`, material);
714
+ // const res = new MaterialIndexResolver(this.document, material, context.jsonDoc.json.materials![materialIndex]);
715
+ // res.debug = this.debug;
716
+ // res.init(context);
717
+ // const dictKey = `/materials/${materialIndex}`
718
+ // let pointer = this.map.get(dictKey);
719
+ // if (!pointer) {
720
+ // pointer = new JsonPointer(null, res, value);
721
+ // }
722
+ // pointer.debug = this.debug;
723
+ // pointer.register(obj, key, fullPath);
724
+ // this.map.set(dictKey, pointer);
725
+ }
708
726
  }
709
727
  break;
710
728
  default:
@@ -4,10 +4,10 @@ import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
4
4
  import draco3d from 'draco3dgltf';
5
5
  import { existsSync, readFileSync, statSync, writeFileSync } from 'fs';
6
6
  import { MeshoptDecoder, MeshoptEncoder } from 'meshoptimizer';
7
- import { dedup, prune, resample } from '@gltf-transform/functions';
7
+ import { dedup, flatten, join, prune, resample } from '@gltf-transform/functions';
8
8
  import { ALL_EXTENSIONS as NEEDLE_EXTENSIONS, NEEDLE_compression_texture, NEEDLE_mesh_compression, } 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 { writeNodeIO } from "../utils/index.js";
10
+ import { getOutputPath, writeNodeIO } from "../utils/index.js";
11
11
  import { addToCache, getHash, tryGetFromCache, } from "../cache/index.js";
12
12
  /** Compress a glTF file
13
13
  */
@@ -60,7 +60,7 @@ export async function packGLTF(inputFile, outputFile, options) {
60
60
  });
61
61
  const document = await io.read(inputFile);
62
62
  /** @ts-ignore */
63
- document.getLogger().verbosity = options.debug || options.verbose ? Verbosity.DEBUG : Verbosity.WARN;
63
+ document.getLogger().verbosity = options.verbose ? Verbosity.DEBUG : options.debug ? Verbosity.INFO : Verbosity.WARN;
64
64
  let totalGPUTextureMemory = 0;
65
65
  /** @type {Array<import('@gltf-transform/core').Transform>} */
66
66
  const transforms = [
@@ -91,8 +91,16 @@ export async function packGLTF(inputFile, outputFile, options) {
91
91
  }));
92
92
  }
93
93
  await document.transform(...transforms);
94
+ const isVRM = inputFile.toLowerCase().endsWith(".vrm");
95
+ if (!isVRM && config?.usecase === "product") {
96
+ await document.transform(flatten(), join({ keepNamed: false }));
97
+ }
94
98
  if (!outputFile)
95
99
  outputFile = inputFile;
100
+ else {
101
+ outputFile = getOutputPath(inputFile, outputFile);
102
+ logger.info(`⇄ Writing to ${outputFile}`);
103
+ }
96
104
  await writeNodeIO(io, outputFile, document);
97
105
  if (useCache && cacheKey != undefined && existsSync(outputFile))
98
106
  addToCache(cacheKey, readFileSync(outputFile));
@@ -33,9 +33,12 @@ async function applyMeshCompression(document, transform_context, context) {
33
33
  }
34
34
  // Meshop is better at re-using vertex attributes
35
35
  if (context.file.endsWith(".vrm")) {
36
- document.getLogger().info(`[${NAME}]: VRM → will apply meshopt mesh compression`);
37
- const transform = meshopt({ encoder: MeshoptEncoder });
38
- return transform(document, transform_context);
36
+ const hasBlendshapes = document.getRoot().listMeshes().some((mesh) => mesh.listPrimitives().some((prim) => prim.listTargets().length > 0));
37
+ if (hasBlendshapes) {
38
+ document.getLogger().info(`[${NAME}]: VRM → will apply meshopt mesh compression`);
39
+ const transform = meshopt({ encoder: MeshoptEncoder });
40
+ return transform(document, transform_context);
41
+ }
39
42
  }
40
43
  // by default we still want to run draco if the root doesnt have a compression model configured
41
44
  document.getLogger().info(`[${NAME}]: → will apply draco mesh compression (default)`);
@@ -2,6 +2,8 @@ import { ILogger, Texture, Transform, vec2 } from '@gltf-transform/core';
2
2
  import { Config } from '../config/index.js';
3
3
  declare interface ProgressiveOptions {
4
4
  path: string | Array<string>;
5
+ /** Directory to output progressive assets to */
6
+ outpath?: string;
5
7
  results: Array<string>;
6
8
  config: Config;
7
9
  debug?: boolean;
@@ -1,13 +1,13 @@
1
1
  import { BufferUtils, Document, NodeIO, PropertyType, TextureChannel, Verbosity } from '@gltf-transform/core';
2
- import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, listTextureChannels, listTextureSlots, sparse } from '@gltf-transform/functions';
2
+ import { SIMPLIFY_DEFAULTS, WELD_DEFAULTS, flatten, join, listTextureChannels, listTextureSlots, sparse } from '@gltf-transform/functions';
3
3
  import draco3d from 'draco3dgltf';
4
4
  import { NEEDLE_PROGRESSIVE_EXTENSION_NAME, NEEDLE_progressive } from "../extensions/NEEDLE_progressive/index.js";
5
5
  import { createOpaqueExtension, currentGenerator, NEEDLE_compression_texture, NEEDLE_mesh_compression, NEEDLE_progressive_mesh_settings, NEEDLE_progressive_texture_settings, registerExtensions as registerDefaultExtensions } from '../extensions/index.js';
6
6
  import path, { basename, resolve } from 'path';
7
- import { existsSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
7
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs';
8
8
  import { addMetaToCache, addToCache, getHash, getKey, tryGetFromCache, tryGetMetaFromCache } from '../cache/index.js';
9
9
  import sharp from "sharp";
10
- import { detectTextureCompressionMode, foreachGLTF, generateGuid, isLightmap, setLightmapName, validateImageMimeType, writeNodeIO } from '../utils/index.js';
10
+ import { detectTextureCompressionMode, ensureIsDirectory, foreachGLTF, generateGuid, getOutputPath, isLightmap, setLightmapName, validateImageMimeType, writeNodeIO } from '../utils/index.js';
11
11
  import { needle_asset, writeNeedleAsset } from './needle_asset.js';
12
12
  import { MeshoptDecoder, MeshoptSimplifier } from 'meshoptimizer';
13
13
  import { compressPrimitive } from './needle_mesh_transform.js';
@@ -77,7 +77,7 @@ async function onProcess(file, opts) {
77
77
  transforms.push(make_progressive_textures(file, { size: [smallesTextureSize, smallesTextureSize] }, opts));
78
78
  }
79
79
  // TODO: progressive meshes aren't supported yet for VRMs
80
- const isVRM = file.endsWith(".vrm");
80
+ const isVRM = file.toLowerCase().endsWith(".vrm");
81
81
  if (!isVRM && opts.config?.meshes?.lods !== false) {
82
82
  transforms.push(make_progressive_meshes(file, {}, opts));
83
83
  }
@@ -85,8 +85,12 @@ async function onProcess(file, opts) {
85
85
  logger.warn("WARN: Progressive meshes are not supported for VRM files yet");
86
86
  transforms.push(sparse());
87
87
  logger.info("→ Transforming: " + file);
88
+ if (!isVRM && opts.config?.usecase === "product") {
89
+ await document.transform(flatten(), join({ keepNamed: false }));
90
+ }
88
91
  await document.transform(...transforms);
89
- const filename = file;
92
+ const filename = getOutputPath(file, opts.outpath);
93
+ ;
90
94
  await writeNodeIO(io, filename, document);
91
95
  opts.results.push(filename);
92
96
  logger.info("← Transforming done: " + filename);
@@ -111,7 +115,10 @@ export function make_progressive_textures(filePath, _options = TEXTURE_RESIZE_DE
111
115
  const startTime = Date.now();
112
116
  const filename = basename(filePath);
113
117
  const options = { ...TEXTURE_RESIZE_DEFAULTS, ..._options };
114
- const directory = path.dirname(filePath);
118
+ let directory = path.dirname(filePath);
119
+ if (opts.outpath) {
120
+ directory = ensureIsDirectory(opts.outpath);
121
+ }
115
122
  // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
116
123
  const textureLodBasePath = directory + "/";
117
124
  return createTransform(NAME, async (doc) => {
@@ -367,7 +374,10 @@ async function createTextureLod(args, opts) {
367
374
  const ext = ".glb";
368
375
  const imageName = "image_" + level + "_" + guid;
369
376
  const filename = imageName + ext;
370
- const outName = basePath + filename;
377
+ const outName = path.join(basePath, filename);
378
+ if (!existsSync(basePath)) {
379
+ mkdirSync(basePath, { recursive: true });
380
+ }
371
381
  logger.info(`Write texture LOD ${level} to \"${filename}\"`);
372
382
  newIo.write(outName, newDoc);
373
383
  return {
@@ -480,7 +490,10 @@ async function resizeTexture(options, texture, logger, uri, slots) {
480
490
  };
481
491
  }
482
492
  export function make_progressive_meshes(filePath, _options, opts) {
483
- const directory = path.dirname(filePath);
493
+ let directory = path.dirname(filePath);
494
+ if (opts.outpath) {
495
+ directory = ensureIsDirectory(opts.outpath);
496
+ }
484
497
  // const fileNameWithoutExtension = path.basename(filePath, path.extname(filePath));
485
498
  const outputDirectory = directory + "/";
486
499
  return createTransform("PROGRESSIVE_MESHES", async (doc, _context) => {
@@ -2,7 +2,7 @@ import { BufferUtils, FileUtils, ImageUtils, uuid } from "@gltf-transform/core";
2
2
  import { listTextureSlots, getTextureChannelMask, fitPowerOfTwo, TextureResizeFilter } from "@gltf-transform/functions";
3
3
  import { KHRTextureBasisu } from "@gltf-transform/extensions";
4
4
  import { checkKTXSoftware, createParams, KTX_SOFTWARE_VERSION_MIN, Mode } from "./toktx.js";
5
- import { existsSync, mkdirSync, rmdirSync } from "fs";
5
+ import { existsSync, mkdirSync, rmSync } from "fs";
6
6
  import { formatBytes, spawn, waitExit } from './util.js';
7
7
  import { ETC1S_DEFAULTS, UASTC_DEFAULTS } from "./toktx_types.js";
8
8
  import fs from 'fs/promises';
@@ -233,7 +233,7 @@ export class NeedleToktxContext {
233
233
  this.basisuExtension?.dispose();
234
234
  }
235
235
  if (this.cacheDirectory && existsSync(this.cacheDirectory)) {
236
- rmdirSync(this.cacheDirectory, { recursive: true });
236
+ rmSync(this.cacheDirectory, { recursive: true });
237
237
  }
238
238
  }
239
239
  }
@@ -6,6 +6,7 @@ export interface NeedleWebPOptions {
6
6
  export declare class NeedleWebPContext implements INeedleTextureTransformContext {
7
7
  options: NeedleWebPOptions;
8
8
  logger: ILogger;
9
+ private compressed;
9
10
  constructor(options: NeedleWebPOptions);
10
11
  prepare(document: Document): Promise<void>;
11
12
  process(index: number, texture: Texture, settings: Pick<NEEDLE_compression_texture_schema, "quality">, context: NeedleTransformContext): Promise<boolean>;
@@ -1,12 +1,15 @@
1
1
  import sharp from 'sharp';
2
2
  import { BufferUtils, HTTPUtils } from '@gltf-transform/core';
3
+ import { EXTTextureWebP } from '@gltf-transform/extensions';
3
4
  export class NeedleWebPContext {
4
5
  options;
5
6
  logger;
7
+ compressed = 0;
6
8
  constructor(options) {
7
9
  this.options = options;
8
10
  }
9
11
  async prepare(document) {
12
+ this.compressed = 0;
10
13
  this.logger = document.getLogger();
11
14
  }
12
15
  async process(index, texture, settings, context) {
@@ -30,8 +33,13 @@ export class NeedleWebPContext {
30
33
  .setMimeType('image/webp')
31
34
  .setImage(BufferUtils.toView(buffer))
32
35
  .setURI(HTTPUtils.basename(texture.getURI()) + '.webp');
36
+ this.compressed += 1;
33
37
  return true;
34
38
  }
35
39
  async finalize(document) {
40
+ if (this.compressed > 0) {
41
+ const webpExtension = document.createExtension(EXTTextureWebP);
42
+ webpExtension.setRequired(true);
43
+ }
36
44
  }
37
45
  }
@@ -1,6 +1,19 @@
1
+ /**
2
+ * Get the output path for a file based on the output directory or file.
3
+ * If outDirOrFile is a filepath, it will be returned as is.
4
+ * If outDirOrFile is a directory, the file will be placed in that directory.
5
+ */
6
+ export declare function getOutputPath(filepath: string, outDirOrFile: string | undefined | null, opts?: {
7
+ create?: boolean;
8
+ }): string;
1
9
  export declare function getFileName(filepath: string): string;
2
10
  export declare function getDirName(filepath: string): string;
3
11
  export declare type GLTFFileCallback = (file: string) => void | Promise<void>;
4
12
  export declare function foreachGLTF(args: string | string[], callback: GLTFFileCallback): Promise<void>;
5
13
  export declare function replaceExtension(filepath: string, newExtension: string): string;
6
14
  export declare function isDirectory(path: string): boolean;
15
+ /**
16
+ * Ensure that a path is a directory.
17
+ * If the directory does not exist, it will be created.
18
+ */
19
+ export declare function ensureIsDirectory(path: string): string;
@@ -1,5 +1,25 @@
1
- import { existsSync, statSync, readdirSync, readFileSync } from 'fs';
2
- import { isAbsolute } from 'path';
1
+ import { existsSync, statSync, readdirSync, readFileSync, mkdirSync } from 'fs';
2
+ import { dirname, isAbsolute, join } from 'path';
3
+ /**
4
+ * Get the output path for a file based on the output directory or file.
5
+ * If outDirOrFile is a filepath, it will be returned as is.
6
+ * If outDirOrFile is a directory, the file will be placed in that directory.
7
+ */
8
+ export function getOutputPath(filepath, outDirOrFile, opts) {
9
+ if (!outDirOrFile) {
10
+ return filepath;
11
+ }
12
+ if (!isDirectory(outDirOrFile)) {
13
+ return outDirOrFile;
14
+ }
15
+ const filename = getFileName(filepath);
16
+ // create the directory if it doesn't exist (default behavior)
17
+ if (opts?.create != false && !existsSync(outDirOrFile)) {
18
+ mkdirSync(outDirOrFile, { recursive: true });
19
+ }
20
+ const outpath = join(outDirOrFile, filename);
21
+ return outpath;
22
+ }
3
23
  export function getFileName(filepath) {
4
24
  const parts = filepath.replaceAll("\\", "/").split("/");
5
25
  return parts[parts.length - 1];
@@ -139,3 +159,16 @@ export function isDirectory(path) {
139
159
  const hasExt = path.replaceAll("\\", "/").split("/").pop()?.includes(".");
140
160
  return !hasExt;
141
161
  }
162
+ /**
163
+ * Ensure that a path is a directory.
164
+ * If the directory does not exist, it will be created.
165
+ */
166
+ export function ensureIsDirectory(path) {
167
+ if (!isDirectory(path)) {
168
+ path = dirname(path);
169
+ }
170
+ if (!existsSync(path)) {
171
+ mkdirSync(path, { recursive: true });
172
+ }
173
+ return path;
174
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.2.1-alpha.7",
3
+ "version": "2.3.0-alpha",
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",