@forgeax/engine-codec 0.1.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.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +268 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts +2 -0
  5. package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts +2 -0
  7. package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/basis-encode-mode-owner.test-d.d.ts +2 -0
  9. package/dist/__tests__/basis-encode-mode-owner.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/basis-transcoder.unit.test.d.ts +2 -0
  11. package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/block-format.unit.test.d.ts +2 -0
  13. package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
  14. package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
  15. package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
  16. package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
  17. package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
  18. package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
  19. package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
  21. package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
  23. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
  25. package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
  27. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
  28. package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
  29. package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
  30. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
  31. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
  32. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
  33. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
  34. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
  35. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
  36. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
  37. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
  38. package/dist/__tests__/runtime-validation.unit.test.d.ts +2 -0
  39. package/dist/__tests__/runtime-validation.unit.test.d.ts.map +1 -0
  40. package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
  41. package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
  43. package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
  44. package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
  45. package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
  47. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
  48. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
  49. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
  50. package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
  51. package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
  52. package/dist/basis-transcoder.d.ts +86 -0
  53. package/dist/basis-transcoder.d.ts.map +1 -0
  54. package/dist/block-format.d.ts +41 -0
  55. package/dist/block-format.d.ts.map +1 -0
  56. package/dist/encode/basis-encode.d.ts +68 -0
  57. package/dist/encode/basis-encode.d.ts.map +1 -0
  58. package/dist/encode/index.d.ts +10 -0
  59. package/dist/encode/index.d.ts.map +1 -0
  60. package/dist/encode/index.mjs +207 -0
  61. package/dist/encode/index.mjs.map +1 -0
  62. package/dist/encode-impl.d.ts +30 -0
  63. package/dist/encode-impl.d.ts.map +1 -0
  64. package/dist/errors.d.ts +61 -0
  65. package/dist/errors.d.ts.map +1 -0
  66. package/dist/index.d.ts +17 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.mjs +672 -0
  69. package/dist/index.mjs.map +1 -0
  70. package/dist/ktx2.d.ts +111 -0
  71. package/dist/ktx2.d.ts.map +1 -0
  72. package/dist/transcode.d.ts +55 -0
  73. package/dist/transcode.d.ts.map +1 -0
  74. package/dist/wasm/basis-types.d.ts +179 -0
  75. package/dist/wasm/basis-types.d.ts.map +1 -0
  76. package/dist/zstd.d.ts +28 -0
  77. package/dist/zstd.d.ts.map +1 -0
  78. package/package.json +71 -0
  79. package/pkg/basis_transcoder.mjs +2 -0
  80. package/pkg/basis_transcoder.wasm +0 -0
  81. package/pkg/encode/basis_encoder.mjs +2 -0
  82. package/pkg/encode/basis_encoder.wasm +0 -0
  83. package/scripts/build-wasm.mjs +370 -0
  84. package/scripts/content-key.mjs +85 -0
  85. package/scripts/ensure-wasm.mjs +38 -0
  86. package/scripts/fetch-basis.mjs +150 -0
  87. package/scripts/fetch-wasm.mjs +97 -0
  88. package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
  89. package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
  90. package/src/__tests__/basis-encode-mode-owner.test-d.ts +39 -0
  91. package/src/__tests__/basis-transcoder.unit.test.ts +502 -0
  92. package/src/__tests__/block-format.unit.test.ts +215 -0
  93. package/src/__tests__/byte-identical-falsification.test.ts +164 -0
  94. package/src/__tests__/codec-errors.test-d.ts +67 -0
  95. package/src/__tests__/codec-errors.unit.test.ts +128 -0
  96. package/src/__tests__/compression-field.unit.test.ts +245 -0
  97. package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
  98. package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
  99. package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
  100. package/src/__tests__/ktx2-errors.test.ts +239 -0
  101. package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
  102. package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
  103. package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
  104. package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
  105. package/src/__tests__/runtime-validation.unit.test.ts +19 -0
  106. package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
  107. package/src/__tests__/wasm-init-smoke.test.ts +102 -0
  108. package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
  109. package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
  110. package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
  111. package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
  112. package/src/basis-transcoder.ts +380 -0
  113. package/src/block-format.ts +128 -0
  114. package/src/encode/basis-encode.ts +264 -0
  115. package/src/encode/index.ts +19 -0
  116. package/src/encode-impl.ts +103 -0
  117. package/src/errors.ts +79 -0
  118. package/src/index.ts +38 -0
  119. package/src/ktx2.ts +469 -0
  120. package/src/transcode.ts +99 -0
  121. package/src/wasm/basis-types.ts +220 -0
  122. package/src/zstd.ts +85 -0
package/src/ktx2.ts ADDED
@@ -0,0 +1,469 @@
1
+ /**
2
+ * KTX2 2.0 container parser (per spec 2026-07-07-ktx2-container-binary-spec.md).
3
+ *
4
+ * Parses header + index + level index + DFD + KV metadata + SGD.
5
+ * Does NOT interpret block-compressed payload content (OOS-6, Loop 2).
6
+ *
7
+ * scheme=2 (Zstandard) level decode reuses decompressZstd (AC-04 single implementation).
8
+ */
9
+
10
+ import type { CodecResult } from './errors.js';
11
+ import { codecError } from './errors.js';
12
+ import { decompressZstd } from './zstd.js';
13
+
14
+ /**
15
+ * KTX2 2.0 identifier magic (12 bytes per spec section 1).
16
+ * «KTX 20»\r\n\x1A\n
17
+ */
18
+ export const KTX2_IDENTIFIER = new Uint8Array([
19
+ 0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
20
+ ]);
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Public types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface Ktx2Header {
27
+ readonly vkFormat: number;
28
+ readonly typeSize: number;
29
+ readonly pixelWidth: number;
30
+ readonly pixelHeight: number;
31
+ readonly pixelDepth: number;
32
+ readonly layerCount: number;
33
+ readonly faceCount: number;
34
+ readonly levelCount: number;
35
+ readonly supercompressionScheme: number;
36
+ }
37
+
38
+ export interface Ktx2Index {
39
+ readonly dfdByteOffset: number;
40
+ readonly dfdByteLength: number;
41
+ readonly kvdByteOffset: number;
42
+ readonly kvdByteLength: number;
43
+ readonly sgdByteOffset: number;
44
+ readonly sgdByteLength: number;
45
+ }
46
+
47
+ export interface Ktx2LevelEntry {
48
+ readonly byteOffset: number;
49
+ readonly byteLength: number;
50
+ readonly uncompressedByteLength: number;
51
+ }
52
+
53
+ export interface Ktx2DfdSample {
54
+ readonly bitOffset: number;
55
+ readonly bitLength: number;
56
+ readonly channelType: number;
57
+ readonly qualifiers: number;
58
+ readonly samplePosition: readonly [number, number, number, number];
59
+ readonly sampleLower: number;
60
+ readonly sampleUpper: number;
61
+ }
62
+
63
+ export interface Ktx2Dfd {
64
+ readonly totalSize: number;
65
+ readonly vendorId: number;
66
+ readonly descriptorType: number;
67
+ readonly versionNumber: number;
68
+ readonly descriptorBlockSize: number;
69
+ readonly colorModel: number;
70
+ readonly colorPrimaries: number;
71
+ readonly transferFunction: number;
72
+ readonly flags: number;
73
+ readonly texelBlockDimension: readonly [number, number, number, number];
74
+ readonly bytesPlane: readonly number[];
75
+ readonly samples: readonly Ktx2DfdSample[];
76
+ }
77
+
78
+ export interface Ktx2KvEntry {
79
+ readonly key: string;
80
+ readonly value: Uint8Array;
81
+ }
82
+
83
+ /** Fully parsed KTX2 container structure (five parts per AC-03). */
84
+ export interface Ktx2Parsed {
85
+ readonly header: Ktx2Header;
86
+ readonly index: Ktx2Index;
87
+ /** Level index entries, stored smallest-first per KTX2 spec section 8. */
88
+ readonly levelIndex: readonly Ktx2LevelEntry[];
89
+ readonly dfd: Ktx2Dfd | null;
90
+ readonly kvEntries: readonly Ktx2KvEntry[];
91
+ readonly sgd: Uint8Array | null;
92
+ /**
93
+ * Reference to the raw source bytes. Carried so ktx2LevelsToRGBA can
94
+ * extract level payloads without requiring a second argument.
95
+ */
96
+ readonly rawBytes: Uint8Array;
97
+ }
98
+
99
+ /**
100
+ * Project the KTX2 DFD transfer function into the engine texture color
101
+ * contract. The DFD is the container's single color authority: callers must
102
+ * not maintain a second descriptor-side color field.
103
+ */
104
+ export function ktx2ColorSpace(parsed: Ktx2Parsed): 'srgb' | 'linear' | undefined {
105
+ switch (parsed.dfd?.transferFunction) {
106
+ case 1:
107
+ return 'linear';
108
+ case 2:
109
+ return 'srgb';
110
+ default:
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Internal helpers — little-endian reads
117
+ // ---------------------------------------------------------------------------
118
+
119
+ const TD = new TextDecoder();
120
+
121
+ function readU32(bytes: Uint8Array, byteOffset: number): number {
122
+ return new DataView(bytes.buffer, bytes.byteOffset + byteOffset, 4).getUint32(0, true);
123
+ }
124
+
125
+ /** Read u64 LE as Number (safe for values < 2^53; all KTX2 offsets fit). */
126
+ function readU64(bytes: Uint8Array, byteOffset: number): number {
127
+ return Number(new DataView(bytes.buffer, bytes.byteOffset + byteOffset, 8).getBigUint64(0, true));
128
+ }
129
+
130
+ /** Check that offset + length is within the byte array; throw if not. */
131
+ function assertBounds(bytes: Uint8Array, offset: number, length: number, context: string): void {
132
+ if (offset + length > bytes.length) {
133
+ throw new Error(
134
+ `KTX2 parse ${context}: OOB (offset=${offset}, length=${length}, fileSize=${bytes.length})`,
135
+ );
136
+ }
137
+ }
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // DFD parser (spec sections 5.1-5.3)
141
+ // ---------------------------------------------------------------------------
142
+
143
+ function parseDfd(bytes: Uint8Array, offset: number, _dfdByteLength: number): Ktx2Dfd {
144
+ assertBounds(bytes, offset, 4, 'DFD-totalSize');
145
+ const totalSize = readU32(bytes, offset);
146
+ assertBounds(bytes, offset, totalSize, 'DFD-block');
147
+
148
+ // Descriptor block starts at offset+4 (after dfdTotalSize)
149
+ const dbOff = offset + 4;
150
+
151
+ const word0 = readU32(bytes, dbOff);
152
+ const vendorId = word0 & 0x1ffff;
153
+ const descriptorType = (word0 >>> 17) & 0x7fff;
154
+
155
+ const word1 = readU32(bytes, dbOff + 4);
156
+ const versionNumber = word1 & 0xffff;
157
+ const descriptorBlockSize = (word1 >>> 16) & 0xffff;
158
+
159
+ const word2 = readU32(bytes, dbOff + 8);
160
+ const colorModel = word2 & 0xff;
161
+ const colorPrimaries = (word2 >>> 8) & 0xff;
162
+ const transferFunction = (word2 >>> 16) & 0xff;
163
+ const flags = (word2 >>> 24) & 0xff;
164
+
165
+ const word3 = readU32(bytes, dbOff + 12);
166
+ const texelBlockDim0 = word3 & 0xff;
167
+ const texelBlockDim1 = (word3 >>> 8) & 0xff;
168
+ const texelBlockDim2 = (word3 >>> 16) & 0xff;
169
+ const texelBlockDim3 = (word3 >>> 24) & 0xff;
170
+
171
+ const word4 = readU32(bytes, dbOff + 16);
172
+ const word5 = readU32(bytes, dbOff + 20);
173
+ const bytesPlane: readonly number[] = [
174
+ word4 & 0xff,
175
+ (word4 >>> 8) & 0xff,
176
+ (word4 >>> 16) & 0xff,
177
+ (word4 >>> 24) & 0xff,
178
+ word5 & 0xff,
179
+ (word5 >>> 8) & 0xff,
180
+ (word5 >>> 16) & 0xff,
181
+ (word5 >>> 24) & 0xff,
182
+ ];
183
+
184
+ // Samples: each 16 bytes = 4 u32 words, after 24-byte descriptor header
185
+ const sampleBase = dbOff + 24;
186
+ const numSamples = (descriptorBlockSize - 24) / 16;
187
+ const samples: Ktx2DfdSample[] = [];
188
+ for (let i = 0; i < numSamples; i++) {
189
+ const so = sampleBase + i * 16;
190
+ const sw0 = readU32(bytes, so);
191
+ const sw1 = readU32(bytes, so + 4);
192
+ const sw2 = readU32(bytes, so + 8);
193
+ const sw3 = readU32(bytes, so + 12);
194
+
195
+ samples.push({
196
+ qualifiers: sw0 & 0xf,
197
+ channelType: (sw0 >>> 4) & 0xff,
198
+ bitLength: ((sw0 >>> 12) & 0xfff) + 1, // stored as actual-1 per spec
199
+ bitOffset: (sw0 >>> 24) & 0xff,
200
+ samplePosition: [
201
+ sw1 & 0xff,
202
+ (sw1 >>> 8) & 0xff,
203
+ (sw1 >>> 16) & 0xff,
204
+ (sw1 >>> 24) & 0xff,
205
+ ] as const,
206
+ sampleLower: sw2,
207
+ sampleUpper: sw3,
208
+ });
209
+ }
210
+
211
+ return {
212
+ totalSize,
213
+ vendorId,
214
+ descriptorType,
215
+ versionNumber,
216
+ descriptorBlockSize,
217
+ colorModel,
218
+ colorPrimaries,
219
+ transferFunction,
220
+ flags,
221
+ texelBlockDimension: [texelBlockDim0, texelBlockDim1, texelBlockDim2, texelBlockDim3] as const,
222
+ bytesPlane,
223
+ samples,
224
+ };
225
+ }
226
+
227
+ // ---------------------------------------------------------------------------
228
+ // KV metadata parser (spec section 6)
229
+ // ---------------------------------------------------------------------------
230
+
231
+ function parseKv(bytes: Uint8Array, offset: number, kvdByteLength: number): Ktx2KvEntry[] {
232
+ if (kvdByteLength === 0) return [];
233
+
234
+ const entries: Ktx2KvEntry[] = [];
235
+ let pos = offset;
236
+ const end = offset + kvdByteLength;
237
+
238
+ while (pos < end) {
239
+ assertBounds(bytes, pos, 4, 'KV-keyAndValueByteLength');
240
+ const keyAndValueByteLength = readU32(bytes, pos);
241
+ pos += 4;
242
+
243
+ assertBounds(bytes, pos, keyAndValueByteLength, 'KV-payload');
244
+
245
+ const raw = bytes.slice(pos, pos + keyAndValueByteLength);
246
+ // Key is NUL-terminated UTF-8
247
+ let nulIdx = raw.indexOf(0);
248
+ if (nulIdx === -1) nulIdx = raw.length;
249
+ const key = TD.decode(raw.slice(0, nulIdx));
250
+ const value = raw.slice(nulIdx + 1);
251
+
252
+ entries.push({ key, value });
253
+
254
+ pos += keyAndValueByteLength;
255
+
256
+ // align(4) padding per spec section 6
257
+ const remainder = pos & 3;
258
+ if (remainder !== 0) {
259
+ pos += 4 - remainder;
260
+ }
261
+ }
262
+
263
+ return entries;
264
+ }
265
+
266
+ // ---------------------------------------------------------------------------
267
+ // Main parser (spec section 12, steps 1-6)
268
+ // ---------------------------------------------------------------------------
269
+
270
+ /**
271
+ * Parse a KTX2 2.0 binary container into its five structural parts:
272
+ * header, index, level index, DFD, KV metadata, and SGD.
273
+ *
274
+ * Does NOT interpret block-compressed payload content (OOS-6).
275
+ *
276
+ * Rejects unsupported supercompression schemes (BasisLZ=1, ZLIB=3, etc.)
277
+ * with `ktx2-unsupported-scheme` that echoes the scheme value.
278
+ */
279
+ export async function parseKtx2(bytes: Uint8Array): Promise<CodecResult<Ktx2Parsed>> {
280
+ try {
281
+ // 1. Validate identifier (12 bytes, spec section 1)
282
+ if (bytes.length < 12) {
283
+ return codecError('ktx2-parse-failed', {
284
+ reason: 'truncated-identifier: file shorter than 12-byte KTX2 magic',
285
+ });
286
+ }
287
+ for (let i = 0; i < 12; i++) {
288
+ const expectedByte = KTX2_IDENTIFIER[i];
289
+ if (expectedByte === undefined || bytes[i] !== expectedByte) {
290
+ return codecError('ktx2-parse-failed', {
291
+ reason: 'invalid-identifier: not a KTX2 2.0 file',
292
+ });
293
+ }
294
+ }
295
+
296
+ // 2. Header + index: identifier(12) + 9*u32(36) + 4*u32(16) + 2*u64(16) = 80
297
+ if (bytes.length < 80) {
298
+ return codecError('ktx2-parse-failed', {
299
+ reason: 'truncated-header: file too short for KTX2 header + index (min 80 bytes)',
300
+ });
301
+ }
302
+
303
+ const header: Ktx2Header = {
304
+ vkFormat: readU32(bytes, 12),
305
+ typeSize: readU32(bytes, 16),
306
+ pixelWidth: readU32(bytes, 20),
307
+ pixelHeight: readU32(bytes, 24),
308
+ pixelDepth: readU32(bytes, 28),
309
+ layerCount: readU32(bytes, 32),
310
+ faceCount: readU32(bytes, 36),
311
+ levelCount: readU32(bytes, 40),
312
+ supercompressionScheme: readU32(bytes, 44),
313
+ };
314
+
315
+ const index: Ktx2Index = {
316
+ dfdByteOffset: readU32(bytes, 48),
317
+ dfdByteLength: readU32(bytes, 52),
318
+ kvdByteOffset: readU32(bytes, 56),
319
+ kvdByteLength: readU32(bytes, 60),
320
+ sgdByteOffset: readU64(bytes, 64),
321
+ sgdByteLength: readU64(bytes, 72),
322
+ };
323
+
324
+ // 3. Reject unsupported supercompression schemes (spec section 2.9).
325
+ // scheme 0 = none, 1 = BasisLZ (Basis ETC1S payload, Loop 2 transcode arm),
326
+ // 2 = Zstandard. scheme 3 (ZLIB) and any future scheme are still rejected;
327
+ // scheme=1 is opened here (F-1 single-point gate) so the Basis payload passes
328
+ // through to the transcode layer -- parseKtx2 itself does not interpret it.
329
+ const scheme = header.supercompressionScheme;
330
+ if (scheme !== 0 && scheme !== 1 && scheme !== 2) {
331
+ return codecError('ktx2-unsupported-scheme', { scheme });
332
+ }
333
+
334
+ // 4. Level index: N = max(1, levelCount) per spec section 4
335
+ const numLevels = Math.max(1, header.levelCount);
336
+ const levelIndexStart = 80;
337
+ assertBounds(bytes, levelIndexStart, numLevels * 24, 'level-index');
338
+
339
+ const levelIndex: Ktx2LevelEntry[] = [];
340
+ for (let i = 0; i < numLevels; i++) {
341
+ const off = levelIndexStart + i * 24;
342
+ const byteOffset = readU64(bytes, off);
343
+ const byteLength = readU64(bytes, off + 8);
344
+ const uncompressedByteLength = readU64(bytes, off + 16);
345
+
346
+ // Validate level data OOB
347
+ if (byteOffset + byteLength > bytes.length) {
348
+ return codecError('ktx2-parse-failed', {
349
+ reason: `level-index-OOB: level ${i} byteOffset=${byteOffset} byteLength=${byteLength} exceeds file size=${bytes.length}`,
350
+ });
351
+ }
352
+
353
+ levelIndex.push({ byteOffset, byteLength, uncompressedByteLength });
354
+ }
355
+
356
+ // 5. DFD (spec section 5)
357
+ let dfd: Ktx2Dfd | null = null;
358
+ if (index.dfdByteLength > 0) {
359
+ assertBounds(bytes, index.dfdByteOffset, index.dfdByteLength, 'DFD');
360
+ dfd = parseDfd(bytes, index.dfdByteOffset, index.dfdByteLength);
361
+ }
362
+
363
+ // 6. KV metadata (spec section 6)
364
+ let kvEntries: Ktx2KvEntry[] = [];
365
+ if (index.kvdByteLength > 0) {
366
+ assertBounds(bytes, index.kvdByteOffset, index.kvdByteLength, 'KVD');
367
+ kvEntries = parseKv(bytes, index.kvdByteOffset, index.kvdByteLength);
368
+ }
369
+
370
+ // 7. Supercompression Global Data (spec section 7)
371
+ let sgd: Uint8Array | null = null;
372
+ if (index.sgdByteLength > 0) {
373
+ assertBounds(bytes, index.sgdByteOffset, index.sgdByteLength, 'SGD');
374
+ sgd = bytes.slice(index.sgdByteOffset, index.sgdByteOffset + index.sgdByteLength);
375
+ }
376
+
377
+ return {
378
+ ok: true,
379
+ value: {
380
+ header,
381
+ index,
382
+ levelIndex,
383
+ dfd,
384
+ kvEntries,
385
+ sgd,
386
+ rawBytes: bytes,
387
+ },
388
+ };
389
+ } catch (err: unknown) {
390
+ const message = err instanceof Error ? err.message : String(err);
391
+ return codecError('ktx2-parse-failed', { reason: `internal: ${message}` });
392
+ }
393
+ }
394
+
395
+ // ---------------------------------------------------------------------------
396
+ // Levels → RGBA extraction (spec section 12, step 7)
397
+ // ---------------------------------------------------------------------------
398
+
399
+ /**
400
+ * Map a mip level number to the levelIndex entry.
401
+ *
402
+ * KTX2 stores mip levels smallest-first in both the level index and payload
403
+ * section (spec section 8): levelIndex[0] = smallest (level N-1),
404
+ * levelIndex[N-1] = base (level 0).
405
+ *
406
+ * So mip level 0 (base/largest) maps to levelIndex[N-1],
407
+ * mip level 1 maps to levelIndex[N-2], etc.
408
+ */
409
+ function levelIndexForMip(totalLevels: number, mipLevel: number): number {
410
+ const entryIdx = totalLevels - 1 - mipLevel;
411
+ if (entryIdx < 0 || entryIdx >= totalLevels) return -1;
412
+ return entryIdx;
413
+ }
414
+
415
+ /**
416
+ * Extract RGBA pixel bytes from a parsed KTX2 container at a given mip level.
417
+ *
418
+ * - scheme=0 (uncompressed): returns raw level payload directly.
419
+ * mipPadding between levels is handled by using levelIndex byteOffsets.
420
+ * - scheme=2 (Zstandard supercompression): decompresses with `decompressZstd`,
421
+ * the same function used by the asset-layer fetchBinary gate (AC-04).
422
+ * - Other schemes: not reached (parseKtx2 rejects them), but defensively
423
+ * returns `ktx2-unsupported-scheme`.
424
+ *
425
+ * @param parsed Result from `parseKtx2`.
426
+ * @param level Mip level number (0 = base/largest, default 0).
427
+ */
428
+ export async function ktx2LevelsToRGBA(
429
+ parsed: Ktx2Parsed,
430
+ level: number = 0,
431
+ ): Promise<CodecResult<Uint8Array>> {
432
+ const totalLevels = parsed.levelIndex.length;
433
+ const entryIdx = levelIndexForMip(totalLevels, level);
434
+ if (entryIdx < 0) {
435
+ return codecError('ktx2-parse-failed', { reason: `mip level ${level} does not exist` });
436
+ }
437
+ const entry = parsed.levelIndex[entryIdx];
438
+ if (!entry) {
439
+ return codecError('ktx2-parse-failed', {
440
+ reason: `mip level ${level} has no level index entry`,
441
+ });
442
+ }
443
+
444
+ if (parsed.header.supercompressionScheme === 0) {
445
+ // Uncompressed — extract raw byte slice from source
446
+ const slice = parsed.rawBytes.slice(entry.byteOffset, entry.byteOffset + entry.byteLength);
447
+ return { ok: true, value: new Uint8Array(slice) };
448
+ }
449
+
450
+ if (parsed.header.supercompressionScheme === 2) {
451
+ // Zstandard — decompress with same function as asset-layer (AC-04)
452
+ const compressedSlice = parsed.rawBytes.slice(
453
+ entry.byteOffset,
454
+ entry.byteOffset + entry.byteLength,
455
+ );
456
+ const result = await decompressZstd(new Uint8Array(compressedSlice));
457
+ if (!result.ok) {
458
+ return codecError('ktx2-parse-failed', {
459
+ reason: `zstd decompression failed for level ${level}: ${result.error.detail}`,
460
+ });
461
+ }
462
+ return result;
463
+ }
464
+
465
+ // Defensive: unsupported schemes should have been rejected by parseKtx2
466
+ return codecError('ktx2-unsupported-scheme', {
467
+ scheme: parsed.header.supercompressionScheme,
468
+ });
469
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Transcode target selection (D-4 priority chain, browser+node dual-safe).
3
+ *
4
+ * `selectTranscodeTarget` is a pure function: given the source Basis encoding
5
+ * descriptor (DFD-derived) and the device's compression capabilities, it returns
6
+ * the `GPUTextureFormat` to transcode into. It performs zero I/O and imports no
7
+ * node-only or DOM API -- an AI user can call it in node to predict, offline,
8
+ * which format a given caps combination will hit.
9
+ *
10
+ * Priority chains (requirements §range table + D-4 Mesa guard):
11
+ * RGBA : ASTC-4x4 -> BC7 -> ETC2-rgba8 -> RGBA8
12
+ * RG : BC5 -> EAC-rg11 -> RG8
13
+ * R : BC4 -> EAC-r11 -> R8
14
+ * HDR : BC6H -> rgba16float
15
+ *
16
+ * D-4 "BC present => prefer BC": when `bc` is available alongside `astc`/`etc2`,
17
+ * every LDR arm selects the BC target first. Rationale: bc coexisting with
18
+ * astc/etc2 means either Apple Silicon (dual hardware; BC7 is lossless-equal at
19
+ * 8bpp) or a desktop driver software-decoding ETC2/ASTC (the Mesa/ANV trap to
20
+ * avoid). Preferring BC is correct in both cases, so the rule needs no source
21
+ * discrimination. Because `bc` is tested first in each arm, this fall-through is
22
+ * inherent -- no extra branch.
23
+ *
24
+ * Fallback is a format, never an error: with no compression cap the LDR arms
25
+ * return `rgba8unorm[-srgb]` and the HDR arm returns `rgba16float`. Degradation
26
+ * is silent but observable (the returned format reflects reality). ASTC-HDR and
27
+ * PVRTC are out of scope (OOS-5 / not exposed by WebGPU core).
28
+ */
29
+
30
+ /** The DFD-derived source encoding of the Basis payload (D-3 delivery encodings). */
31
+ export type TranscodeModel = 'etc1s' | 'uastc-ldr' | 'uastc-hdr';
32
+
33
+ /** Which channels the source carries, deciding the LDR arm (data vs color). */
34
+ export type TranscodeChannels = 'rgba' | 'rg' | 'r';
35
+
36
+ /** Source descriptor for target selection; all fields DFD-derived upstream. */
37
+ export interface TranscodeSource {
38
+ readonly model: TranscodeModel;
39
+ /** sRGB transfer function (from the DFD). Only varies the RGBA color arm. */
40
+ readonly srgb: boolean;
41
+ readonly channels: TranscodeChannels;
42
+ }
43
+
44
+ /**
45
+ * Device compression capabilities (D-8 local structure -- codec must not depend
46
+ * on the rhi package; the runtime side projects `RhiCaps` into this).
47
+ */
48
+ export interface TranscodeCaps {
49
+ readonly bc: boolean;
50
+ readonly etc2: boolean;
51
+ readonly astc: boolean;
52
+ }
53
+
54
+ function selectRgba(srgb: boolean, caps: TranscodeCaps): GPUTextureFormat {
55
+ // D-4: BC wins whenever present (before ASTC), guarding the Mesa/ANV trap.
56
+ if (caps.bc) return srgb ? 'bc7-rgba-unorm-srgb' : 'bc7-rgba-unorm';
57
+ if (caps.astc) return srgb ? 'astc-4x4-unorm-srgb' : 'astc-4x4-unorm';
58
+ if (caps.etc2) return srgb ? 'etc2-rgba8unorm-srgb' : 'etc2-rgba8unorm';
59
+ return srgb ? 'rgba8unorm-srgb' : 'rgba8unorm';
60
+ }
61
+
62
+ function selectRg(caps: TranscodeCaps): GPUTextureFormat {
63
+ // Data channels have no sRGB variant. ASTC is not in the RG chain.
64
+ if (caps.bc) return 'bc5-rg-unorm';
65
+ if (caps.etc2) return 'eac-rg11unorm';
66
+ return 'rg8unorm';
67
+ }
68
+
69
+ function selectR(caps: TranscodeCaps): GPUTextureFormat {
70
+ if (caps.bc) return 'bc4-r-unorm';
71
+ if (caps.etc2) return 'eac-r11unorm';
72
+ return 'r8unorm';
73
+ }
74
+
75
+ function selectHdr(caps: TranscodeCaps): GPUTextureFormat {
76
+ // ASTC-HDR is OOS; only BC6H, else uncompressed half-float.
77
+ if (caps.bc) return 'bc6h-rgb-ufloat';
78
+ return 'rgba16float';
79
+ }
80
+
81
+ /**
82
+ * Select the transcode target `GPUTextureFormat` for a Basis source under the
83
+ * given device caps. Pure, browser+node dual-safe, never returns an error --
84
+ * a missing cap degrades to an uncompressed format, not a failure.
85
+ */
86
+ export function selectTranscodeTarget(
87
+ source: TranscodeSource,
88
+ caps: TranscodeCaps,
89
+ ): GPUTextureFormat {
90
+ if (source.model === 'uastc-hdr') return selectHdr(caps);
91
+ switch (source.channels) {
92
+ case 'rgba':
93
+ return selectRgba(source.srgb, caps);
94
+ case 'rg':
95
+ return selectRg(caps);
96
+ case 'r':
97
+ return selectR(caps);
98
+ }
99
+ }