Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ node-assets pipeline input.glb ktx2 draco output.glb
The command syntax is:

```text
node-assets pipeline <input> [operation...] <output>
node-assets pipeline <input> [operation...] <output> [--stats] [--benchmark]
```

| Element | Supported values |
Expand All @@ -32,6 +32,41 @@ Without specifying operations, the CLI reads the input and writes it back out as
Run `node-assets --help` for command help or `node-assets --version` for the
installed version.

### Run reports

```sh
node-assets pipeline input.glb draco output.glb --stats --benchmark
```

Use either flag independently or both together. Reports appear after the output
file has been written successfully. Without these flags, no run report is printed.

| Flag | Report |
| ------------- | ------ |
| `--stats` | Total size before and after for the named input and output files. |
| `--benchmark` | Completion time, user and system CPU time, RSS, peak RSS, and heap used. |

Size statistics compare serialized file sizes, not decoded asset sizes. The input
size excludes external buffers and images referenced by a `.gltf` or `.glb` file.

Benchmark timing covers pipeline creation (including library loading), execution,
output writing, and disposal, but excludes argument parsing, input validation, and
report printing. CPU times are process-wide and may include worker threads. RSS
(resident set size) and heap used are process-wide snapshots at completion; peak
RSS is the process-lifetime high-water mark, not a per-pipeline memory delta.

The total size before selects a shared unit for both size totals, using
1024-based units (B, KiB, MiB, GiB, TiB, PiB). Memory values scale independently.
Bytes remain whole numbers; larger units use two decimal places. For example,
2,088 bytes before and 776 bytes after display as `2.04 KiB` and `0.76 KiB`.

Completion time selects a shared unit for all timing rows: milliseconds (`ms`),
seconds (`s`), minutes (`min`), or hours (`h`), with two decimal places. For
example, a 2,538.93 ms run displays as `2.54 s`, and 115.06 ms of system CPU time
displays as `0.12 s`. Size and time unit selection advances to the next unit when
rounding reaches its boundary. The selected unit is kept for the other rows,
even when their values would otherwise use a different unit.

## Develop locally

From the repository root:
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/benchmark.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { createDurationFormatter, formatBytes } from "./format";

export function startBenchmark(): () => string {
const startedAt = process.hrtime.bigint();
const initialCpuUsage = process.cpuUsage();

return () => {
const elapsedMilliseconds = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const cpuUsage = process.cpuUsage(initialCpuUsage);
const memoryUsage = process.memoryUsage();
const resourceUsage = process.resourceUsage();
const formatDuration = createDurationFormatter(elapsedMilliseconds);

return [
"Benchmark:",
` Completion time: ${formatDuration(elapsedMilliseconds)}`,
` CPU time (user): ${formatDuration(cpuUsage.user / 1_000)}`,
` CPU time (system): ${formatDuration(cpuUsage.system / 1_000)}`,
` RSS: ${formatBytes(memoryUsage.rss)}`,
` Peak RSS (process lifetime): ${formatBytes(resourceUsage.maxRSS * 1_024)}`,
` Heap used: ${formatBytes(memoryUsage.heapUsed)}`,
].join("\n");
};
}
25 changes: 22 additions & 3 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { resolve } from "node:path";
import { parseArgs } from "node:util";

import { version } from "../package.json";
import { startBenchmark } from "./benchmark";
import { createByteFormatter } from "./format";
import { createPipelineAsync, getPipelineDefinitions } from "./pipeline";

export async function runCliAsync(args: string[]): Promise<void> {
Expand All @@ -13,6 +15,8 @@ export async function runCliAsync(args: string[]): Promise<void> {
options: {
help: { type: "boolean", short: "h" },
version: { type: "boolean", short: "v" },
stats: { type: "boolean" },
benchmark: { type: "boolean" },
},
});

Expand All @@ -36,25 +40,38 @@ export async function runCliAsync(args: string[]): Promise<void> {

const inputPath = resolve(input);
const outputPath = resolve(output);
if (!(await stat(inputPath)).isFile()) {
const inputFile = await stat(inputPath);
if (!inputFile.isFile()) {
throw new Error(`Input is not a regular file: ${inputPath}`);
}

const finishBenchmark = values.benchmark ? startBenchmark() : undefined;
const asset = await createPipelineAsync({ inputPath, outputPath, blockNames: remaining });
let outputSize: number;
try {
const file = await asset.executeAsync();
await writeFile(outputPath, new Uint8Array(await file.arrayBuffer()), { flag: "wx" });
console.log(`Wrote ${outputPath}`);
outputSize = file.size;
} finally {
asset.dispose();
}
const benchmarkReport = finishBenchmark?.();

console.log(`Wrote ${outputPath}`);
if (values.stats) {
const formatSize = createByteFormatter(inputFile.size);
console.log(`Stats:\n Total size before: ${formatSize(inputFile.size)}\n Total size after: ${formatSize(outputSize)}`);
}
if (benchmarkReport !== undefined) {
console.log(benchmarkReport);
}
}

function printHelp(): void {
const { inputs, outputs, operations } = getPipelineDefinitions();
console.log(
[
"Usage: node-assets pipeline <input> [operations...] <output>",
"Usage: node-assets pipeline <input> [operations...] <output> [--stats] [--benchmark]",
"",
`Supported file types:`,
`Input: ${inputs.flatMap(({ extensions }) => extensions).join(", ")}`,
Expand All @@ -67,6 +84,8 @@ function printHelp(): void {
"Options:",
" -h, --help Show this help",
" -v, --version Show the CLI version",
" --stats Show named input/output file sizes in readable units",
" --benchmark Show completion time, CPU time, RSS, and heap usage",
" -- End options before hyphen-prefixed paths",
"",
"Example: node-assets pipeline input.gltf ktx2 draco output.glb",
Expand Down
46 changes: 46 additions & 0 deletions packages/cli/src/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export function createByteFormatter(referenceBytes: number): (bytes: number) => string {
return createFormatter({
referenceValue: referenceBytes,
baseUnit: "B",
basePrecision: 0,
steps: ["KiB", "MiB", "GiB", "TiB", "PiB"].map((unit) => ({ unit, factor: 1_024 })),
});
}

export function createDurationFormatter(referenceMilliseconds: number): (milliseconds: number) => string {
return createFormatter({
referenceValue: referenceMilliseconds,
baseUnit: "ms",
basePrecision: 2,
steps: [
{ unit: "s", factor: 1_000 },
{ unit: "min", factor: 60 },
{ unit: "h", factor: 60 },
],
});
}

export function formatBytes(bytes: number): string {
return createByteFormatter(bytes)(bytes);
}

interface FormatterOptions {
readonly referenceValue: number;
readonly baseUnit: string;
readonly basePrecision: number;
readonly steps: readonly { readonly unit: string; readonly factor: number }[];
}

function createFormatter({ referenceValue, baseUnit, basePrecision, steps }: FormatterOptions): (value: number) => string {
let divisor = 1;
let unit = baseUnit;
for (const step of steps) {
if (Number((referenceValue / divisor).toFixed(2)) < step.factor) {
break;
}
divisor *= step.factor;
unit = step.unit;
}
const precision = divisor === 1 ? basePrecision : 2;
return (value) => `${(value / divisor).toFixed(precision)} ${unit}`;
}
21 changes: 21 additions & 0 deletions packages/core/docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,27 @@ const asset = new NodeAsset({
const result = await asset.executeAsync();
```

# Example: CLI run reports

```sh
node-assets pipeline input.glb draco output.glb --stats --benchmark
```

Both flags are optional and can be used independently. `--stats` reports the total
size before and after, comparing the named input file with the written
output file. External buffers and images referenced by a glTF input are not
included in the input file size.

`--benchmark` reports elapsed time and CPU time for pipeline creation, execution,
output writing, and disposal. It also reports process RSS and heap usage at
completion, plus peak RSS over the process lifetime. Reports are printed only
after a successful run. The total size before selects the unit for both size
totals, using 1024-based units (B, KiB, MiB, GiB, TiB, PiB). Completion time selects
the unit for all timing rows (ms, s, min, h). Memory values scale independently.
Times and sizes above bytes use two decimal places; bytes remain whole numbers.
Unit selection advances to the next unit when rounding reaches its boundary.
See the [CLI guide](../../cli/README.md) for command syntax.

# Example: Compressing GLB

Same as before, but now add KTX2 texture encoding and Draco geometry encoding.
Expand Down
134 changes: 128 additions & 6 deletions tests/e2e/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { join, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";

import { NodeIO } from "@gltf-transform/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
Expand Down Expand Up @@ -38,7 +38,7 @@ describe("Node Assets CLI", () => {
const result = await runNodeAsync([launcher, ...args], directory);
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2"]) {
for (const term of ["pipeline", ".gltf", ".glb", "draco", "meshopt", "ktx2", "--stats", "--benchmark"]) {
expect(result.stdout).toContain(term);
}
});
Expand Down Expand Up @@ -80,13 +80,132 @@ describe("Node Assets CLI", () => {
const output = join(directory, `roundtrip-${extension}.glb`);
const result = await runNodeAsync([launcher, "pipeline", `input.${extension}`, output], directory);
expect(result.code).toBe(0);
expect(result.stdout).not.toContain("Stats:");
expect(result.stdout).not.toContain("Benchmark:");
const document = await new NodeIO().read(output);
expect(document.getRoot().listMeshes()[0]?.listPrimitives()[0]?.getAttribute("POSITION")?.getArray()).toEqual(new Float32Array([0, 0, 0, 1, 0, 0, 0, 1, 0]));
const parsed = await readGlbAsync(output);
expect(parsed.json.extensionsUsed ?? []).not.toContain("KHR_draco_mesh_compression");
expect(parsed.json.extensionsUsed ?? []).not.toContain("EXT_meshopt_compression");
});

it.each([
{ extension: "gltf", flags: ["--stats"] },
{ extension: "glb", flags: ["--stats"] },
{ extension: "glb", flags: ["--benchmark"] },
{ extension: "glb", flags: ["--stats", "--benchmark"] },
])("reports requested metrics for $extension with $flags", async ({ extension, flags }) => {
const source = join(directory, `input.${extension}`);
const output = join(directory, `report-${extension}-${flags.join("-")}.glb`);
const result = await runNodeAsync([launcher, "pipeline", source, "draco", output, ...flags], directory);
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect((await readGlbAsync(output)).json.extensionsUsed).toContain("KHR_draco_mesh_compression");
expect(result.stdout).toContain(`Wrote ${output}`);

if (flags.includes("--stats")) {
expect(result.stdout).toContain(`Stats:\n Total size before: ${(await stat(source)).size} B\n Total size after: ${(await stat(output)).size} B`);
} else {
expect(result.stdout).not.toContain("Stats:");
}

if (flags.includes("--benchmark")) {
expect(result.stdout).toContain("Benchmark:");
for (const label of ["Completion time", "CPU time (user)", "CPU time (system)"]) {
const line = result.stdout.split("\n").find((line) => line.startsWith(` ${label}: `));
expect(line).toMatch(/: \d+\.\d{2} (?:ms|s|min|h)$/u);
const value = Number(line?.split(": ")[1]?.split(" ")[0]);
expect(value).toBeGreaterThanOrEqual(0);
if (label === "Completion time") {
expect(value).toBeGreaterThan(0);
}
}
for (const label of ["RSS", "Peak RSS (process lifetime)", "Heap used"]) {
const line = result.stdout.split("\n").find((line) => line.startsWith(` ${label}: `));
expect(line).toMatch(/: (?:\d+ B|\d+\.\d{2} (?:KiB|MiB|GiB|TiB|PiB))$/u);
expect(Number(line?.split(": ")[1]?.split(" ")[0])).toBeGreaterThan(0);
expect(Number(line?.split(": ")[1]?.split(" ")[0])).toBeLessThan(1_024);
}
} else {
expect(result.stdout).not.toContain("Benchmark:");
}
});

it.each([
{ bytes: 1_023, expected: "1023 B", divisor: 1 },
{ bytes: 1_024, expected: "1.00 KiB", divisor: 1_024 },
{ bytes: 1_536, expected: "1.50 KiB", divisor: 1_024 },
{ bytes: 1_048_575, expected: "1.00 MiB", divisor: 1_048_576 },
{ bytes: 1_048_576, expected: "1.00 MiB", divisor: 1_048_576 },
{ bytes: 1_310_720, expected: "1.25 MiB", divisor: 1_048_576 },
])("formats input sizes of $bytes bytes as $expected and keeps the unit for output", async ({ bytes, expected, divisor }) => {
const source = join(directory, `size-${bytes}.gltf`);
const output = join(directory, `size-${bytes}.glb`);
await writeFile(source, generateGltfJson().padEnd(bytes, " "));

const result = await runNodeAsync([launcher, "pipeline", source, output, "--stats"], directory);
expect(result.code).toBe(0);
expect(result.stdout).toContain(`Total size before: ${expected}`);
const outputBytes = (await stat(output)).size;
expect(result.stdout).toContain(`Total size after: ${divisor === 1 ? outputBytes : (outputBytes / divisor).toFixed(2)} ${expected.split(" ")[1]}`);
});

it.each([
{ padding: 960, unit: "B" },
{ padding: 2_048, unit: "KiB" },
])("formats output sizes using the input's $unit unit even when output grows", async ({ padding, unit }) => {
const source = join(directory, `large-output-${unit}.gltf`);
const output = join(directory, `large-output-${unit}.glb`);
await writeFile(source, JSON.stringify({ asset: { version: "2.0" }, extras: { label: "x".repeat(padding) } }));

const result = await runNodeAsync([launcher, "pipeline", source, output, "--stats"], directory);
expect(result.code).toBe(0);
const outputBytes = (await stat(output)).size;
expect(outputBytes).toBeGreaterThan(1_024);
expect(outputBytes).toBeLessThan(1_048_576);
expect(result.stdout).toContain(`Total size after: ${unit === "B" ? outputBytes : (outputBytes / 1_024).toFixed(2)} ${unit}`);
});

it.each([
{ milliseconds: 0, completion: "0.00 ms", user: "0.00 ms", system: "0.00 ms" },
{ milliseconds: 12.34, completion: "12.34 ms", user: "24.68 ms", system: "6.17 ms" },
{ milliseconds: 999.994, completion: "999.99 ms", user: "1999.99 ms", system: "500.00 ms" },
{ milliseconds: 999.999, completion: "1.00 s", user: "2.00 s", system: "0.50 s" },
{ milliseconds: 1_000, completion: "1.00 s", user: "2.00 s", system: "0.50 s" },
{ milliseconds: 2_538.93, completion: "2.54 s", user: "5.08 s", system: "1.27 s" },
{ milliseconds: 59_999, completion: "1.00 min", user: "2.00 min", system: "0.50 min" },
{ milliseconds: 60_000, completion: "1.00 min", user: "2.00 min", system: "0.50 min" },
{ milliseconds: 72_471.94, completion: "1.21 min", user: "2.42 min", system: "0.60 min" },
{ milliseconds: 3_599_990, completion: "1.00 h", user: "2.00 h", system: "0.50 h" },
{ milliseconds: 3_600_000, completion: "1.00 h", user: "2.00 h", system: "0.50 h" },
{ milliseconds: 5_400_000, completion: "1.50 h", user: "3.00 h", system: "0.75 h" },
])("formats $milliseconds ms using the completion-time unit for every timing row", async ({ milliseconds, completion, user, system }) => {
const output = join(directory, `timing-${milliseconds}.glb`);
// Keep the real pipeline, but control clocks in a child process to cover long runs without waiting.
const script = `
const { runCliAsync } = await import(${JSON.stringify(new URL("../dist/cli.js", pathToFileURL(launcher)).href)});
let started = false;
process.hrtime.bigint = () => {
if (!started) {
started = true;
return 0n;
}
return ${BigInt(Math.round(milliseconds * 1_000_000))}n;
};
process.cpuUsage = (previous) => previous === undefined
? { user: 0, system: 0 }
: ${JSON.stringify({ user: Math.round(milliseconds * 2_000), system: Math.round(milliseconds * 500) })};
await runCliAsync(${JSON.stringify(["pipeline", input, output, "--benchmark"])});
`;
const result = await runNodeAsync(["--input-type=module", "--eval", script], directory);
expect(result.code).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain(`Completion time: ${completion}`);
expect(result.stdout).toContain(`CPU time (user): ${user}`);
expect(result.stdout).toContain(`CPU time (system): ${system}`);
expect((await readGlbAsync(output)).json.meshes).toHaveLength(1);
});

it("resolves sibling glTF resources from the input path", async () => {
const sourceDirectory = join(directory, "external");
await mkdir(sourceDirectory);
Expand Down Expand Up @@ -182,13 +301,16 @@ describe("Node Assets CLI", () => {
60_000
);

it("does not overwrite an existing destination", async () => {
const output = join(directory, "existing.glb");
it.each([[], ["--stats", "--benchmark"]].map((flags) => ({ flags })))("does not overwrite an existing destination with $flags", async ({ flags }) => {
const output = join(directory, `existing-${flags.join("-")}.glb`);
const original = Buffer.from("keep this file");
await writeFile(output, original);
const result = await runNodeAsync([launcher, "pipeline", input, output], directory);
const result = await runNodeAsync([launcher, ...flags, "pipeline", input, output], directory);
expect(result.code).toBe(1);
expect(result.stderr.trim()).not.toBe("");
expect(result.stdout).not.toContain("Wrote ");
expect(result.stdout).not.toContain("Stats:");
expect(result.stdout).not.toContain("Benchmark:");
expect(await readFile(output)).toEqual(original);
});

Expand Down
Loading