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

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,10 @@ 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.2.1-alpha.1] - 2023-06-17
8
+ - add: `--verbose` option to transform command
9
+ - add: `stats` command to create stats.needle.json
10
+
7
11
  ## [2.2.1-alpha] - 2023-06-15
8
12
  - fix: issue with progressive mesh cache key calculation when running multiple transform processes with different configurations
9
13
 
package/dist/cli/index.js CHANGED
@@ -1,13 +1,13 @@
1
1
  #! /usr/bin/env node
2
2
  import { program } from '@donmccurdy/caporal';
3
- import { packGLTF } from "../scripts/index.js";
3
+ import { calculateStats, packGLTF } from "../scripts/index.js";
4
4
  import { printStats, writeFileStatsToFile } from '../utils/stats.js';
5
5
  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 } from '../cache/cache.js';
9
9
  import { statSync } from 'fs';
10
- import { foreachGLTF, getDirName } from '../utils/fileutils.js';
10
+ import { foreachGLTF, getDirName, isDirectory } from '../utils/fileutils.js';
11
11
  import path, { dirname } from 'path';
12
12
  import { ERROR_CODES } from '../constants.js';
13
13
  // For testing / dev you can run `npm link` in the package directory
@@ -24,6 +24,32 @@ Caches are limited to ${cacheSizeLimit} MB disc space by default.
24
24
  .action(async ({ logger }) => {
25
25
  logger.info("Clearing caches");
26
26
  clearCache(logger);
27
+ })
28
+ .command("stats", "Calculate glTF stats")
29
+ .help(`
30
+ `)
31
+ .argument('<input>', "Path to a glTF, GLB or VRM file OR a directory")
32
+ .argument('<output>', "Output path for the stats", { default: null, validator: program.STRING })
33
+ .action(async ({ logger, args, options }) => {
34
+ logger.level = "info";
35
+ logger.info("Calculating stats for \"" + args.input + "\"");
36
+ const input = args.input.toString();
37
+ const filestats = new Array();
38
+ await foreachGLTF(input, async (file) => {
39
+ const stats = await calculateStats(file);
40
+ filestats.push(stats);
41
+ });
42
+ let output = args.ourput?.toString();
43
+ if (!output) {
44
+ if (isDirectory(input)) {
45
+ output = path.join(input, "stats.needle.json");
46
+ }
47
+ else {
48
+ output = input + ".needle.stats.json";
49
+ }
50
+ }
51
+ logger.info("Writing stats to: " + output);
52
+ writeFileStatsToFile(filestats, output);
27
53
  })
28
54
  .command("transform", "Transform (progressive loading) and compress glTF, GLB or VRM files")
29
55
  .help(`
@@ -39,6 +65,7 @@ Each version will be compressed and written to the output directory.
39
65
  .option("--stats", "Output a stats file next to input file (or inside the input directory). It will be named as the input file/directory \"<input>.needle.stats.json\"", { validator: program.BOOLEAN, default: false })
40
66
  .option("--cache", "Use \"--cache False\" to disable caching.\nWhen 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: true })
41
67
  .option("--debug", "Enable for more verbose outout with \"--debug True\"", { validator: program.BOOLEAN, default: false })
68
+ .option("--verbose", "Enable for more verbose outout with \"--verbose True\"", { validator: program.BOOLEAN, default: false })
42
69
  .action(async ({ logger, args, options }) => {
43
70
  if (options.compress === false && options.progressive === false) {
44
71
  logger.error("You need to enable at least one of the options: --compress or --progressive");
@@ -69,7 +96,7 @@ Each version will be compressed and written to the output directory.
69
96
  const output = options.output?.toString() || file;
70
97
  const outputIsDirectory = statSync(output).isDirectory();
71
98
  const outDir = outputIsDirectory ? output : dirname(output);
72
- const file_stats = [];
99
+ const file_stats = options.stats === true ? new Array : null;
73
100
  const set = new Set();
74
101
  const useCache = options.cache !== false;
75
102
  const debug = options.debug === true;
@@ -113,7 +140,7 @@ Each version will be compressed and written to the output directory.
113
140
  printStats(stats, logger, config);
114
141
  if (options.stats) {
115
142
  const statsFile = (outputIsDirectory ? (path.join(file, getDirName(file))) : file) + ".needle.stats.json";
116
- writeFileStatsToFile(config, file_stats, statsFile);
143
+ writeFileStatsToFile(file_stats, statsFile);
117
144
  }
118
145
  });
119
146
  program.run().catch(err => {
@@ -1 +1,2 @@
1
1
  export * from "./pack-gltf.js";
2
+ export * from "./stats.js";
@@ -1 +1,2 @@
1
1
  export * from "./pack-gltf.js";
2
+ export * from "./stats.js";
@@ -1,14 +1,15 @@
1
1
  import { ILogger } from '@gltf-transform/core';
2
- import { FileStats, Stats } from "../utils/index.js";
2
+ import { FileStats, ProcessStats } from "../utils/index.js";
3
3
  import { Config } from '../config/index.js';
4
4
  export type PackGLTFOptions = {
5
- debug: boolean;
6
5
  useCache: boolean;
7
6
  packedFiles?: Set<string>;
8
- stats?: Stats;
7
+ stats?: ProcessStats;
9
8
  file_stats: FileStats[] | null;
10
9
  config: Config;
11
10
  logger?: ILogger;
11
+ debug: boolean;
12
+ verbose?: boolean;
12
13
  };
13
14
  /** Compress a glTF file
14
15
  */
@@ -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 ? Verbosity.DEBUG : Verbosity.WARN;
63
+ document.getLogger().verbosity = options.debug || options.verbose ? Verbosity.DEBUG : Verbosity.WARN;
64
64
  let totalGPUTextureMemory = 0;
65
65
  /** @type {Array<import('@gltf-transform/core').Transform>} */
66
66
  const transforms = [
@@ -107,9 +107,7 @@ export async function packGLTF(inputFile, outputFile, options) {
107
107
  stats.totalFileSizeInMBBefore += prevSizeInMB;
108
108
  stats.totalGPUMemoryInMB += gpuMemoryInMB;
109
109
  }
110
- const filestats = createNewFilestats(outputFile, document, {
111
- gpu_memory: totalGPUTextureMemory,
112
- });
110
+ const filestats = createNewFilestats(outputFile, document);
113
111
  options.file_stats?.push(filestats);
114
112
  return true;
115
113
  }
@@ -0,0 +1,2 @@
1
+ import { FileStats } from "../utils/stats.js";
2
+ export declare function calculateStats(inputfile: string): Promise<FileStats>;
@@ -0,0 +1,18 @@
1
+ import { NodeIO } from "@gltf-transform/core";
2
+ import { ALL_EXTENSIONS } from "@gltf-transform/extensions";
3
+ import draco3d from 'draco3dgltf';
4
+ import { MeshoptDecoder, MeshoptEncoder } from "meshoptimizer";
5
+ import { createNewFilestats } from "../utils/stats.js";
6
+ export async function calculateStats(inputfile) {
7
+ const io = new NodeIO()
8
+ .registerExtensions(ALL_EXTENSIONS)
9
+ .registerDependencies({
10
+ 'draco3d.decoder': await draco3d.createDecoderModule(),
11
+ 'draco3d.encoder': await draco3d.createEncoderModule(),
12
+ 'meshopt.decoder': MeshoptDecoder,
13
+ 'meshopt.encoder': MeshoptEncoder,
14
+ });
15
+ const document = await io.read(inputfile);
16
+ const stats = createNewFilestats(inputfile, document);
17
+ return stats;
18
+ }
@@ -5,6 +5,7 @@ declare interface ProgressiveOptions {
5
5
  results: Array<string>;
6
6
  config: Config;
7
7
  debug?: boolean;
8
+ verbose?: boolean;
8
9
  useCache?: boolean;
9
10
  }
10
11
  export declare function make_progressive(opts: ProgressiveOptions): Promise<boolean>;
@@ -65,7 +65,7 @@ async function onProcess(file, opts) {
65
65
  if (document) {
66
66
  const logger = document.getLogger();
67
67
  //@ts-ignore
68
- logger.verbosity = opts.debug ? Verbosity.DEBUG : Verbosity.WARN;
68
+ logger.verbosity = opts.debug || opts.verbose ? Verbosity.DEBUG : Verbosity.WARN;
69
69
  let smallesTextureSize = 128;
70
70
  if (opts.config.usecase === "product") {
71
71
  smallesTextureSize = 512;
@@ -45,11 +45,6 @@ export class NeedleToktxContext {
45
45
  this.env.PATH = `${process.env.NEEDLE_TOKTX};${this.env.PATH}`;
46
46
  }
47
47
  }
48
- else {
49
- if (didLogEnvironmentInfo === false)
50
- console.log("INFO: Environment variable 'NEEDLE_TOKTX' not set. Use 'NEEDLE_TOKTX' to specify the directory of the toktx installation (path/to/bin)");
51
- didLogEnvironmentInfo = true;
52
- }
53
48
  // fallback default installation directory:
54
49
  if (isUsingWindows) {
55
50
  const defaultPath = "C:\\Program Files\\KTX-Software\\bin";
@@ -59,10 +54,15 @@ export class NeedleToktxContext {
59
54
  // append default system paths
60
55
  if (isUsingWindows && process.env.Path?.length)
61
56
  this.env.PATH = `${this.env.PATH};${process.env.Path}`;
57
+ const ktxPaths = this.env.PATH.split(";").filter(p => p.toLocaleLowerCase().includes("ktx"));
62
58
  if (options.debug) {
63
- const ktxPaths = this.env.PATH.split(";").filter(p => p.toLocaleLowerCase().includes("ktx"));
64
59
  console.log("INFO: Found these paths including ktx:\n\t" + ktxPaths.join("\n\t") + "");
65
60
  }
61
+ else if (ktxPaths?.length <= 0) {
62
+ if (didLogEnvironmentInfo === false)
63
+ console.log("INFO: Environment variable 'NEEDLE_TOKTX' not set. Use 'NEEDLE_TOKTX' to specify the directory of the toktx installation (path/to/bin)");
64
+ didLogEnvironmentInfo = true;
65
+ }
66
66
  }
67
67
  async prepare(document) {
68
68
  try {
@@ -24,7 +24,7 @@ export class NeedleWebPContext {
24
24
  options.quality = 1;
25
25
  }
26
26
  // console.log(options);
27
- console.log(`> Will compress texture ${texture.getName()} to webp with quality ${options.quality}`);
27
+ this.logger.info(`→ Will compress texture ${texture.getName()} to webp with quality ${options.quality}`);
28
28
  const buffer = await sharp(texture.getImage()).toFormat('webp', options).toBuffer();
29
29
  texture
30
30
  .setMimeType('image/webp')
@@ -3,3 +3,4 @@ export declare function getDirName(filepath: string): string;
3
3
  export declare type GLTFFileCallback = (file: string) => void | Promise<void>;
4
4
  export declare function foreachGLTF(args: string | string[], callback: GLTFFileCallback): Promise<void>;
5
5
  export declare function replaceExtension(filepath: string, newExtension: string): string;
6
+ export declare function isDirectory(path: string): boolean;
@@ -132,3 +132,10 @@ export function replaceExtension(filepath, newExtension) {
132
132
  parts.pop();
133
133
  return parts.join(".") + "." + newExtension;
134
134
  }
135
+ export function isDirectory(path) {
136
+ if (existsSync(path)) {
137
+ return statSync(path).isDirectory();
138
+ }
139
+ const hasExt = path.replaceAll("\\", "/").split("/").pop()?.includes(".");
140
+ return !hasExt;
141
+ }
@@ -6,3 +6,4 @@ export * from "./texture.js";
6
6
  export * from "./guid.js";
7
7
  export * from "./nodeio.js";
8
8
  export * from "./stats.js";
9
+ export * from "./merge.js";
@@ -6,3 +6,4 @@ export * from "./texture.js";
6
6
  export * from "./guid.js";
7
7
  export * from "./nodeio.js";
8
8
  export * from "./stats.js";
9
+ export * from "./merge.js";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Simple object check.
3
+ * @param item
4
+ * @returns {boolean}
5
+ */
6
+ export declare function isObject(item: object): boolean;
7
+ /**
8
+ * Deep merge two objects.
9
+ * @param target
10
+ * @param ...sources
11
+ */
12
+ export declare function mergeDeep(target: any, ...sources: any[]): any;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Simple object check.
3
+ * @param item
4
+ * @returns {boolean}
5
+ */
6
+ export function isObject(item) {
7
+ return (item && typeof item === 'object' && !Array.isArray(item));
8
+ }
9
+ /**
10
+ * Deep merge two objects.
11
+ * @param target
12
+ * @param ...sources
13
+ */
14
+ export function mergeDeep(target, ...sources) {
15
+ if (!sources.length)
16
+ return target;
17
+ const source = sources.shift();
18
+ if (isObject(target) && isObject(source)) {
19
+ for (const key in source) {
20
+ if (isObject(source[key])) {
21
+ if (!target[key])
22
+ Object.assign(target, { [key]: {} });
23
+ mergeDeep(target[key], source[key]);
24
+ }
25
+ else {
26
+ Object.assign(target, { [key]: source[key] });
27
+ }
28
+ }
29
+ }
30
+ return mergeDeep(target, ...sources);
31
+ }
@@ -1,29 +1,53 @@
1
1
  import { Logger } from "@donmccurdy/caporal";
2
2
  import { Document, Logger as _Logger } from "@gltf-transform/core";
3
3
  import { Config } from "../config/index.js";
4
- export type Stats = {
4
+ export type ProcessStats = {
5
5
  startTime: number;
6
6
  totalGPUMemoryInMB: number;
7
7
  totalFilesProcessed: number;
8
8
  totalFileSizeInMB: number;
9
9
  totalFileSizeInMBBefore: number;
10
10
  };
11
- export declare function printStats(stats: Stats, logger: Logger | _Logger, config: Config): void;
12
- export type FileStats = {
13
- filename: string;
11
+ export declare function printStats(stats: ProcessStats, logger: Logger | _Logger, config: Config): void;
12
+ declare class Stats {
14
13
  /** Size in bytes */
15
14
  size: number;
16
- /** Memory in bytes */
17
- gpu_memory: number;
15
+ asset: {
16
+ version: string;
17
+ generator?: string;
18
+ copyright?: string;
19
+ extras: any;
20
+ };
18
21
  nodes: number;
19
22
  materials: number;
20
- meshes: number;
21
- textures: number;
22
- vertex_count: number;
23
- triangle_count: number;
24
- };
23
+ meshes: MeshInfo;
24
+ textures: TextureInfo;
25
+ constructor(document: Document, size: number);
26
+ static combine(others: Stats[]): Stats;
27
+ }
28
+ export declare class FileStats extends Stats {
29
+ readonly filename: string;
30
+ constructor(file: string, document: Document);
31
+ }
32
+ export declare class MeshInfo {
33
+ count: number;
34
+ primitives: number;
35
+ vertices: number;
36
+ triangles: number;
37
+ memory: number;
38
+ formats: string[];
39
+ addTo(other: MeshInfo): void;
40
+ }
41
+ export declare class TextureInfo {
42
+ count: number;
43
+ memory: number;
44
+ gpu_memory: number;
45
+ formats: string[];
46
+ addTo(other: TextureInfo): void;
47
+ }
25
48
  /**
26
49
  * Create a new filestats object from a file and a document
27
50
  */
28
- export declare function createNewFilestats(file: string, document: Document, stats: Pick<FileStats, "gpu_memory">): FileStats;
29
- export declare function writeFileStatsToFile(config: Config, filestats: FileStats[], output_path: string): void;
51
+ export declare function createNewFilestats(file: string, document: Document): FileStats;
52
+ export declare function writeFileStatsToFile(filestats: FileStats[], output_path: string): string | null;
53
+ export {};
@@ -1,4 +1,5 @@
1
1
  import { getVersion } from "./version.js";
2
+ import { Document, ImageUtils } from "@gltf-transform/core";
2
3
  import { getFileName } from "./fileutils.js";
3
4
  import { statSync, writeFileSync } from "fs";
4
5
  export function printStats(stats, logger, config) {
@@ -10,36 +11,156 @@ export function printStats(stats, logger, config) {
10
11
  else
11
12
  console.log(msg);
12
13
  }
14
+ class Stats {
15
+ /** Size in bytes */
16
+ size;
17
+ asset;
18
+ nodes;
19
+ materials;
20
+ meshes;
21
+ textures;
22
+ constructor(document, size) {
23
+ const root = document.getRoot();
24
+ const asset = root.getAsset();
25
+ this.asset = {
26
+ generator: asset.generator,
27
+ version: asset.version,
28
+ copyright: asset.copyright,
29
+ extras: asset.extras,
30
+ };
31
+ this.size = size;
32
+ this.nodes = root.listNodes().length;
33
+ this.materials = root.listMaterials().length;
34
+ this.meshes = calculateMeshInfo(document);
35
+ this.textures = calculateTextureInfo(document);
36
+ }
37
+ static combine(others) {
38
+ const res = new Stats(new Document(), 0);
39
+ for (const other of others) {
40
+ res.size += other.size;
41
+ res.nodes += other.nodes;
42
+ res.materials += other.materials;
43
+ res.meshes.addTo(other.meshes);
44
+ res.textures.addTo(other.textures);
45
+ }
46
+ return res;
47
+ }
48
+ }
49
+ export class FileStats extends Stats {
50
+ filename;
51
+ constructor(file, document) {
52
+ super(document, statSync(file).size);
53
+ this.filename = getFileName(file);
54
+ }
55
+ }
56
+ export class MeshInfo {
57
+ count = 0;
58
+ primitives = 0;
59
+ vertices = 0;
60
+ triangles = 0;
61
+ memory = 0;
62
+ formats = [];
63
+ addTo(other) {
64
+ this.count += other.count;
65
+ this.primitives += other.primitives;
66
+ this.vertices += other.vertices;
67
+ this.triangles += other.triangles;
68
+ this.memory += other.memory;
69
+ this.formats = Array.from(new Set(this.formats.concat(other.formats)));
70
+ }
71
+ }
72
+ export class TextureInfo {
73
+ count = 0;
74
+ memory = 0;
75
+ gpu_memory = 0;
76
+ formats = [];
77
+ addTo(other) {
78
+ this.count += other.count;
79
+ this.memory += other.memory;
80
+ this.gpu_memory += other.gpu_memory;
81
+ this.formats = Array.from(new Set(this.formats.concat(other.formats)));
82
+ }
83
+ }
13
84
  /**
14
85
  * Create a new filestats object from a file and a document
15
86
  */
16
- export function createNewFilestats(file, document, stats) {
17
- const filename = getFileName(file);
18
- const root = document.getRoot();
19
- const filestats = {
20
- filename,
21
- size: statSync(file).size,
22
- gpu_memory: Math.round(stats.gpu_memory),
23
- nodes: root.listNodes().length,
24
- materials: root.listMaterials().length,
25
- meshes: root.listMeshes().length,
26
- textures: root.listTextures().length,
27
- vertex_count: 0,
28
- triangle_count: 0,
29
- };
30
- // Count vertices
31
- for (const mesh of root.listMeshes()) {
32
- filestats.vertex_count += mesh.listPrimitives().reduce((acc, p) => acc + p.getAttribute('POSITION').getCount(), 0);
33
- }
34
- filestats.triangle_count = filestats.vertex_count / 3;
87
+ export function createNewFilestats(file, document) {
88
+ const filestats = new FileStats(file, document);
35
89
  return filestats;
36
90
  }
37
- export function writeFileStatsToFile(config, filestats, output_path) {
91
+ export function writeFileStatsToFile(filestats, output_path) {
38
92
  if (output_path == undefined) {
39
93
  console.error("No stats output path provided");
40
- return;
94
+ return null;
41
95
  }
42
- console.log("Writing file stats to: " + output_path);
43
- const json = JSON.stringify(filestats);
96
+ const res = {
97
+ sum: Stats.combine(filestats),
98
+ files: filestats,
99
+ };
100
+ const json = JSON.stringify(res);
44
101
  writeFileSync(output_path, json);
102
+ return json;
103
+ }
104
+ function calculateMeshInfo(document) {
105
+ const root = document.getRoot();
106
+ const meshes = root.listMeshes();
107
+ const res = new MeshInfo();
108
+ res.count = meshes.length;
109
+ for (const mesh of root.listMeshes()) {
110
+ const primitives = mesh.listPrimitives();
111
+ res.primitives += primitives.length;
112
+ for (const prim of primitives) {
113
+ const positions = prim.getAttribute('POSITION');
114
+ const indices = prim.getIndices();
115
+ if (positions) {
116
+ res.vertices += positions.getCount();
117
+ }
118
+ if (indices) {
119
+ res.triangles += indices.getCount() / 3;
120
+ }
121
+ else {
122
+ res.triangles += positions.getCount() / 3;
123
+ }
124
+ const semantics = prim.listSemantics();
125
+ for (const semantic of semantics) {
126
+ const accessor = prim.getAttribute(semantic);
127
+ if (accessor) {
128
+ const buffer = accessor.getByteLength();
129
+ res.memory += buffer;
130
+ }
131
+ }
132
+ }
133
+ }
134
+ const extensions = root.listExtensionsUsed();
135
+ for (const ext of extensions) {
136
+ if (ext.extensionName.startsWith("EXT_meshopt_compression")) {
137
+ res.formats.push("meshopt");
138
+ }
139
+ else if (ext.extensionName.startsWith("KHR_draco_mesh_compression")) {
140
+ res.formats.push("draco");
141
+ }
142
+ }
143
+ // filter formats so we only have unique values
144
+ res.formats = Array.from(new Set(res.formats));
145
+ return res;
146
+ }
147
+ function calculateTextureInfo(document) {
148
+ const root = document.getRoot();
149
+ const textures = root.listTextures();
150
+ const res = new TextureInfo();
151
+ res.count = textures.length;
152
+ res.formats = textures.map(t => t.getMimeType());
153
+ res.gpu_memory = 0;
154
+ res.memory = 0;
155
+ for (const texture of textures) {
156
+ const image = texture.getImage();
157
+ const mimetype = texture.getMimeType();
158
+ if (image && mimetype) {
159
+ res.gpu_memory += ImageUtils.getVRAMByteLength(image, mimetype);
160
+ res.memory += image.byteLength;
161
+ }
162
+ }
163
+ // filter formats so we only have unique values
164
+ res.formats = Array.from(new Set(res.formats));
165
+ return res;
45
166
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@needle-tools/gltf-build-pipeline",
3
- "version": "2.2.1-alpha",
3
+ "version": "2.2.1-alpha.1",
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",