@forgeax/engine-codec 0.0.0-dev.8d955ade1c79

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 (116) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +261 -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-transcoder.unit.test.d.ts +2 -0
  9. package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
  10. package/dist/__tests__/block-format.unit.test.d.ts +2 -0
  11. package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
  13. package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
  14. package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
  15. package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
  16. package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
  17. package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
  18. package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
  19. package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
  20. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
  21. package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
  23. package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
  25. package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
  26. package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
  27. package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
  28. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
  29. package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
  30. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
  31. package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
  32. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
  33. package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
  35. package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
  36. package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
  37. package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
  38. package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
  39. package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
  40. package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
  41. package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
  42. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
  43. package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
  44. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
  45. package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
  46. package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
  47. package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
  48. package/dist/basis-transcoder.d.ts +86 -0
  49. package/dist/basis-transcoder.d.ts.map +1 -0
  50. package/dist/block-format.d.ts +41 -0
  51. package/dist/block-format.d.ts.map +1 -0
  52. package/dist/encode/basis-encode.d.ts +68 -0
  53. package/dist/encode/basis-encode.d.ts.map +1 -0
  54. package/dist/encode/index.d.ts +10 -0
  55. package/dist/encode/index.d.ts.map +1 -0
  56. package/dist/encode/index.mjs +207 -0
  57. package/dist/encode/index.mjs.map +1 -0
  58. package/dist/encode-impl.d.ts +30 -0
  59. package/dist/encode-impl.d.ts.map +1 -0
  60. package/dist/errors.d.ts +61 -0
  61. package/dist/errors.d.ts.map +1 -0
  62. package/dist/index.d.ts +17 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.mjs +610 -0
  65. package/dist/index.mjs.map +1 -0
  66. package/dist/ktx2.d.ts +111 -0
  67. package/dist/ktx2.d.ts.map +1 -0
  68. package/dist/transcode.d.ts +55 -0
  69. package/dist/transcode.d.ts.map +1 -0
  70. package/dist/wasm/basis-types.d.ts +179 -0
  71. package/dist/wasm/basis-types.d.ts.map +1 -0
  72. package/dist/zstd.d.ts +28 -0
  73. package/dist/zstd.d.ts.map +1 -0
  74. package/package.json +71 -0
  75. package/pkg/basis_transcoder.mjs +2 -0
  76. package/pkg/basis_transcoder.wasm +0 -0
  77. package/pkg/encode/basis_encoder.mjs +2 -0
  78. package/pkg/encode/basis_encoder.wasm +0 -0
  79. package/scripts/build-wasm.mjs +370 -0
  80. package/scripts/content-key.mjs +85 -0
  81. package/scripts/ensure-wasm.mjs +38 -0
  82. package/scripts/fetch-basis.mjs +150 -0
  83. package/scripts/fetch-wasm.mjs +97 -0
  84. package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
  85. package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
  86. package/src/__tests__/basis-transcoder.unit.test.ts +151 -0
  87. package/src/__tests__/block-format.unit.test.ts +215 -0
  88. package/src/__tests__/byte-identical-falsification.test.ts +164 -0
  89. package/src/__tests__/codec-errors.test-d.ts +67 -0
  90. package/src/__tests__/codec-errors.unit.test.ts +128 -0
  91. package/src/__tests__/compression-field.unit.test.ts +245 -0
  92. package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
  93. package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
  94. package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
  95. package/src/__tests__/ktx2-errors.test.ts +239 -0
  96. package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
  97. package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
  98. package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
  99. package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
  100. package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
  101. package/src/__tests__/wasm-init-smoke.test.ts +102 -0
  102. package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
  103. package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
  104. package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
  105. package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
  106. package/src/basis-transcoder.ts +315 -0
  107. package/src/block-format.ts +128 -0
  108. package/src/encode/basis-encode.ts +264 -0
  109. package/src/encode/index.ts +19 -0
  110. package/src/encode-impl.ts +103 -0
  111. package/src/errors.ts +79 -0
  112. package/src/index.ts +38 -0
  113. package/src/ktx2.ts +469 -0
  114. package/src/transcode.ts +99 -0
  115. package/src/wasm/basis-types.ts +220 -0
  116. package/src/zstd.ts +85 -0
@@ -0,0 +1,49 @@
1
+ import { compressZstd } from '@forgeax/engine-codec/encode';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ /** Generate deterministic f32-ish bytes (vertex-like float data). */
5
+ function makeF32Data(elementCount: number): Uint8Array {
6
+ const floats = new Float32Array(elementCount);
7
+ for (let i = 0; i < elementCount; i++) {
8
+ floats[i] = Math.sin(i * 0.1) * 10.0 + (i % 100) * 0.01;
9
+ }
10
+ return new Uint8Array(floats.buffer, floats.byteOffset, floats.byteLength);
11
+ }
12
+
13
+ describe('zstd encode deterministic (w3)', () => {
14
+ it('5x compress same input produces byte-identical output (AC-07)', async () => {
15
+ const input = makeF32Data(4096); // 16384 bytes of f32 vertex-like data
16
+ const results: Uint8Array[] = [];
17
+ for (let i = 0; i < 5; i++) {
18
+ const r = await compressZstd(input);
19
+ expect(r.ok).toBe(true);
20
+ if (!r.ok) throw new Error(`compress failed at iteration ${i}`);
21
+ results.push(r.value);
22
+ }
23
+ // All 5 outputs must be byte-identical
24
+ const first = results[0];
25
+ if (!first) throw new Error('unreachable: results[0] exists after 5 pushes');
26
+ for (let i = 1; i < results.length; i++) {
27
+ expect(results[i]).toEqual(first);
28
+ }
29
+ });
30
+
31
+ it('different inputs produce different compressed output (discriminability)', async () => {
32
+ const inputA = makeF32Data(4096);
33
+ const inputB = makeF32Data(4097); // different data
34
+ const rA = await compressZstd(inputA);
35
+ const rB = await compressZstd(inputB);
36
+ expect(rA.ok).toBe(true);
37
+ expect(rB.ok).toBe(true);
38
+ if (!rA.ok || !rB.ok) throw new Error('compress failed');
39
+ expect(rA.value).not.toEqual(rB.value);
40
+ });
41
+
42
+ it('compressed output is smaller than input for f32 array data (compression sanity)', async () => {
43
+ const input = makeF32Data(8192); // 32768 bytes
44
+ const r = await compressZstd(input);
45
+ expect(r.ok).toBe(true);
46
+ if (!r.ok) throw new Error('compress failed');
47
+ expect(r.value.length).toBeLessThan(input.length);
48
+ });
49
+ });
@@ -0,0 +1,63 @@
1
+ import {
2
+ _setZstdEncoderImporter,
3
+ _zstdEncodeInitCount,
4
+ compressZstd,
5
+ } from '@forgeax/engine-codec/encode';
6
+ import { afterEach, describe, expect, it } from 'vitest';
7
+
8
+ function makeBytes(size: number): Uint8Array {
9
+ const bytes = new Uint8Array(size);
10
+ for (let i = 0; i < size; i++) {
11
+ bytes[i] = (i * 37 + 13) & 0xff;
12
+ }
13
+ return bytes;
14
+ }
15
+
16
+ function hasZstdMagic(bytes: Uint8Array): boolean {
17
+ return bytes[0] === 0x28 && bytes[1] === 0xb5 && bytes[2] === 0x2f && bytes[3] === 0xfd;
18
+ }
19
+
20
+ afterEach(() => {
21
+ _setZstdEncoderImporter();
22
+ });
23
+
24
+ describe('zstd encoder deferred-init singleton', () => {
25
+ it('does not initialize until the first compression', () => {
26
+ _setZstdEncoderImporter();
27
+ expect(_zstdEncodeInitCount()).toBe(0);
28
+ });
29
+
30
+ it('shares one WASM init across concurrent first compressions', async () => {
31
+ _setZstdEncoderImporter();
32
+ const input = makeBytes(65536);
33
+ const results = await Promise.all(Array.from({ length: 8 }, () => compressZstd(input)));
34
+
35
+ expect(_zstdEncodeInitCount()).toBe(1);
36
+ for (const result of results) {
37
+ expect(result.ok).toBe(true);
38
+ if (!result.ok) throw new Error('zstd compression failed');
39
+ expect(hasZstdMagic(result.value)).toBe(true);
40
+ expect(result.value.some((byte) => byte !== 0)).toBe(true);
41
+ }
42
+ });
43
+
44
+ it('clears a failed init so the next compression retries', async () => {
45
+ let attempts = 0;
46
+ _setZstdEncoderImporter(() => ({
47
+ init: async () => {
48
+ attempts++;
49
+ if (attempts === 1) throw new Error('simulated zstd init failure');
50
+ },
51
+ compress: () => new Uint8Array([0x28, 0xb5, 0x2f, 0xfd]),
52
+ }));
53
+
54
+ const first = await compressZstd(makeBytes(8));
55
+ expect(first.ok).toBe(false);
56
+ expect(_zstdEncodeInitCount()).toBe(1);
57
+
58
+ const second = await compressZstd(makeBytes(8));
59
+ expect(second.ok).toBe(true);
60
+ expect(attempts).toBe(2);
61
+ expect(_zstdEncodeInitCount()).toBe(2);
62
+ });
63
+ });
@@ -0,0 +1,87 @@
1
+ import { decompressZstd } from '@forgeax/engine-codec';
2
+ // compressZstd comes from the encode subpath (build-time).
3
+ // In vitest context this resolves to packages/codec/src/encode/index.ts.
4
+ import { compressZstd } from '@forgeax/engine-codec/encode';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ /** Generate deterministic test bytes of given length. */
8
+ function makeBytes(size: number): Uint8Array {
9
+ const arr = new Uint8Array(size);
10
+ for (let i = 0; i < size; i++) {
11
+ arr[i] = (i * 37 + 13) & 0xff;
12
+ }
13
+ return arr;
14
+ }
15
+
16
+ /** Unwrap a CodecResult, throwing on error for test simplicity. */
17
+ async function unwrapZstd(result: Awaited<ReturnType<typeof decompressZstd>>): Promise<Uint8Array> {
18
+ if (!result.ok) {
19
+ throw new Error(`unexpected codec error: ${result.error.code} — ${result.error.detail}`);
20
+ }
21
+ return result.value;
22
+ }
23
+
24
+ describe('zstd round-trip (w2)', () => {
25
+ it('round-trip byte-identical for non-empty input (1 byte)', async () => {
26
+ const original = makeBytes(1);
27
+ const comp = await compressZstd(original);
28
+ expect(comp.ok).toBe(true);
29
+ if (!comp.ok) throw new Error('compress failed');
30
+ const decompressed = await unwrapZstd(await decompressZstd(comp.value));
31
+ expect(decompressed).toEqual(original);
32
+ });
33
+
34
+ it('round-trip byte-identical for 1024 bytes', async () => {
35
+ const original = makeBytes(1024);
36
+ const comp = await compressZstd(original);
37
+ expect(comp.ok).toBe(true);
38
+ if (!comp.ok) throw new Error('compress failed');
39
+ const decompressed = await unwrapZstd(await decompressZstd(comp.value));
40
+ expect(decompressed).toEqual(original);
41
+ });
42
+
43
+ it('round-trip byte-identical for 65536 bytes', async () => {
44
+ const original = makeBytes(65536);
45
+ const comp = await compressZstd(original);
46
+ expect(comp.ok).toBe(true);
47
+ if (!comp.ok) throw new Error('compress failed');
48
+ const decompressed = await unwrapZstd(await decompressZstd(comp.value));
49
+ expect(decompressed).toEqual(original);
50
+ });
51
+
52
+ it('0-byte input round-trip returns 0-byte (E4)', async () => {
53
+ const original = new Uint8Array(0);
54
+ const comp = await compressZstd(original);
55
+ expect(comp.ok).toBe(true);
56
+ if (!comp.ok) throw new Error('compress failed');
57
+ const decompressed = await unwrapZstd(await decompressZstd(comp.value));
58
+ expect(decompressed.length).toBe(0);
59
+ expect(decompressed).toEqual(original);
60
+ });
61
+
62
+ it('non-zstd garbage input returns decompression-failed error', async () => {
63
+ const garbage = new Uint8Array([0x00, 0x01, 0x02, 0x03, 0xff]);
64
+ const result = await decompressZstd(garbage);
65
+ expect(result.ok).toBe(false);
66
+ if (result.ok) {
67
+ throw new Error('expected error for garbage input');
68
+ }
69
+ expect(result.error.code).toBe('decompression-failed');
70
+ });
71
+
72
+ it('FALSIFICATION: corrupt 1 byte of valid compressed output, round-trip must FAIL', async () => {
73
+ const original = makeBytes(4096);
74
+ const comp = await compressZstd(original);
75
+ expect(comp.ok).toBe(true);
76
+ if (!comp.ok) throw new Error('compress failed');
77
+ // Corrupt byte 1 of the compressed stream
78
+ const corrupted = new Uint8Array(comp.value);
79
+ if (corrupted.length > 1) {
80
+ const val = corrupted[1];
81
+ if (val !== undefined) corrupted[1] = val ^ 0xff;
82
+ }
83
+ const result = await decompressZstd(corrupted);
84
+ // Must NOT silently return original bytes — correctness requires error
85
+ expect(result.ok).toBe(false);
86
+ });
87
+ });
@@ -0,0 +1,105 @@
1
+ import { compressZstd } from '@forgeax/engine-codec/encode';
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { _setZstdImporter, _zstdInitCount, decompressZstd } from '../zstd.js';
4
+
5
+ /**
6
+ * zstd lazy-init singleton test (w5).
7
+ *
8
+ * Validates that the decompressZstd implementation:
9
+ * 1. Does NOT trigger the fzstd importer until the first decompress call (AC-12 zero-cost).
10
+ * 2. Loads the decompressor exactly once even under concurrent first callers.
11
+ * 3. Retries on init failure (does NOT permanently cache the failure).
12
+ *
13
+ * The `_setZstdImporter` / `_zstdInitCount` internal hooks make the module-private
14
+ * singleton observable so these are real assertions, not behavioral guesses.
15
+ */
16
+
17
+ /** Generate deterministic test bytes. */
18
+ function makeBytes(size: number): Uint8Array {
19
+ const arr = new Uint8Array(size);
20
+ for (let i = 0; i < size; i++) {
21
+ arr[i] = (i * 37 + 13) & 0xff;
22
+ }
23
+ return arr;
24
+ }
25
+
26
+ afterEach(() => {
27
+ // Restore the real fzstd importer + reset counters so tests do not leak state.
28
+ _setZstdImporter();
29
+ });
30
+
31
+ describe('zstd deferred-init singleton (w5)', () => {
32
+ it('AC-12 zero-cost: importer is not invoked until the first decompress call', () => {
33
+ _setZstdImporter(); // reset counter to 0
34
+ expect(typeof decompressZstd).toBe('function');
35
+ // Importing / referencing the function must not have loaded the decompressor.
36
+ expect(_zstdInitCount()).toBe(0);
37
+ });
38
+
39
+ it('first call to decompressZstd triggers init exactly once and decompresses correctly', async () => {
40
+ _setZstdImporter();
41
+ const input = makeBytes(256);
42
+ const comp = await compressZstd(input);
43
+ if (!comp.ok) throw new Error('compress failed for test setup');
44
+
45
+ const result = await decompressZstd(comp.value);
46
+ expect(result.ok).toBe(true);
47
+ if (!result.ok) throw new Error('decompress failed');
48
+ expect(result.value).toEqual(input);
49
+ expect(_zstdInitCount()).toBe(1);
50
+ });
51
+
52
+ it('N concurrent first calls share a single init (importer invoked once) (AC-12)', async () => {
53
+ // Inject a slow importer so all 5 calls race while init is still in-flight.
54
+ const realFzstd = await import('fzstd');
55
+ _setZstdImporter(
56
+ () =>
57
+ new Promise((resolve) => {
58
+ setTimeout(() => resolve(realFzstd.decompress), 10);
59
+ }),
60
+ );
61
+
62
+ const input = makeBytes(256);
63
+ const comp = await compressZstd(input);
64
+ if (!comp.ok) throw new Error('compress failed for test setup');
65
+
66
+ const results = await Promise.all(Array.from({ length: 5 }, () => decompressZstd(comp.value)));
67
+
68
+ // The importer must have been invoked exactly once despite 5 concurrent callers.
69
+ expect(_zstdInitCount()).toBe(1);
70
+ for (const r of results) {
71
+ expect(r.ok).toBe(true);
72
+ if (!r.ok) throw new Error('decompress failed');
73
+ expect(r.value).toEqual(input);
74
+ }
75
+ });
76
+
77
+ it('init failure returns codec-init-failed and the next call retries (not permanently cached)', async () => {
78
+ let attempts = 0;
79
+ const realFzstd = await import('fzstd');
80
+ _setZstdImporter(() => {
81
+ attempts++;
82
+ if (attempts === 1) {
83
+ return Promise.reject(new Error('simulated fzstd load failure'));
84
+ }
85
+ return Promise.resolve(realFzstd.decompress);
86
+ });
87
+
88
+ const input = makeBytes(128);
89
+ const comp = await compressZstd(input);
90
+ if (!comp.ok) throw new Error('compress failed for test setup');
91
+
92
+ // First call: importer rejects -> codec-init-failed, cached failure cleared.
93
+ const failed = await decompressZstd(comp.value);
94
+ expect(failed.ok).toBe(false);
95
+ if (failed.ok) throw new Error('expected first call to fail');
96
+ expect(failed.error.code).toBe('codec-init-failed');
97
+
98
+ // Second call: importer retried (not permanently cached) -> succeeds.
99
+ const retried = await decompressZstd(comp.value);
100
+ expect(retried.ok).toBe(true);
101
+ if (!retried.ok) throw new Error('retry decompress failed');
102
+ expect(retried.value).toEqual(input);
103
+ expect(attempts).toBe(2);
104
+ });
105
+ });
@@ -0,0 +1,315 @@
1
+ /**
2
+ * Basis transcoder glue: lazy-init singleton (D-10) + per-mip KTX2 transcode.
3
+ *
4
+ * `initBasisTranscoder` dynamic-imports the self-built transcoder WASM module on
5
+ * first call and caches it; the second call returns the same instance without
6
+ * re-importing (D-10 main-thread lazy-init -- `Engine.create` does not preload,
7
+ * the first Basis payload triggers this). No worker pool (OOS -- forgeax has no
8
+ * worker infrastructure). Both node and browser can `import` this module: the
9
+ * WASM glue is a MODULARIZE factory whose `.mjs` path resolves in both.
10
+ *
11
+ * `transcodeKtx2` opens a parsed KTX2 container's raw bytes as a basis KTX2File,
12
+ * starts transcoding, and transcodes every mip level (mip-major) into the target
13
+ * `GPUTextureFormat`. It returns the block (or pixel) byte arrays per level; it
14
+ * does not upload -- the runtime upload path (M5) consumes these with the block
15
+ * table.
16
+ */
17
+
18
+ import type { CodecResult } from './errors.js';
19
+ import { codecError } from './errors.js';
20
+ import type { Ktx2Parsed } from './ktx2.js';
21
+ import type { BasisFile, BasisModuleFactory, BasisTranscoderModule } from './wasm/basis-types.js';
22
+
23
+ /** How the transcoder WASM module is loaded. Overridable in tests. */
24
+ type TranscoderImporter = () => Promise<BasisTranscoderModule>;
25
+
26
+ const TRANSCODER_GLUE = new URL('../pkg/basis_transcoder.mjs', import.meta.url);
27
+ const TRANSCODER_WASM = new URL('../pkg/basis_transcoder.wasm', import.meta.url);
28
+
29
+ const defaultImporter: TranscoderImporter = async () => {
30
+ const factory = (
31
+ (await import(/* @vite-ignore */ TRANSCODER_GLUE.href)) as {
32
+ default: BasisModuleFactory<BasisTranscoderModule>;
33
+ }
34
+ ).default;
35
+ const mod = await factory({ locateFile: () => TRANSCODER_WASM.href });
36
+ mod.initializeBasis();
37
+ return mod;
38
+ };
39
+
40
+ let importer: TranscoderImporter = defaultImporter;
41
+
42
+ /**
43
+ * Lazy-init singleton (D-10): null = never attempted, Promise = init in flight,
44
+ * resolved = ready, rejected = cleared so the next call retries.
45
+ * @internal
46
+ */
47
+ let _initPromise: Promise<BasisTranscoderModule> | null = null;
48
+
49
+ /** Count of importer invocations; observed in tests to prove single-init. @internal */
50
+ let initCount = 0;
51
+
52
+ /**
53
+ * Lazy-init the Basis transcoder WASM module (D-10 main-thread singleton).
54
+ * First call dynamic-imports + initializes; subsequent calls return the cache.
55
+ */
56
+ export function initBasisTranscoder(): Promise<BasisTranscoderModule> {
57
+ if (_initPromise !== null) return _initPromise;
58
+ initCount++;
59
+ _initPromise = importer().catch((cause: unknown) => {
60
+ _initPromise = null;
61
+ throw new Error('codec-init-failed', { cause });
62
+ });
63
+ return _initPromise;
64
+ }
65
+
66
+ /**
67
+ * Test-only: number of times the transcoder importer has been invoked. Proves
68
+ * the lazy singleton loads the module at most once (D-10).
69
+ * @internal
70
+ */
71
+ export function _basisTranscoderInitCount(): number {
72
+ return initCount;
73
+ }
74
+
75
+ /**
76
+ * Test-only: reset the lazy singleton and optionally override the importer.
77
+ * Call with no argument to restore the real WASM importer.
78
+ * @internal
79
+ */
80
+ export function _setBasisTranscoderImporter(next?: TranscoderImporter): void {
81
+ importer = next ?? defaultImporter;
82
+ _initPromise = null;
83
+ initCount = 0;
84
+ }
85
+
86
+ /** One transcoded mip level (mip-major output). */
87
+ export interface TranscodedMip {
88
+ readonly level: number;
89
+ readonly width: number;
90
+ readonly height: number;
91
+ /** Transcoded block (compressed) or pixel (uncompressed) bytes for this level. */
92
+ readonly data: Uint8Array;
93
+ }
94
+
95
+ /** Result of transcoding a KTX2 container into a target GPU format. */
96
+ export interface TranscodedTexture {
97
+ readonly format: GPUTextureFormat;
98
+ readonly width: number;
99
+ readonly height: number;
100
+ /** Mip levels, base (level 0 / largest) first. */
101
+ readonly mips: readonly TranscodedMip[];
102
+ }
103
+
104
+ export interface BasisSourceMeta {
105
+ readonly colorSpace: 'srgb' | 'linear';
106
+ }
107
+
108
+ export interface BasisSourceInspection {
109
+ readonly colorSpace: BasisSourceMeta['colorSpace'];
110
+ readonly profile: 'etc1s' | 'uastc-ldr';
111
+ readonly width: number;
112
+ readonly height: number;
113
+ readonly levelCount: number;
114
+ readonly imageCount: number;
115
+ }
116
+
117
+ /** Result of transcoding a raw Basis file into one engine texture format. */
118
+ export interface TranscodedBasisTexture {
119
+ readonly format: GPUTextureFormat;
120
+ readonly width: number;
121
+ readonly height: number;
122
+ readonly mips: readonly TranscodedMip[];
123
+ }
124
+
125
+ /** Inspect a raw Basis source without inferring color from the binary payload. */
126
+ export function inspectBasisSource(
127
+ file: BasisFile,
128
+ meta: Partial<BasisSourceMeta>,
129
+ profile: BasisSourceInspection['profile'],
130
+ ): CodecResult<BasisSourceInspection> {
131
+ if (meta.colorSpace !== 'srgb' && meta.colorSpace !== 'linear') {
132
+ return codecError('ktx2-parse-failed', {
133
+ reason: 'raw Basis source requires Meta.colorSpace=srgb|linear',
134
+ });
135
+ }
136
+ if (file.getNumImages() <= 0 || file.getNumLevels(0) <= 0) {
137
+ return codecError('ktx2-parse-failed', { reason: 'invalid raw Basis source shape' });
138
+ }
139
+ return {
140
+ ok: true,
141
+ value: {
142
+ colorSpace: meta.colorSpace,
143
+ profile,
144
+ width: file.getImageWidth(0, 0),
145
+ height: file.getImageHeight(0, 0),
146
+ levelCount: file.getNumLevels(0),
147
+ imageCount: file.getNumImages(),
148
+ },
149
+ };
150
+ }
151
+
152
+ /**
153
+ * Map a selected `GPUTextureFormat` to a basis `transcoder_texture_format` enum
154
+ * value. sRGB variants share the transcode target (sRGB is a view/DFD concern,
155
+ * not a distinct transcode format). Uncompressed single/dual-channel fallbacks
156
+ * (r8/rg8) route through RGBA32 -- basis has no r8/rg8 uncompressed target; the
157
+ * runtime upload path (M5) reconciles channel selection.
158
+ */
159
+ function basisTargetFor(mod: BasisTranscoderModule, format: GPUTextureFormat): number | null {
160
+ const e = mod.transcoder_texture_format;
161
+ switch (format) {
162
+ case 'bc7-rgba-unorm':
163
+ case 'bc7-rgba-unorm-srgb':
164
+ return e.cTFBC7_RGBA.value;
165
+ case 'bc5-rg-unorm':
166
+ return e.cTFBC5_RG.value;
167
+ case 'bc4-r-unorm':
168
+ return e.cTFBC4_R.value;
169
+ case 'bc6h-rgb-ufloat':
170
+ return e.cTFBC6H.value;
171
+ case 'etc2-rgba8unorm':
172
+ case 'etc2-rgba8unorm-srgb':
173
+ return e.cTFETC2_RGBA.value;
174
+ case 'astc-4x4-unorm':
175
+ case 'astc-4x4-unorm-srgb':
176
+ return e.cTFASTC_4x4_RGBA.value;
177
+ case 'rgba16float':
178
+ return e.cTFRGBA_HALF.value;
179
+ case 'rgba8unorm':
180
+ case 'rgba8unorm-srgb':
181
+ case 'rg8unorm':
182
+ case 'r8unorm':
183
+ return e.cTFRGBA32.value;
184
+ default:
185
+ return null;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Transcode every mip level of a parsed KTX2 container into `targetFormat`.
191
+ *
192
+ * Uses `parsed.rawBytes` as the basis KTX2File source. Returns one entry per mip
193
+ * level (base first). On any basis-side failure returns a `transcode-failed`
194
+ * CodecError carrying the source DFD color model and the target format.
195
+ */
196
+ export async function transcodeKtx2(
197
+ parsed: Ktx2Parsed,
198
+ targetFormat: GPUTextureFormat,
199
+ ): Promise<CodecResult<TranscodedTexture>> {
200
+ let mod: BasisTranscoderModule;
201
+ try {
202
+ mod = await initBasisTranscoder();
203
+ } catch {
204
+ return codecError('codec-init-failed', { stage: 'dynamic-import-basis-transcoder' });
205
+ }
206
+
207
+ const targetEnum = basisTargetFor(mod, targetFormat);
208
+ if (targetEnum === null) {
209
+ return codecError('transcode-failed', {
210
+ sourceFormat: `dfd-model-${parsed.dfd?.colorModel ?? 'unknown'}`,
211
+ targetFormat,
212
+ });
213
+ }
214
+
215
+ const file = new mod.KTX2File(parsed.rawBytes);
216
+ try {
217
+ if (!file.isValid()) {
218
+ return codecError('transcode-failed', {
219
+ sourceFormat: 'invalid-ktx2-file',
220
+ targetFormat,
221
+ });
222
+ }
223
+ if (file.startTranscoding() === 0) {
224
+ return codecError('transcode-failed', {
225
+ sourceFormat: 'start-transcoding-failed',
226
+ targetFormat,
227
+ });
228
+ }
229
+
230
+ const levels = file.getLevels();
231
+ const mips: TranscodedMip[] = [];
232
+ for (let level = 0; level < levels; level++) {
233
+ const info = file.getImageLevelInfo(level, 0, 0);
234
+ const size = file.getImageTranscodedSizeInBytes(level, 0, 0, targetEnum);
235
+ const dst = new Uint8Array(size);
236
+ const ok = file.transcodeImage(dst, level, 0, 0, targetEnum, 0, -1, -1);
237
+ if (ok === 0) {
238
+ return codecError('transcode-failed', {
239
+ sourceFormat: `dfd-model-${parsed.dfd?.colorModel ?? 'unknown'}-level-${level}`,
240
+ targetFormat,
241
+ });
242
+ }
243
+ mips.push({ level, width: info.origWidth, height: info.origHeight, data: dst });
244
+ }
245
+
246
+ return {
247
+ ok: true,
248
+ value: {
249
+ format: targetFormat,
250
+ width: file.getWidth(),
251
+ height: file.getHeight(),
252
+ mips,
253
+ },
254
+ };
255
+ } finally {
256
+ file.close();
257
+ }
258
+ }
259
+
260
+ /** Transcode a raw `.basis` file; source color remains a Meta author fact. */
261
+ export async function transcodeBasis(
262
+ bytes: Uint8Array,
263
+ targetFormat: GPUTextureFormat,
264
+ ): Promise<CodecResult<TranscodedBasisTexture>> {
265
+ let mod: BasisTranscoderModule;
266
+ try {
267
+ mod = await initBasisTranscoder();
268
+ } catch {
269
+ return codecError('codec-init-failed', { stage: 'dynamic-import-basis-transcoder' });
270
+ }
271
+ const targetEnum = basisTargetFor(mod, targetFormat);
272
+ if (targetEnum === null || mod.BasisFile === undefined) {
273
+ return codecError('transcode-failed', {
274
+ sourceFormat: 'raw-basis',
275
+ targetFormat,
276
+ });
277
+ }
278
+ const file = new mod.BasisFile(bytes);
279
+ try {
280
+ if (file.getNumImages() <= 0 || file.getNumLevels(0) <= 0) {
281
+ return codecError('transcode-failed', { sourceFormat: 'invalid-basis-file', targetFormat });
282
+ }
283
+ if (file.startTranscoding() === 0) {
284
+ return codecError('transcode-failed', {
285
+ sourceFormat: 'start-transcoding-failed',
286
+ targetFormat,
287
+ });
288
+ }
289
+ const mips: TranscodedMip[] = [];
290
+ for (let level = 0; level < file.getNumLevels(0); level++) {
291
+ const width = file.getImageWidth(0, level);
292
+ const height = file.getImageHeight(0, level);
293
+ const size = file.getImageTranscodedSizeInBytes(0, level, targetEnum);
294
+ const data = new Uint8Array(size);
295
+ if (file.transcodeImage(data, 0, level, targetEnum, 0) === 0) {
296
+ return codecError('transcode-failed', {
297
+ sourceFormat: `raw-basis-level-${level}`,
298
+ targetFormat,
299
+ });
300
+ }
301
+ mips.push({ level, width, height, data });
302
+ }
303
+ return {
304
+ ok: true,
305
+ value: {
306
+ format: targetFormat,
307
+ width: mips[0]?.width ?? 0,
308
+ height: mips[0]?.height ?? 0,
309
+ mips,
310
+ },
311
+ };
312
+ } finally {
313
+ file.close();
314
+ }
315
+ }