@threenative/assets 0.3.0 → 0.3.2

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/dist/index.d.ts CHANGED
@@ -1,3 +1,52 @@
1
+ /**
2
+ * Encodes compiled textures to KTX2/Basis so the GPU stores them compressed instead of as
3
+ * decoded RGBA (a 2048x2048 PNG is 16 MB in VRAM; BC7/ASTC hold it in a quarter of that).
4
+ *
5
+ * Codec choice is a declared property of the asset, never a guess: a config override wins,
6
+ * then the `*_normal.*` / `_nrm.*` filename convention, then alpha presence (UASTC keeps
7
+ * exact alpha and survives normal-map data; ETC1S does not). Mip chains are generated at
8
+ * encode time — always — because an uploaded compressed texture without mips looks worse
9
+ * than the PNG it replaced.
10
+ *
11
+ * The encoder is Basis Universal through `ktx2-encoder`'s in-process WASM build; users
12
+ * install nothing extra. Sources it cannot decode (anything but PNG/JPEG today) fail the
13
+ * build naming the file rather than shipping uncompressed behind the user's back.
14
+ */
15
+ type TextureCodec = "etc1s" | "none" | "uastc";
16
+ /**
17
+ * Why a source shipped uncompressed although its pass ran. `not-smaller` means encoding would
18
+ * have grown the download; `block-size` means no block codec can address the source's
19
+ * dimensions. Both keep the authored bytes; neither is a silent decision — the manifest carries
20
+ * the reason and the report prints it.
21
+ */
22
+ type TextureSkipReason = "block-size" | "not-smaller";
23
+ interface ITextureOverride {
24
+ readonly codec: TextureCodec;
25
+ /** First matching override wins; matched against the logical path, e.g. "ui/x.png". */
26
+ readonly glob: string;
27
+ /** ETC1S encoder quality 1–255. Ignored for UASTC, which uses its own fixed defaults. */
28
+ readonly quality?: number;
29
+ }
30
+ interface ITexturePassOptions {
31
+ /** Longest edge to retain; larger sources are downsampled without upscaling. */
32
+ readonly maxSize?: number;
33
+ readonly overrides?: readonly ITextureOverride[];
34
+ /** ETC1S encoder quality 1–255, default 150. Ignored for UASTC. */
35
+ readonly quality?: number;
36
+ }
37
+ /**
38
+ * Encodes source textures as mipmapped KTX2/Basis data and declares their runtime transcode targets.
39
+ * @constraint every compressed source width and height must be divisible by 4 because BC7, BC1, ETC2, and ASTC 4x4 use 4x4 blocks; WebGPU rejects an unaligned texture at draw time
40
+ * @constraint automatic cooking retains an unaligned source unchanged and reports block-size; an explicit compression codec override fails, while codec "none" remains available
41
+ * @example const pass = texturePass({ overrides: [{ glob: "ui/*", codec: "none" }] });
42
+ */
43
+ declare function texturePass(options?: ITexturePassOptions): IAssetPass;
44
+
45
+ interface IAssetBudget {
46
+ readonly uncooked: number | "none";
47
+ readonly total: number | "none";
48
+ }
49
+
1
50
  type AssetFindingGrade = "fail" | "ok" | "warn";
2
51
  interface IAssetFinding {
3
52
  readonly asset: string;
@@ -38,6 +87,8 @@ interface IAssetHealthReport {
38
87
  interface IAssetHealthInput {
39
88
  readonly data: Buffer;
40
89
  readonly logicalPath: string;
90
+ /** Compiler-owned model output, when an imported source's GLB references shared images. */
91
+ readonly modelPath?: string;
41
92
  }
42
93
  /**
43
94
  * Measures every compiled asset and grades findings against the declared targets.
@@ -48,6 +99,99 @@ declare function runHealthReport(inputs: readonly IAssetHealthInput[], targets?:
48
99
  /** The person-readable form printed by the compile step, one line per finding plus a summary. */
49
100
  declare function formatHealthReport(report: IAssetHealthReport): readonly string[];
50
101
 
102
+ /**
103
+ * The five bands a clip's content is judged in, contiguous and gapless up to Nyquist.
104
+ *
105
+ * Borrowed verbatim from `packages/playtest/src/runner/audio.ts`, names and edges both, so a game
106
+ * declares one band in one vocabulary and means the same thing to the build gate and to the audio
107
+ * inspector. Chosen for what they separate in practice: below 100 Hz a wood has nothing, 100-500 Hz
108
+ * is where a "warm" generation collapses into a hum, 500 Hz-2 kHz is body, 2-8 kHz is leaf rustle
109
+ * and birdsong and the brightness of a struck bell, and above 8 kHz is air.
110
+ */
111
+ declare const AUDIO_BANDS: {
112
+ readonly sub: readonly [0, 100];
113
+ readonly low: readonly [100, 500];
114
+ readonly mid: readonly [500, 2000];
115
+ readonly high: readonly [2000, 8000];
116
+ readonly air: readonly [8000, number];
117
+ };
118
+ type AudioBand = keyof typeof AUDIO_BANDS;
119
+
120
+ /**
121
+ * The audio declarations a game writes in `threenative.config.ts`, validated here.
122
+ *
123
+ * **Declared, never guessed.** Which clips loop, which are positional, and what a clip is *for*
124
+ * are facts only the game knows, and a pass that inferred them from a filename would be
125
+ * confidently wrong on the first asset named against its convention. `texture.ts` takes codec
126
+ * choice from an override, then a filename convention, then measured alpha — for audio there is no
127
+ * honest middle rung, so there is only the override. A clip nobody declared is still measured and
128
+ * still reported; what it does not get is an assertion about an intent nobody stated.
129
+ *
130
+ * Every key is checked here rather than deeper in the pass, because a silently-dropped `loop`
131
+ * is a build that shipped a clicking bed while reporting success — which is the defect this pass
132
+ * exists to stop.
133
+ */
134
+
135
+ type AudioConditioning = "none";
136
+ /** `"ceiling"` only ever attenuates; `"peak"` also lifts a quiet clip to the ceiling. */
137
+ type AudioNormalisation = "ceiling" | "peak";
138
+ /**
139
+ * What a clip is *for*, stated as a bound on where its energy sits.
140
+ *
141
+ * Both directions matter, and the two real defects this pass was built for are one of each. A chime
142
+ * that came back a hum needed a floor — it had 83% of its energy in `low` and nothing in `high`,
143
+ * where a struck bell lives. Fifteen footsteps needed a ceiling — they carried up to 45% of their
144
+ * energy in `sub`, below 100 Hz, which a wood has nothing in and which costs a phone's speaker its
145
+ * whole headroom for a sound nobody can hear.
146
+ *
147
+ * Percentages on the same 0-100 scale as the audio inspector's, and named bands from its
148
+ * vocabulary, so a game declares one number in one language for both tools.
149
+ */
150
+ interface IAudioSpectrumExpectation {
151
+ readonly band: AudioBand;
152
+ /** Most of the clip's energy that may sit in the band. */
153
+ readonly maxPercent?: number;
154
+ /** Least of the clip's energy that must sit in the band. */
155
+ readonly minPercent?: number;
156
+ }
157
+ interface IAudioLoopOptions {
158
+ /**
159
+ * Equal-power cross-fade length in milliseconds. `0` keeps the clip's own length — a
160
+ * bar-accurate musical loop cannot be shortened by a fade — and still has its seam asserted.
161
+ */
162
+ readonly crossFadeMs?: number;
163
+ /** How far the splice may move to find a quiet seam, in milliseconds. */
164
+ readonly spliceToleranceMs?: number;
165
+ }
166
+ interface IAudioOverride {
167
+ /** Ships these bytes as committed. Measurement, and a declared loop's assertion, still run. */
168
+ readonly conditioning?: AudioConditioning;
169
+ /** First matching override wins; matched against the logical path, e.g. `"audio/bed.ogg"`. */
170
+ readonly glob: string;
171
+ /** The clip repeats forever, so its seam is cross-faded and then asserted. */
172
+ readonly loop?: boolean | IAudioLoopOptions;
173
+ readonly normalise?: AudioNormalisation;
174
+ /** Peak ceiling in dBFS; must be at or below 0. */
175
+ readonly peakDb?: number;
176
+ /** The clip plays from a place in the world, so it is downmixed to mono. */
177
+ readonly positional?: boolean;
178
+ /** Vorbis VBR quality, -1 to 10. */
179
+ readonly quality?: number;
180
+ /** The largest wrap-to-neighbourhood step ratio this loop may ship with. */
181
+ readonly seamMaxRatio?: number;
182
+ /** What the clip is for, stated as a band its energy must occupy. */
183
+ readonly spectrum?: IAudioSpectrumExpectation;
184
+ }
185
+ interface IAudioPassOptions {
186
+ readonly normalise?: AudioNormalisation;
187
+ readonly overrides?: readonly IAudioOverride[];
188
+ readonly peakDb?: number;
189
+ readonly quality?: number;
190
+ readonly seamMaxRatio?: number;
191
+ }
192
+ /** `"none"` drops the pass; an object configures it; absent means the defaults run. */
193
+ declare function parseAudioConfig(raw: unknown): IAudioPassOptions | undefined;
194
+
51
195
  /**
52
196
  * Atlas integration follows the mesh → xatlas → remapped-attribute split documented by
53
197
  * xatlas-three 0.2.1 (MIT, copyright 2022 Palash Bansal). This implementation uses xatlasjs's
@@ -98,35 +242,6 @@ interface IModelVirtualOptions {
98
242
  readonly simplifyRatio?: number;
99
243
  }
100
244
 
101
- /**
102
- * Encodes compiled textures to KTX2/Basis so the GPU stores them compressed instead of as
103
- * decoded RGBA (a 2048x2048 PNG is 16 MB in VRAM; BC7/ASTC hold it in a quarter of that).
104
- *
105
- * Codec choice is a declared property of the asset, never a guess: a config override wins,
106
- * then the `*_normal.*` / `_nrm.*` filename convention, then alpha presence (UASTC keeps
107
- * exact alpha and survives normal-map data; ETC1S does not). Mip chains are generated at
108
- * encode time — always — because an uploaded compressed texture without mips looks worse
109
- * than the PNG it replaced.
110
- *
111
- * The encoder is Basis Universal through `ktx2-encoder`'s in-process WASM build; users
112
- * install nothing extra. Sources it cannot decode (anything but PNG/JPEG today) fail the
113
- * build naming the file rather than shipping uncompressed behind the user's back.
114
- */
115
- type TextureCodec = "etc1s" | "none" | "uastc";
116
- interface ITextureOverride {
117
- readonly codec: TextureCodec;
118
- /** First matching override wins; matched against the logical path, e.g. "ui/x.png". */
119
- readonly glob: string;
120
- /** ETC1S encoder quality 1–255. Ignored for UASTC, which uses its own fixed defaults. */
121
- readonly quality?: number;
122
- }
123
- interface ITexturePassOptions {
124
- readonly overrides?: readonly ITextureOverride[];
125
- /** ETC1S encoder quality 1–255, default 150. Ignored for UASTC. */
126
- readonly quality?: number;
127
- }
128
- declare function texturePass(options?: ITexturePassOptions): IAssetPass;
129
-
130
245
  /**
131
246
  * Compresses the images embedded *inside* a `.glb` — the half of a model the geometry passes
132
247
  * never touched. A prop carrying three 2048x2048 JPEGs is a small file and 67 MB of VRAM once
@@ -172,6 +287,8 @@ interface IEmbeddedTextureSummary {
172
287
  readonly gpuBytesBefore: number;
173
288
  /** How many images the resolution cap actually downsampled. */
174
289
  readonly resized: number;
290
+ /** Images retained at their authored bytes, by name, with the reason each one was not cooked. */
291
+ readonly skippedCompression: Readonly<Record<string, TextureSkipReason>>;
175
292
  }
176
293
  /** One embedded image as the verification compares it, source against re-read output. */
177
294
  interface IModelTextureBinding {
@@ -190,6 +307,33 @@ interface IModelTextureBindings {
190
307
  readonly textures: readonly IModelTextureBinding[];
191
308
  }
192
309
 
310
+ /**
311
+ * Images that models share, written once and referenced from every model that carries them.
312
+ *
313
+ * A marketplace pack embeds its textures in every model that uses them: eight pines each carry
314
+ * the same 9 MB of bark and needle maps, so a valley of 58 models moves 280 MB for 63 MB of
315
+ * distinct images — and re-encodes every one of them on every build. The store keys an image on
316
+ * its **source** bytes plus the settings that decide its output, so the second model that
317
+ * carries the same bark map finds the encoded result instead of paying for it again, and a
318
+ * second build finds it on disk.
319
+ *
320
+ * The output is content-addressed under `shared/images/`, so two models that embed the same
321
+ * bytes resolve to one URL and the browser cache, the bundle and the delete-test all see one file.
322
+ */
323
+ interface ISharedImage {
324
+ readonly buffer: Buffer;
325
+ /** `uastc`, `etc1s` or `none`, spelled into the filename so a cache hit knows what it found. */
326
+ readonly codec: string;
327
+ readonly mimeType: string;
328
+ }
329
+ interface ISharedImageStore {
330
+ /** The image encoded from this key, from memory or from a previous build's output. */
331
+ get(key: string): Promise<ISharedImage | undefined>;
332
+ put(key: string, image: ISharedImage): Promise<void>;
333
+ /** Output path, relative to the output root, of a stored image. */
334
+ outputPath(key: string, image: Pick<ISharedImage, "codec" | "mimeType">): string;
335
+ }
336
+
193
337
  /**
194
338
  * Optimizes compiled models: dedup → prune → simplify → reorder → quantize → textures →
195
339
  * meshopt, in that fixed order, each individually switchable in config. Output declares
@@ -243,11 +387,21 @@ interface IModelSimplifyOptions {
243
387
  }
244
388
  interface IModelPassOptions {
245
389
  readonly passes?: IModelPassesOptions;
390
+ /** Native WebGPU rejects interleaved vertex attributes; mobile output uses separate buffers. */
391
+ readonly vertexLayout?: "interleaved" | "separate";
246
392
  /** Preserve generated TEXCOORD_1 data that is consumed by a runtime-attached lightmap. */
247
393
  readonly preserveLightmapUv?: boolean;
248
394
  readonly quantize?: IModelQuantizeOptions;
249
395
  /** LOD simplification; absent means no simplification at all. */
250
396
  readonly simplify?: IModelSimplifyOptions;
397
+ /**
398
+ * Share embedded images across models: each distinct image is written once, content-addressed
399
+ * under `shared/images/`, and every model that carries it references that one file. The store
400
+ * remembers encoded results within a build and, when it was given the output root, across
401
+ * builds — so a pack whose eight pines embed the same bark map encodes it once, not eight
402
+ * times per build. Absent means every model keeps its images embedded.
403
+ */
404
+ readonly sharedImages?: ISharedImageStore;
251
405
  /** Embedded-texture compression: options, or `"none"` to ship every image as authored. */
252
406
  readonly textures?: IModelTexturesOptions | "none";
253
407
  /**
@@ -277,6 +431,200 @@ interface IModelSimplifySummary {
277
431
  }
278
432
  declare function modelPass(options?: IModelPassOptions): IAssetPass;
279
433
 
434
+ /**
435
+ * The person-readable size report the compile step prints after encoding: one line per
436
+ * compressed texture and optimized model plus a total, before against after. Pure formatting
437
+ * so tests can pin the exact lines a build prints.
438
+ */
439
+ interface ITextureSizeRow {
440
+ readonly after: number;
441
+ readonly before: number;
442
+ /** Set when the bytes shipped as authored; printed so a flat row is never left unexplained. */
443
+ readonly compressionSkipped?: TextureSkipReason;
444
+ readonly format: string | undefined;
445
+ readonly logicalPath: string;
446
+ }
447
+ declare function formatTextureSizes(rows: readonly ITextureSizeRow[]): readonly string[];
448
+ /** What the model pass did to the images inside one `.glb`. */
449
+ interface IEmbeddedTextureRow {
450
+ readonly skippedCompression?: Readonly<Record<string, TextureSkipReason>>;
451
+ readonly bytesAfter: number;
452
+ readonly bytesBefore: number;
453
+ readonly count: number;
454
+ /** Chosen codec per embedded image, keyed by texture name. */
455
+ readonly formats?: Readonly<Record<string, string>>;
456
+ readonly gpuBytesAfter: number;
457
+ readonly gpuBytesBefore: number;
458
+ readonly resized: number;
459
+ }
460
+ /** What the cluster-DAG bake produced for one model. */
461
+ interface IVirtualRow {
462
+ readonly bakeSeconds: number;
463
+ readonly clusters: number;
464
+ readonly levels: number;
465
+ readonly payloadBytes: number;
466
+ readonly primitives: number;
467
+ readonly skipped: number;
468
+ readonly stopReason: string;
469
+ }
470
+ /** What simplification delivered against what the config asked for. */
471
+ interface ISimplifyRow {
472
+ readonly achievedRatio: number;
473
+ readonly error: number;
474
+ readonly requestedRatio: number;
475
+ readonly trianglesAfter: number;
476
+ readonly trianglesBefore: number;
477
+ }
478
+ /** Whether the pass executed this bake or every input that reached it was compile-cache-served. */
479
+ type PassCostStatus = "cached" | "ran";
480
+ /** What one input cost the pass that processed it, as the pass driver measured it. */
481
+ interface IPassCostAssetRow {
482
+ readonly durationMs: number;
483
+ readonly logicalPath: string;
484
+ }
485
+ /**
486
+ * What one pass cost the whole bake: totals across inputs plus the per-asset attribution a
487
+ * 274-file pack needs to name its expensive members. Durations are measured by the pass driver,
488
+ * never reported by the pass itself, so a pass cannot opt out of measurement.
489
+ */
490
+ interface IPassCostRow {
491
+ /** Per-input wall clock for the ran inputs, sorted by logical path; empty when cached. */
492
+ readonly assets: readonly IPassCostAssetRow[];
493
+ readonly cachedInputs: number;
494
+ readonly durationMs: number;
495
+ readonly pass: string;
496
+ readonly ranInputs: number;
497
+ readonly status: PassCostStatus;
498
+ }
499
+ /**
500
+ * Formats the per-pass cost lines the compile prints after the size report: one line per pass,
501
+ * then one line per input it ran on. A cached pass names its input count instead of a duration —
502
+ * the cache decision is the source, never a threshold inferred from a fast run.
503
+ */
504
+ declare function formatPassCosts(rows: readonly IPassCostRow[]): readonly string[];
505
+ /**
506
+ * What the audio pass measured and did to one clip.
507
+ *
508
+ * Every field is reported whether or not it was asserted on: an undeclared clip carries no
509
+ * `seamMaxRatio` and still carries its measured seam, because turning a convention off must not
510
+ * turn its measurement off. Every one of these numbers was measurable all along on the clips a
511
+ * hand-written script conditioned; nothing measured them.
512
+ */
513
+ interface IAudioRow {
514
+ /**
515
+ * Where the clip's energy sits, as percentages across the five named bands summing to 100.
516
+ *
517
+ * Measured on every clip whether or not the game declared a bound, because this is the thing the
518
+ * hand-written script that preceded this pass never looked at, and it is where both of the real
519
+ * defects were.
520
+ */
521
+ readonly bandAir: number;
522
+ readonly bandHigh: number;
523
+ readonly bandLow: number;
524
+ readonly bandMid: number;
525
+ readonly bandSub: number;
526
+ readonly bytesAfter: number;
527
+ readonly bytesBefore: number;
528
+ readonly channelsAfter: number;
529
+ readonly channelsBefore: number;
530
+ /** False when the game declared these bytes shipped as committed. */
531
+ readonly conditioned: boolean;
532
+ readonly container: string;
533
+ /**
534
+ * Whether the audio was re-encoded, or the source bytes shipped untouched because nothing here
535
+ * moved the PCM. A needless re-encode costs a generation of lossy Vorbis to deliver the same
536
+ * audio, so a pass that does nothing says so instead of quietly charging for it.
537
+ */
538
+ readonly reencoded: boolean;
539
+ /** The fade applied, against the length the game asked for; absent unless the clip loops. */
540
+ readonly crossFadeMs?: number;
541
+ readonly crossFadeMsRequested?: number;
542
+ readonly dcOffsetAfter: number;
543
+ readonly dcOffsetBefore: number;
544
+ /** What the clip costs a device's memory once decoded — the larger of the two costs. */
545
+ readonly decodedBytesAfter: number;
546
+ readonly decodedBytesBefore: number;
547
+ readonly durationSeconds: number;
548
+ readonly frames: number;
549
+ readonly logicalPath: string;
550
+ readonly loop: boolean;
551
+ readonly peakAfter: number;
552
+ readonly peakBefore: number;
553
+ readonly sampleRate: number;
554
+ /** The largest ordinary adjacent step within 50 ms of the join. */
555
+ readonly seamNearP99: number;
556
+ /**
557
+ * `seamWrap / seamNearP99`, measured on the decoded output bytes — the ones that ship.
558
+ *
559
+ * The ratio rather than the magnitude, because a click is a step that is anomalous *where it
560
+ * happens*: an absolute bound condemns dense clips and excuses quiet ones.
561
+ */
562
+ readonly seamRatio: number;
563
+ readonly seamRatioBefore: number;
564
+ /** The bare step across the join. Reported because it is informative, not because it is judged. */
565
+ readonly seamWrap: number;
566
+ readonly seamWrapBefore: number;
567
+ /** Present only for a declared loop, which is the only clip whose seam is asserted. */
568
+ readonly seamMaxRatio?: number;
569
+ /** The band the game declared a bound on, and the bound; absent when it declared none. */
570
+ readonly spectrumBand?: string;
571
+ readonly spectrumMaxPercent?: number;
572
+ readonly spectrumMinPercent?: number;
573
+ readonly spectrumPercent?: number;
574
+ }
575
+ /**
576
+ * One line per conditioned clip plus a total, on the wire and decoded.
577
+ *
578
+ * The decoded total is the one worth printing loudest: a 21-second stereo ambience bed is 220 KB
579
+ * to download and 7.5 MB of resident memory, and it is the second number that decides whether a
580
+ * phone keeps the app alive.
581
+ */
582
+ declare function formatAudioSizes(rows: readonly IAudioRow[]): readonly string[];
583
+ /** One compiled model plus a total, before against after the optimization pass. */
584
+ interface IModelSizeRow {
585
+ readonly after: number;
586
+ readonly before: number;
587
+ /** Embedded-image compression, when the model carried any. */
588
+ readonly embeddedTextures?: IEmbeddedTextureRow;
589
+ /** glTF extensions declared by the compiled output, e.g. EXT_meshopt_compression. */
590
+ readonly extensions?: readonly string[];
591
+ readonly logicalPath: string;
592
+ readonly lightmap?: {
593
+ readonly atlasHeight: number;
594
+ readonly atlasWidth: number;
595
+ readonly bakeMs: number;
596
+ readonly bytesAfter: number;
597
+ readonly bytesBefore: number;
598
+ readonly dilatedTexels: number;
599
+ readonly occludedTexels: number;
600
+ readonly validTexels: number;
601
+ };
602
+ /** LOD simplification, when it was configured for this model. */
603
+ readonly simplify?: ISimplifyRow;
604
+ /** The cluster-DAG bake, when it was configured for this model. */
605
+ readonly virtual?: IVirtualRow;
606
+ /** Triangle count of the compiled output, recorded in the manifest. */
607
+ readonly triangles?: number;
608
+ }
609
+ declare function formatModelSizes(rows: readonly IModelSizeRow[]): readonly string[];
610
+ /** What an uncompressed pass cost this build, per kind, and which decision caused it. */
611
+ interface ISkippedCompressionRow {
612
+ readonly bytes: number;
613
+ readonly files: number;
614
+ readonly kind: "model" | "texture";
615
+ /** Decoder-backed sub-passes that this row actually omitted. */
616
+ readonly decoders?: readonly ("meshopt" | "KTX2")[];
617
+ /** `"config"` — the game set `"none"`. `"platform"` — this target cannot decode compression. */
618
+ readonly reason: "config" | "platform";
619
+ }
620
+ /** A caller-supplied pass omitted because this target cannot provide its runtime decoder. */
621
+ interface ISkippedPassRow {
622
+ readonly kind: "pass";
623
+ readonly pass: string;
624
+ readonly reason: "platform";
625
+ }
626
+ type ISkippedReportRow = ISkippedCompressionRow | ISkippedPassRow;
627
+
280
628
  type AssetKind = "audio" | "model" | "other" | "texture";
281
629
  /** Budgets a project declares for its assets; exceeding one fails the build. */
282
630
  interface IAssetTargets {
@@ -303,12 +651,20 @@ interface IAssetAuxiliaryOutput {
303
651
  /** Manifest array receiving this output, for example `lightmaps`. */
304
652
  readonly manifestField: string;
305
653
  readonly metadata?: Readonly<Record<string, unknown>>;
654
+ /**
655
+ * An output the pass already content-addressed itself, relative to the output root — the
656
+ * shared image several models reference by the same relative URL. Absent means the compile
657
+ * names it `<stem>.<role>.<digest><extension>` beside the input's own output.
658
+ */
659
+ readonly outputPath?: string;
306
660
  /** Stable filename segment, for example `lightmap`. */
307
661
  readonly role: string;
308
662
  }
309
663
  interface IAssetPass {
310
664
  /** Deterministic settings that change output bytes; omitted for identity/default passes. */
311
665
  readonly cacheKey?: string;
666
+ /** True when this pass's emitted output requires a runtime decoder unavailable on mobile. */
667
+ readonly needsRuntimeDecoder?: boolean;
312
668
  readonly name: string;
313
669
  /**
314
670
  * JSON-serializable snapshot of every option that changes this pass's output. Part of the
@@ -320,6 +676,21 @@ interface IAssetPass {
320
676
  apply(input: Buffer, logicalPath: string): Buffer | IAssetPassOutput | Promise<Buffer | IAssetPassOutput>;
321
677
  }
322
678
  interface IAssetSourceConfig {
679
+ readonly budget?: Partial<IAssetBudget> | number | "none";
680
+ /** Source-relative globs omitted from the build; excluded bytes are always reported. */
681
+ readonly exclude?: readonly string[];
682
+ /**
683
+ * Audio conditioning: an object of options, or the string `"none"` to ship every clip exactly
684
+ * as committed. Absent means conditioning runs with defaults. See `passes/audio-config.ts` for
685
+ * how a game declares which clips loop, which are positional, and what a clip is for.
686
+ */
687
+ readonly audio?: IAudioConfig | "none";
688
+ /**
689
+ * The bound on how many workers a bake may use. A direct `concurrency` option overrides it;
690
+ * absent means the driver's default (`min(4, cores - 1)`). CI boxes and laptops differ, and
691
+ * a 6.8 GB pack does not get to decide the machine's fate.
692
+ */
693
+ readonly concurrency?: number;
323
694
  /**
324
695
  * Model optimization: an object of options, or the string `"none"` to ship every model
325
696
  * exactly as committed. Absent means optimization runs with defaults.
@@ -339,6 +710,14 @@ interface IModelsConfig {
339
710
  readonly lightmap?: ILightmapPassOptions;
340
711
  readonly passes?: IModelPassesOptions;
341
712
  readonly quantize?: IModelQuantizeOptions;
713
+ /**
714
+ * Write each distinct embedded image once under `shared/images/` and reference it from every
715
+ * model that carries it, instead of embedding a copy in each. A marketplace pack's eight pines
716
+ * stop shipping eight copies of the same bark map, and a rebuild reuses last build's encodes.
717
+ * Default true: the served GLB references files beside it, which a host must serve. False
718
+ * keeps each model self-contained and reports the duplicated embedded-image byte cost.
719
+ */
720
+ readonly sharedImages?: boolean;
342
721
  /** LOD simplification. Absent means none: it is the one lossy stage, and it is opt-in. */
343
722
  readonly simplify?: IModelSimplifyOptions;
344
723
  /**
@@ -353,11 +732,20 @@ interface IModelsConfig {
353
732
  */
354
733
  readonly virtual?: IModelVirtualOptions | "none";
355
734
  }
735
+ interface IAudioConfig {
736
+ readonly normalise?: "ceiling" | "peak";
737
+ readonly overrides?: readonly IAudioOverride[];
738
+ readonly peakDb?: number;
739
+ readonly quality?: number;
740
+ readonly seamMaxRatio?: number;
741
+ }
356
742
  interface ITexturesConfig {
743
+ readonly maxSize?: number;
357
744
  readonly overrides?: readonly ITextureOverride[];
358
745
  readonly quality?: number;
359
746
  }
360
747
  interface IAssetCompileOptions {
748
+ readonly concurrency?: number;
361
749
  readonly config?: IAssetSourceConfig;
362
750
  readonly cwd?: string;
363
751
  /** Includes the machine-readable health report on the result. */
@@ -368,6 +756,30 @@ interface IAssetCompileOptions {
368
756
  * registry (the KTX2 texture pass) runs unless `config.textures` is `"none"`.
369
757
  */
370
758
  readonly passes?: readonly IAssetPass[];
759
+ /**
760
+ * The order inputs are processed in, which under a scheduler is the order their work
761
+ * completes. `"sorted"` (the default) is the documented behaviour; `"reversed"` exists for
762
+ * the determinism gate, which must be able to run the same inputs through the driver in a
763
+ * different completion order and compare every emitted byte. It is a test seam, not a
764
+ * project setting, and a game config never carries it.
765
+ */
766
+ readonly processingOrder?: "reversed" | "sorted";
767
+ /**
768
+ * The platform this bake is for, which decides whether compression can ship at all.
769
+ *
770
+ * Android and iOS run the native host without WebAssembly, so they carry no Basis transcoder
771
+ * and no Meshopt decoder: a `.ktx2` texture or a meshopt-compressed mesh in a mobile bundle is
772
+ * a black screen, and `threenative build` refuses one with `TN_NATIVE_KTX2_UNSUPPORTED`. Web
773
+ * and desktop decode both.
774
+ *
775
+ * Absent means web — a direct `compileAssets` call, or a project that compiles once and serves
776
+ * the result. `threenative build` always names its `--target`, so the passes that a platform
777
+ * cannot decode drop for that build and stay on for every other one. This is the whole reason
778
+ * `assets.textures: "none"` used to be pinned in the scaffolded config: the author was asked to
779
+ * choose one constant for four targets, and every game that wanted Android shipped its web
780
+ * build uncompressed too. The build knows its target; it decides.
781
+ */
782
+ readonly platform?: "android" | "desktop" | "ios" | "web";
371
783
  readonly source?: string;
372
784
  /** Overrides resolution of three's Basis transcoder for the copy into the output root. */
373
785
  readonly transcoder?: IBasisTranscoder;
@@ -377,10 +789,41 @@ interface IBasisTranscoder {
377
789
  readonly wasmPath: string;
378
790
  }
379
791
  interface IAssetCompileResult {
792
+ /** How many workers the driver actually used: 1 for a sequential bake (custom passes, or a bound of 1). */
793
+ readonly concurrencyUsed: number;
794
+ /** One cost row per pass, driver-measured; empty when no bake ran. */
795
+ readonly passCosts: readonly IPassCostRow[];
796
+ /**
797
+ * What built-in compression shipped uncompressed, plus caller-supplied decoder-dependent passes
798
+ * omitted for this target as `kind: "pass"` rows. Turning a convention off does not turn its
799
+ * measurement off.
800
+ */
801
+ readonly skippedCompression: readonly ISkippedReportRow[];
802
+ readonly receipt?: IBakeReceipt;
380
803
  readonly report?: IAssetHealthReport;
381
804
  readonly skipped: number;
382
805
  readonly written: number;
383
806
  }
807
+ /** One file this bake owns, and where it came from. */
808
+ interface IBakeReceiptOutput {
809
+ readonly bytes: number;
810
+ /** Path relative to the output root, with `/` separators on every platform. */
811
+ readonly path: string;
812
+ /** The producer: the pass chain for a compiled input, or the auxiliary output's own role. */
813
+ readonly producer: string;
814
+ /** The source asset this came from, or `null` for a file the bake ships on its own behalf. */
815
+ readonly source: string | null;
816
+ }
817
+ /**
818
+ * Everything the bake wrote, so the delete-test can remove exactly that and nothing else.
819
+ *
820
+ * Deterministic given the same inputs — no wall-clock field — because this repository proves a
821
+ * change is neutral by diffing emitted output, and a timestamp makes every such diff dirty.
822
+ */
823
+ interface IBakeReceipt {
824
+ readonly outputs: readonly IBakeReceiptOutput[];
825
+ readonly pipelineVersion: number;
826
+ }
384
827
  /**
385
828
  * Resolves three's Basis transcoder through the project's own `three` install so a path that
386
829
  * moved between three versions fails the build here, as a named error, instead of 404ing at
@@ -390,73 +833,36 @@ declare function resolveBasisTranscoder(cwd: string): IBasisTranscoder;
390
833
  declare function compileAssets(options?: IAssetCompileOptions): Promise<IAssetCompileResult>;
391
834
 
392
835
  /**
393
- * The person-readable size report the compile step prints after encoding: one line per
394
- * compressed texture and optimized model plus a total, before against after. Pure formatting
395
- * so tests can pin the exact lines a build prints.
836
+ * Conditions a game's audio at build time and proves it did not destroy it.
837
+ *
838
+ * `AssetKind` has classified `.ogg`, `.wav` and `.mp3` as audio since the pipeline was written and
839
+ * nothing acted on it: audio was classified and then shipped through untouched. A lane generated
840
+ * nineteen clips for a game and conditioned them in a one-off script instead — downmix, cross-fade,
841
+ * DC removal, peak, encode — and the script reported success on every one while having measured
842
+ * nothing at all.
843
+ *
844
+ * What it actually got wrong was **content**, not joins: a chime that came back 92% low-mid with
845
+ * nothing above 1 kHz, and footsteps carrying heavy sub-bass. The joins were fine — an early
846
+ * reading that one bed was clicking came from comparing bare wrap steps between clips, and by the
847
+ * measurement that reflects audibility that bed has the *cleaner* join of the two. That correction
848
+ * is why the seam gate here judges a ratio and not a magnitude, and why the declared-band check
849
+ * exists at all: the defect that shipped was the one nobody thought to measure.
850
+ *
851
+ * So this is `model.ts`'s shape rather than a filter chain: it transforms bytes and then measures
852
+ * the result, and every measurement it takes is reported whether or not it was asked to assert on
853
+ * it. Turning a convention off must not turn its measurement off — an undeclared clip's seam is
854
+ * measured and lands in the receipt, it just does not fail the build.
855
+ *
856
+ * What it deliberately does not do:
857
+ *
858
+ * - **Own the mix.** Peak handling defaults to a *ceiling*, which only ever attenuates. Normalising
859
+ * every clip up to the same peak would make a footstep as loud as a chime and force the game to
860
+ * undo the pipeline in its volume settings; that is deciding how the game sounds, and it belongs
861
+ * to the game. `normalise: "peak"` asks for the lift by name.
862
+ * - **Guess intent.** See `audio-config.ts`.
863
+ * - **Process at runtime.** Every measurement and every transform here happens once, at build time.
396
864
  */
397
- interface ITextureSizeRow {
398
- readonly after: number;
399
- readonly before: number;
400
- readonly format: string | undefined;
401
- readonly logicalPath: string;
402
- }
403
- declare function formatTextureSizes(rows: readonly ITextureSizeRow[]): readonly string[];
404
- /** What the model pass did to the images inside one `.glb`. */
405
- interface IEmbeddedTextureRow {
406
- readonly bytesAfter: number;
407
- readonly bytesBefore: number;
408
- readonly count: number;
409
- /** Chosen codec per embedded image, keyed by texture name. */
410
- readonly formats?: Readonly<Record<string, string>>;
411
- readonly gpuBytesAfter: number;
412
- readonly gpuBytesBefore: number;
413
- readonly resized: number;
414
- }
415
- /** What the cluster-DAG bake produced for one model. */
416
- interface IVirtualRow {
417
- readonly bakeSeconds: number;
418
- readonly clusters: number;
419
- readonly levels: number;
420
- readonly payloadBytes: number;
421
- readonly primitives: number;
422
- readonly skipped: number;
423
- readonly stopReason: string;
424
- }
425
- /** What simplification delivered against what the config asked for. */
426
- interface ISimplifyRow {
427
- readonly achievedRatio: number;
428
- readonly error: number;
429
- readonly requestedRatio: number;
430
- readonly trianglesAfter: number;
431
- readonly trianglesBefore: number;
432
- }
433
- /** One compiled model plus a total, before against after the optimization pass. */
434
- interface IModelSizeRow {
435
- readonly after: number;
436
- readonly before: number;
437
- /** Embedded-image compression, when the model carried any. */
438
- readonly embeddedTextures?: IEmbeddedTextureRow;
439
- /** glTF extensions declared by the compiled output, e.g. EXT_meshopt_compression. */
440
- readonly extensions?: readonly string[];
441
- readonly logicalPath: string;
442
- readonly lightmap?: {
443
- readonly atlasHeight: number;
444
- readonly atlasWidth: number;
445
- readonly bakeMs: number;
446
- readonly bytesAfter: number;
447
- readonly bytesBefore: number;
448
- readonly dilatedTexels: number;
449
- readonly occludedTexels: number;
450
- readonly validTexels: number;
451
- };
452
- /** LOD simplification, when it was configured for this model. */
453
- readonly simplify?: ISimplifyRow;
454
- /** The cluster-DAG bake, when it was configured for this model. */
455
- readonly virtual?: IVirtualRow;
456
- /** Triangle count of the compiled output, recorded in the manifest. */
457
- readonly triangles?: number;
458
- }
459
- declare function formatModelSizes(rows: readonly IModelSizeRow[]): readonly string[];
865
+ declare function audioPass(options?: IAudioPassOptions): IAssetPass;
460
866
 
461
867
  interface IPngInfo {
462
868
  readonly width: number;
@@ -467,8 +873,25 @@ interface IPngInfo {
467
873
  declare function parsePng(value: Buffer): IPngInfo | undefined;
468
874
 
469
875
  /**
470
- * Dev-mode compilation: watches the asset source directory and recompiles changed inputs into
471
- * the output directory, so the dev loop is not "rebuild to see a texture".
876
+ * Dev-mode compilation: watches the asset source directory and recompiles into the output
877
+ * directory, so the dev loop is not "rebuild to see a texture".
878
+ *
879
+ * A settled burst runs `compileAssets` over the whole project — the same call the build lane
880
+ * makes, once per burst rather than once per changed file. The compiler's cache is what makes
881
+ * that affordable: an input whose bytes and pass configuration are unchanged is a hit and is
882
+ * never re-encoded, so a save pays for the file that changed. It is also the only way the output
883
+ * root stays honest, because nothing that matters here is per-file. One cook writes the manifest,
884
+ * the receipt naming every file it owns, the shared images several models point at and the Basis
885
+ * runtime the KTX2 loader fetches for them; it deletes the outputs the previous receipt owned and
886
+ * this one does not; and it measures the byte budget across the project, which is the only scale
887
+ * a ceiling means anything at. Cooking one file in isolation can produce a file but never a
888
+ * project: it publishes an entry without the images underneath it, orphans yesterday's output in
889
+ * `public/` the moment a save renames it, and clears a whole-game budget by weighing one asset.
890
+ *
891
+ * The price is that a burst fails closed. One compile means a broken file fails the burst, so a
892
+ * good file saved beside it is not published either. That is the compiler's own contract — a pass
893
+ * failure stops the build with the asset path — and the safe direction here: the last good cook
894
+ * keeps serving, the named failure goes to stderr, and the next good save heals it.
472
895
  *
473
896
  * Watcher mechanism: `node:fs.watch({ recursive: true })` — deliberately neither chokidar nor
474
897
  * Vite's own server watcher. This package is Node-only with zero runtime dependencies; Vite's
@@ -480,6 +903,12 @@ declare function parsePng(value: Buffer): IPngInfo | undefined;
480
903
  interface IAssetWatchSummary {
481
904
  readonly compiled: readonly string[];
482
905
  readonly failed: readonly string[];
906
+ /**
907
+ * One cost row per pass for this burst's compile, the same records a build emits. Present only
908
+ * when the burst compiled something; a burst compiles the whole project, so an input the cache
909
+ * skipped is counted in `cachedInputs` rather than left out.
910
+ */
911
+ readonly passCosts?: readonly IPassCostRow[];
483
912
  }
484
913
  interface IAssetWatchOptions extends IAssetCompileOptions {
485
914
  /** Quiet window that coalesces a burst of saves into one recompile. Default 100 ms. */
@@ -499,4 +928,4 @@ interface IAssetWatchHandle {
499
928
  }
500
929
  declare function watchAssets(options?: IAssetWatchOptions): IAssetWatchHandle;
501
930
 
502
- export { type AssetFindingGrade, type AssetKind, type IAssetCompileOptions, type IAssetCompileResult, type IAssetFinding, type IAssetHealthEntry, type IAssetHealthInput, type IAssetHealthReport, type IAssetPass, type IAssetPassOutput, type IAssetSourceConfig, type IAssetTargets, type IAssetWatchHandle, type IAssetWatchOptions, type IAssetWatchSummary, type IBasisTranscoder, type IEmbeddedTextureRow, type IEmbeddedTextureSummary, type ILightmapPassOptions, type IModelPassOptions, type IModelPassesOptions, type IModelQuantizeOptions, type IModelSimplifyOptions, type IModelSimplifySummary, type IModelSizeRow, type IModelStats, type IModelTextureBinding, type IModelTextureBindings, type IModelTextureOverride, type IModelTexturesOptions, type IModelsConfig, type IPngInfo, type ISimplifyRow, type ITextureOverride, type ITexturePassOptions, type ITextureSizeRow, type ITextureStats, type ITexturesConfig, type TextureCodec, compileAssets, formatHealthReport, formatModelSizes, formatTextureSizes, lightmapPass, modelPass, parsePng, resolveBasisTranscoder, runHealthReport, texturePass, watchAssets };
931
+ export { type AssetFindingGrade, type AssetKind, type AudioBand, type AudioConditioning, type AudioNormalisation, type IAssetCompileOptions, type IAssetCompileResult, type IAssetFinding, type IAssetHealthEntry, type IAssetHealthInput, type IAssetHealthReport, type IAssetPass, type IAssetPassOutput, type IAssetSourceConfig, type IAssetTargets, type IAssetWatchHandle, type IAssetWatchOptions, type IAssetWatchSummary, type IAudioConfig, type IAudioLoopOptions, type IAudioOverride, type IAudioPassOptions, type IAudioRow, type IAudioSpectrumExpectation, type IBakeReceipt, type IBakeReceiptOutput, type IBasisTranscoder, type IEmbeddedTextureRow, type IEmbeddedTextureSummary, type ILightmapPassOptions, type IModelPassOptions, type IModelPassesOptions, type IModelQuantizeOptions, type IModelSimplifyOptions, type IModelSimplifySummary, type IModelSizeRow, type IModelStats, type IModelTextureBinding, type IModelTextureBindings, type IModelTextureOverride, type IModelTexturesOptions, type IModelsConfig, type IPassCostAssetRow, type IPassCostRow, type IPngInfo, type ISimplifyRow, type ITextureOverride, type ITexturePassOptions, type ITextureSizeRow, type ITextureStats, type ITexturesConfig, type PassCostStatus, type TextureCodec, audioPass, compileAssets, formatAudioSizes, formatHealthReport, formatModelSizes, formatPassCosts, formatTextureSizes, lightmapPass, modelPass, parseAudioConfig, parsePng, resolveBasisTranscoder, runHealthReport, texturePass, watchAssets };