@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
@@ -0,0 +1,117 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseKtx2 } from '@forgeax/engine-codec';
4
+ import { beforeAll, describe, expect, it } from 'vitest';
5
+ import type { BasisEncoderModule, BasisModuleFactory } from '../wasm/basis-types.js';
6
+
7
+ // @perf-budget-skip: intentional large-source WASM encoder boundary gate.
8
+ /**
9
+ * Encoder source-pixel ceiling gate (CI-red fix, human decision).
10
+ *
11
+ * The wasm32 Basis encoder caps total source texels at
12
+ * BASISU_ENCODER_MAX_SOURCE_IMAGE_PIXELS. The vendored default was 4 Mpx (higher
13
+ * limit 12 Mpx), below a 4096x4096 = 16.78 Mpx game texture, so encode() returned
14
+ * 0 bytes -> ktx2-encode-failed. build-wasm.mjs now raises both non-wasm64 limits
15
+ * to 16 Mpx (1024*1024*16) at build time. This test proves the raised WASM really
16
+ * encodes a 4096x4096 source: ETC1S encode returns a non-empty KTX2 that parseKtx2
17
+ * accepts.
18
+ *
19
+ * pkg/ is a gitignored emcc build artifact (AC-12); the case skips when it is not
20
+ * built (contributor without emsdk) and runs in the CI build-artifacts job.
21
+ */
22
+
23
+ const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
24
+ const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
25
+ const pkgBuilt =
26
+ existsSync(fileURLToPath(ENCODER_GLUE)) && existsSync(fileURLToPath(TRANSCODER_GLUE));
27
+
28
+ /** KTX2 BasisLZ supercompression scheme (ETC1S rides scheme=1). */
29
+ const SCHEME_BASISLZ = 1;
30
+
31
+ async function loadEncoder(): Promise<BasisEncoderModule> {
32
+ const factory = (
33
+ (await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
34
+ default: BasisModuleFactory<BasisEncoderModule>;
35
+ }
36
+ ).default;
37
+ const mod = await factory({
38
+ locateFile: () => new URL('../../pkg/encode/basis_encoder.wasm', import.meta.url).href,
39
+ });
40
+ mod.initializeBasis();
41
+ return mod;
42
+ }
43
+
44
+ /** A deterministic WxH RGBA image (opaque, cheap to build for large sizes). */
45
+ function makeRgba(w: number, h: number): Uint8Array {
46
+ const img = new Uint8Array(w * h * 4);
47
+ for (let i = 0; i < w * h; i++) {
48
+ img[i * 4] = (i * 7) & 255;
49
+ img[i * 4 + 1] = (i * 13) & 255;
50
+ img[i * 4 + 2] = (i * 29) & 255;
51
+ img[i * 4 + 3] = 255;
52
+ }
53
+ return img;
54
+ }
55
+
56
+ function encodeEtc1s(mod: BasisEncoderModule, img: Uint8Array, w: number, h: number): number {
57
+ const e = new mod.BasisEncoder();
58
+ try {
59
+ e.controlThreading(false, 0);
60
+ e.setSliceSourceImage(0, img, w, h, 0);
61
+ e.setCreateKTX2File(true);
62
+ e.setFormatMode(mod.basis_tex_format.cETC1S.value);
63
+ e.setQualityLevel(128);
64
+ e.setETC1SCompressionLevel(0);
65
+ e.setPerceptual(true);
66
+ e.setMipGen(false);
67
+ // A 4096x4096 ETC1S KTX2 stays well under 32 MiB; give it headroom.
68
+ const out = new Uint8Array(1 << 25);
69
+ const n = e.encode(out);
70
+ if (n <= 0) return 0;
71
+ return n;
72
+ } finally {
73
+ e.delete();
74
+ }
75
+ }
76
+
77
+ describe.skipIf(!pkgBuilt)('encoder source-pixel ceiling raised to 4096^2', () => {
78
+ let mod: BasisEncoderModule;
79
+
80
+ beforeAll(async () => {
81
+ mod = await loadEncoder();
82
+ });
83
+
84
+ it('encodes a 4096x4096 (16.78 Mpx) source to a non-empty ETC1S KTX2', async () => {
85
+ const w = 4096;
86
+ const h = 4096;
87
+ const img = makeRgba(w, h);
88
+ const out = new Uint8Array(1 << 25);
89
+ const e = new mod.BasisEncoder();
90
+ let n = 0;
91
+ try {
92
+ e.controlThreading(false, 0);
93
+ e.setSliceSourceImage(0, img, w, h, 0);
94
+ e.setCreateKTX2File(true);
95
+ e.setFormatMode(mod.basis_tex_format.cETC1S.value);
96
+ e.setQualityLevel(128);
97
+ e.setETC1SCompressionLevel(0);
98
+ e.setPerceptual(true);
99
+ e.setMipGen(false);
100
+ n = e.encode(out);
101
+ } finally {
102
+ e.delete();
103
+ }
104
+ // The whole point: this returns 0 with the un-patched 4/12 Mpx limit.
105
+ expect(n).toBeGreaterThan(0);
106
+ const parsed = await parseKtx2(out.slice(0, n));
107
+ expect(parsed.ok).toBe(true);
108
+ if (!parsed.ok) return;
109
+ expect(parsed.value.header.supercompressionScheme).toBe(SCHEME_BASISLZ);
110
+ expect(parsed.value.header.pixelWidth).toBe(w);
111
+ expect(parsed.value.header.pixelHeight).toBe(h);
112
+ }, 120_000);
113
+
114
+ it('a small source still encodes (sanity: raised limit did not break the low path)', () => {
115
+ expect(encodeEtc1s(mod, makeRgba(16, 16), 16, 16)).toBeGreaterThan(0);
116
+ });
117
+ });
@@ -0,0 +1,239 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseKtx2, transcodeKtx2 } from '@forgeax/engine-codec';
4
+ import { beforeAll, describe, expect, it } from 'vitest';
5
+ import type { BasisEncoderModule, BasisModuleFactory } from '../wasm/basis-types.js';
6
+
7
+ /**
8
+ * M3 encode round-trip + determinism gate (w15).
9
+ *
10
+ * Exercises the whole offline-encode arm end to end against the M1 encoder WASM
11
+ * and the M2 transcoder:
12
+ *
13
+ * (a) AC-01 round-trip -- encode an RGB/RGBA test image into a Basis KTX2
14
+ * container (ETC1S / UASTC-LDR), parse it with parseKtx2, and transcode
15
+ * the levels with transcodeKtx2. parseKtx2 must NOT reject the ETC1S
16
+ * payload with `ktx2-unsupported-scheme` (scheme=1 gate, M2 w13).
17
+ * (b) AC-02 determinism -- the same input bytes encoded twice are byte-equal
18
+ * (fixed encoder params, no timestamp / no random dither; R-11).
19
+ * (c) DFD model -- ETC1S / UASTC-LDR / UASTC-HDR produce distinct DFD color
20
+ * models (163 / 166 / 167) empirically verified against the pinned
21
+ * encoder (D-2 / D-3).
22
+ *
23
+ * This file uses the raw M1 encoder WASM directly (like wasm-init-smoke); it
24
+ * does not exercise the w17 basisEncode wrapper (that is w17's own AC). It does
25
+ * NOT test compression ratio (M2 w9) or image quality (M6).
26
+ *
27
+ * pkg/ is a gitignored emcc build artifact (AC-12). When it is not built
28
+ * (contributor without emsdk) the WASM cases skip; CI builds it in the
29
+ * build-artifacts job so the gate runs there.
30
+ */
31
+
32
+ const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
33
+ const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
34
+ const pkgBuilt =
35
+ existsSync(fileURLToPath(ENCODER_GLUE)) && existsSync(fileURLToPath(TRANSCODER_GLUE));
36
+
37
+ /** DFD color models the pinned basis encoder writes per delivery encoding (D-3). */
38
+ const DFD_MODEL_ETC1S = 163;
39
+ const DFD_MODEL_UASTC_LDR = 166;
40
+ const DFD_MODEL_UASTC_HDR = 167;
41
+
42
+ /** KTX2 supercompression schemes (spec section 2.9). */
43
+ const SCHEME_NONE = 0;
44
+ const SCHEME_BASISLZ = 1;
45
+ const SCHEME_ZSTD = 2;
46
+
47
+ async function loadEncoder(): Promise<BasisEncoderModule> {
48
+ const factory = (
49
+ (await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
50
+ default: BasisModuleFactory<BasisEncoderModule>;
51
+ }
52
+ ).default;
53
+ const mod = await factory({
54
+ locateFile: () => new URL('../../pkg/encode/basis_encoder.wasm', import.meta.url).href,
55
+ });
56
+ mod.initializeBasis();
57
+ return mod;
58
+ }
59
+
60
+ /** A deterministic WxH RGBA image. */
61
+ function makeRgba(w: number, h: number): Uint8Array {
62
+ const img = new Uint8Array(w * h * 4);
63
+ for (let i = 0; i < w * h; i++) {
64
+ img[i * 4] = (i * 7) & 255;
65
+ img[i * 4 + 1] = (i * 13) & 255;
66
+ img[i * 4 + 2] = (i * 29) & 255;
67
+ img[i * 4 + 3] = 255;
68
+ }
69
+ return img;
70
+ }
71
+
72
+ /** A deterministic WxH RGBA half-float image (rgba16float bytes) for HDR encode. */
73
+ function makeHdrF16(w: number, h: number): Uint8Array {
74
+ // Half-float is IEEE 754 binary16; build the pattern directly so this helper
75
+ // stays free of a runtime math dependency (the values are small, exactly
76
+ // representable magnitudes).
77
+ const out = new Uint16Array(w * h * 4);
78
+ const toF16 = (v: number): number => f32ToF16(v);
79
+ for (let i = 0; i < w * h; i++) {
80
+ out[i * 4] = toF16((i % 8) / 8);
81
+ out[i * 4 + 1] = toF16((i % 4) / 4);
82
+ out[i * 4 + 2] = toF16(0.5);
83
+ out[i * 4 + 3] = toF16(1);
84
+ }
85
+ return new Uint8Array(out.buffer);
86
+ }
87
+
88
+ /** Minimal f32 -> f16 bit encoder (round-to-nearest-even not needed for exact values). */
89
+ function f32ToF16(value: number): number {
90
+ const f32 = new Float32Array(1);
91
+ const u32 = new Uint32Array(f32.buffer);
92
+ f32[0] = value;
93
+ const x = u32[0] ?? 0;
94
+ const sign = (x >>> 16) & 0x8000;
95
+ const exp = (x >>> 23) & 0xff;
96
+ const mant = x & 0x7fffff;
97
+ if (exp === 0) return sign;
98
+ const e = exp - 127 + 15;
99
+ if (e <= 0) return sign;
100
+ if (e >= 0x1f) return sign | 0x7c00;
101
+ return sign | (e << 10) | (mant >> 13);
102
+ }
103
+
104
+ function encodeEtc1s(mod: BasisEncoderModule, img: Uint8Array, w: number, h: number): Uint8Array {
105
+ const e = new mod.BasisEncoder();
106
+ e.setSliceSourceImage(0, img, w, h, 0);
107
+ e.setCreateKTX2File(true);
108
+ e.setFormatMode(mod.basis_tex_format.cETC1S.value);
109
+ e.setQualityLevel(128);
110
+ e.setPerceptual(true);
111
+ e.setMipGen(false);
112
+ const out = new Uint8Array(1 << 20);
113
+ const n = e.encode(out);
114
+ e.delete();
115
+ if (n <= 0) throw new Error('ETC1S encode failed');
116
+ return out.slice(0, n);
117
+ }
118
+
119
+ function encodeUastcLdr(
120
+ mod: BasisEncoderModule,
121
+ img: Uint8Array,
122
+ w: number,
123
+ h: number,
124
+ ): Uint8Array {
125
+ const e = new mod.BasisEncoder();
126
+ e.setSliceSourceImage(0, img, w, h, 0);
127
+ e.setCreateKTX2File(true);
128
+ e.setKTX2UASTCSupercompression(true);
129
+ e.setFormatMode(mod.basis_tex_format.cUASTC_LDR_4x4.value);
130
+ e.setPerceptual(true);
131
+ e.setMipGen(false);
132
+ const out = new Uint8Array(1 << 20);
133
+ const n = e.encode(out);
134
+ e.delete();
135
+ if (n <= 0) throw new Error('UASTC-LDR encode failed');
136
+ return out.slice(0, n);
137
+ }
138
+
139
+ function encodeUastcHdr(
140
+ mod: BasisEncoderModule,
141
+ img: Uint8Array,
142
+ w: number,
143
+ h: number,
144
+ ): Uint8Array {
145
+ const e = new mod.BasisEncoder();
146
+ // img_type 0 = cHITRGBAHalfFloat (rgba16float source).
147
+ e.setSliceSourceImageHDR(0, img, w, h, 0, false, 1.0);
148
+ e.setCreateKTX2File(true);
149
+ e.setFormatMode(mod.basis_tex_format.cUASTC_HDR_4x4.value);
150
+ e.setMipGen(false);
151
+ const out = new Uint8Array(1 << 20);
152
+ const n = e.encode(out);
153
+ e.delete();
154
+ if (n <= 0) throw new Error('UASTC-HDR encode failed');
155
+ return out.slice(0, n);
156
+ }
157
+
158
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
159
+ if (a.length !== b.length) return false;
160
+ for (let i = 0; i < a.length; i++) {
161
+ if (a[i] !== b[i]) return false;
162
+ }
163
+ return true;
164
+ }
165
+
166
+ describe.skipIf(!pkgBuilt)('encode round-trip + determinism (M3 w15)', () => {
167
+ let mod: BasisEncoderModule;
168
+
169
+ beforeAll(async () => {
170
+ mod = await loadEncoder();
171
+ });
172
+
173
+ it('AC-01: ETC1S encode -> parseKtx2 (scheme=1 accepted) -> transcode round-trip', async () => {
174
+ const ktx2 = encodeEtc1s(mod, makeRgba(16, 16), 16, 16);
175
+ const parsed = await parseKtx2(ktx2);
176
+ expect(parsed.ok).toBe(true);
177
+ if (!parsed.ok) return;
178
+ // scheme=1 (BasisLZ) is opened by the M2 gate; must not be rejected.
179
+ expect(parsed.value.header.supercompressionScheme).toBe(SCHEME_BASISLZ);
180
+ expect(parsed.value.dfd?.colorModel).toBe(DFD_MODEL_ETC1S);
181
+
182
+ const transcoded = await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
183
+ expect(transcoded.ok).toBe(true);
184
+ if (!transcoded.ok) return;
185
+ expect(transcoded.value.format).toBe('bc7-rgba-unorm');
186
+ expect(transcoded.value.mips.length).toBeGreaterThan(0);
187
+ expect(transcoded.value.mips[0]?.data.length).toBeGreaterThan(0);
188
+ });
189
+
190
+ it('AC-01: UASTC-LDR encode -> parseKtx2 -> transcode round-trip', async () => {
191
+ const ktx2 = encodeUastcLdr(mod, makeRgba(16, 16), 16, 16);
192
+ const parsed = await parseKtx2(ktx2);
193
+ expect(parsed.ok).toBe(true);
194
+ if (!parsed.ok) return;
195
+ // UASTC-LDR with supercompression rides scheme=2 (Zstandard), also accepted.
196
+ expect(parsed.value.header.supercompressionScheme).toBe(SCHEME_ZSTD);
197
+ expect(parsed.value.dfd?.colorModel).toBe(DFD_MODEL_UASTC_LDR);
198
+
199
+ const transcoded = await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
200
+ expect(transcoded.ok).toBe(true);
201
+ if (!transcoded.ok) return;
202
+ expect(transcoded.value.format).toBe('bc7-rgba-unorm');
203
+ expect(transcoded.value.mips[0]?.data.length).toBeGreaterThan(0);
204
+ });
205
+
206
+ it('AC-02: double encode is byte-equal (ETC1S)', () => {
207
+ const img = makeRgba(16, 16);
208
+ const a = encodeEtc1s(mod, img, 16, 16);
209
+ const b = encodeEtc1s(mod, img, 16, 16);
210
+ expect(bytesEqual(a, b)).toBe(true);
211
+ });
212
+
213
+ it('AC-02: double encode is byte-equal (UASTC-LDR)', () => {
214
+ const img = makeRgba(16, 16);
215
+ const a = encodeUastcLdr(mod, img, 16, 16);
216
+ const b = encodeUastcLdr(mod, img, 16, 16);
217
+ expect(bytesEqual(a, b)).toBe(true);
218
+ });
219
+
220
+ it('DFD model differs per delivery encoding (163 / 166 / 167)', async () => {
221
+ const etc1s = await parseKtx2(encodeEtc1s(mod, makeRgba(8, 8), 8, 8));
222
+ const uastcLdr = await parseKtx2(encodeUastcLdr(mod, makeRgba(8, 8), 8, 8));
223
+ const uastcHdr = await parseKtx2(encodeUastcHdr(mod, makeHdrF16(8, 8), 8, 8));
224
+ expect(etc1s.ok && uastcLdr.ok && uastcHdr.ok).toBe(true);
225
+ if (!(etc1s.ok && uastcLdr.ok && uastcHdr.ok)) return;
226
+ expect(etc1s.value.dfd?.colorModel).toBe(DFD_MODEL_ETC1S);
227
+ expect(uastcLdr.value.dfd?.colorModel).toBe(DFD_MODEL_UASTC_LDR);
228
+ expect(uastcHdr.value.dfd?.colorModel).toBe(DFD_MODEL_UASTC_HDR);
229
+ // The three models are pairwise distinct.
230
+ const models = new Set([
231
+ etc1s.value.dfd?.colorModel,
232
+ uastcLdr.value.dfd?.colorModel,
233
+ uastcHdr.value.dfd?.colorModel,
234
+ ]);
235
+ expect(models.size).toBe(3);
236
+ // HDR container carries no supercompression by default.
237
+ expect(uastcHdr.value.header.supercompressionScheme).toBe(SCHEME_NONE);
238
+ });
239
+ });
@@ -0,0 +1,241 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import {
4
+ inspectBasisSource,
5
+ ktx2ColorSpace,
6
+ parseKtx2,
7
+ selectTranscodeTarget,
8
+ type TranscodeCaps,
9
+ } from '@forgeax/engine-codec';
10
+ import { basisEncode } from '@forgeax/engine-codec/encode';
11
+ import { describe, expect, it } from 'vitest';
12
+ import type { Ktx2Parsed } from '../ktx2.js';
13
+ import type { BasisEncoderModule, BasisFile, BasisModuleFactory } from '../wasm/basis-types.js';
14
+
15
+ const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
16
+ const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
17
+ const pkgBuilt =
18
+ existsSync(fileURLToPath(ENCODER_GLUE)) && existsSync(fileURLToPath(TRANSCODER_GLUE));
19
+
20
+ function parsedWithTransfer(transferFunction: number): Ktx2Parsed {
21
+ return {
22
+ header: {
23
+ vkFormat: 0,
24
+ typeSize: 1,
25
+ pixelWidth: 4,
26
+ pixelHeight: 4,
27
+ pixelDepth: 0,
28
+ layerCount: 0,
29
+ faceCount: 1,
30
+ levelCount: 1,
31
+ supercompressionScheme: 1,
32
+ },
33
+ index: {
34
+ dfdByteOffset: 0,
35
+ dfdByteLength: 0,
36
+ kvdByteOffset: 0,
37
+ kvdByteLength: 0,
38
+ sgdByteOffset: 0,
39
+ sgdByteLength: 0,
40
+ },
41
+ levelIndex: [{ byteOffset: 0, byteLength: 0, uncompressedByteLength: 0 }],
42
+ dfd: {
43
+ totalSize: 44,
44
+ vendorId: 0,
45
+ descriptorType: 0,
46
+ versionNumber: 2,
47
+ descriptorBlockSize: 40,
48
+ colorModel: 166,
49
+ colorPrimaries: 1,
50
+ transferFunction,
51
+ flags: 0,
52
+ texelBlockDimension: [3, 3, 0, 0],
53
+ bytesPlane: [16, 0, 0, 0, 0, 0, 0, 0],
54
+ samples: [],
55
+ },
56
+ kvEntries: [],
57
+ sgd: null,
58
+ rawBytes: new Uint8Array(),
59
+ };
60
+ }
61
+
62
+ function fakeBasisFile(): BasisFile {
63
+ return {
64
+ close: () => undefined,
65
+ getNumImages: () => 1,
66
+ getNumLevels: () => 2,
67
+ getImageWidth: () => 8,
68
+ getImageHeight: () => 4,
69
+ getBasisTexFormat: () => 0,
70
+ getHasAlpha: () => 1,
71
+ startTranscoding: () => 1,
72
+ getImageTranscodedSizeInBytes: () => 16,
73
+ transcodeImage: () => 1,
74
+ };
75
+ }
76
+
77
+ async function loadEncoder(): Promise<BasisEncoderModule> {
78
+ const factory = (
79
+ (await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
80
+ default: BasisModuleFactory<BasisEncoderModule>;
81
+ }
82
+ ).default;
83
+ const mod = await factory({
84
+ locateFile: () => new URL('../../pkg/encode/basis_encoder.wasm', import.meta.url).href,
85
+ });
86
+ mod.initializeBasis();
87
+ return mod;
88
+ }
89
+
90
+ async function realKtx2(mode: 'etc1s' | 'uastc-ldr' | 'uastc-hdr'): Promise<Uint8Array> {
91
+ const pixels =
92
+ mode === 'uastc-hdr'
93
+ ? new Uint8Array(4 * 4 * 8)
94
+ : new Uint8Array(4 * 4 * 4).map((_, index) => (index * 17) & 0xff);
95
+ const result = await basisEncode(pixels, {
96
+ mode,
97
+ width: 4,
98
+ height: 4,
99
+ srgb: mode !== 'uastc-hdr',
100
+ perceptual: mode !== 'uastc-hdr',
101
+ uastcSupercompression: mode === 'uastc-ldr',
102
+ mipGen: false,
103
+ });
104
+ if (!result.ok) throw new Error(result.error.code);
105
+ return result.value;
106
+ }
107
+
108
+ async function realBasis(): Promise<Uint8Array> {
109
+ const mod = await loadEncoder();
110
+ const encoder = new mod.BasisEncoder();
111
+ try {
112
+ encoder.setSliceSourceImage(0, new Uint8Array(4 * 4 * 4).fill(127), 4, 4, 0);
113
+ encoder.setCreateKTX2File(false);
114
+ encoder.setFormatMode(mod.basis_tex_format.cUASTC_LDR_4x4.value);
115
+ encoder.setPerceptual(false);
116
+ encoder.setMipGen(false);
117
+ const bytes = new Uint8Array(1 << 20);
118
+ const length = encoder.encode(bytes);
119
+ if (length <= 0) throw new Error('raw Basis encode failed');
120
+ return bytes.slice(0, length);
121
+ } finally {
122
+ encoder.delete();
123
+ }
124
+ }
125
+
126
+ const capabilityCells = [
127
+ [{ bc: false, etc2: false, astc: false }, 'rgba'],
128
+ [{ bc: false, etc2: false, astc: true }, 'astc'],
129
+ [{ bc: true, etc2: false, astc: false }, 'bc'],
130
+ [{ bc: false, etc2: true, astc: false }, 'etc'],
131
+ [{ bc: true, etc2: true, astc: true }, 'all'],
132
+ ] as const;
133
+
134
+ function expectedLdrTarget(srgb: boolean, arm: string): string {
135
+ const suffix = srgb ? '-srgb' : '';
136
+ switch (arm) {
137
+ case 'bc':
138
+ case 'all':
139
+ return `bc7-rgba-unorm${suffix}`;
140
+ case 'astc':
141
+ return `astc-4x4-unorm${suffix}`;
142
+ case 'etc':
143
+ return `etc2-rgba8unorm${suffix}`;
144
+ default:
145
+ return `rgba8unorm${suffix}`;
146
+ }
147
+ }
148
+
149
+ function expectedHdrTarget(arm: string): string {
150
+ return arm === 'bc' || arm === 'all' ? 'bc6h-rgb-ufloat' : 'rgba16float';
151
+ }
152
+
153
+ describe('KTX2/Basis capability matrix', () => {
154
+ it('projects DFD transfer functions without a duplicate source color fact', async () => {
155
+ expect(ktx2ColorSpace(parsedWithTransfer(1))).toBe('linear');
156
+ expect(ktx2ColorSpace(parsedWithTransfer(2))).toBe('srgb');
157
+ expect(ktx2ColorSpace(parsedWithTransfer(0))).toBeUndefined();
158
+ });
159
+
160
+ it('inspects raw Basis dimensions and keeps Meta color-space provenance', () => {
161
+ const result = inspectBasisSource(fakeBasisFile(), { colorSpace: 'srgb' }, 'etc1s');
162
+ expect(result).toEqual({
163
+ ok: true,
164
+ value: {
165
+ colorSpace: 'srgb',
166
+ profile: 'etc1s',
167
+ width: 8,
168
+ height: 4,
169
+ levelCount: 2,
170
+ imageCount: 1,
171
+ },
172
+ });
173
+ });
174
+
175
+ it('rejects raw Basis inspection without an authored color-space fact', () => {
176
+ const result = inspectBasisSource(fakeBasisFile(), {}, 'uastc-ldr');
177
+ expect(result.ok).toBe(false);
178
+ if (result.ok) return;
179
+ expect(result.error.code).toBe('ktx2-parse-failed');
180
+ expect('reason' in result.error.detail).toBe(true);
181
+ if ('reason' in result.error.detail)
182
+ expect(result.error.detail.reason).toContain('Meta.colorSpace');
183
+ });
184
+
185
+ it.each([
186
+ [{ bc: true, etc2: true, astc: true }, 'bc7-rgba-unorm-srgb'],
187
+ [{ bc: false, etc2: false, astc: true }, 'astc-4x4-unorm-srgb'],
188
+ [{ bc: false, etc2: true, astc: false }, 'etc2-rgba8unorm-srgb'],
189
+ [{ bc: false, etc2: false, astc: false }, 'rgba8unorm-srgb'],
190
+ ] as const)('selects a deterministic LDR target or explicit RGBA fallback', (caps, target) => {
191
+ expect(
192
+ selectTranscodeTarget(
193
+ { model: 'etc1s', srgb: true, channels: 'rgba' },
194
+ caps as TranscodeCaps,
195
+ ),
196
+ ).toBe(target);
197
+ });
198
+
199
+ it.skipIf(!pkgBuilt)('covers every real input profile across every capability cell', async () => {
200
+ const inputs = [
201
+ { id: 'ktx2-etc1s', bytes: await realKtx2('etc1s'), model: 'etc1s', srgb: true },
202
+ { id: 'ktx2-uastc-ldr', bytes: await realKtx2('uastc-ldr'), model: 'uastc-ldr', srgb: true },
203
+ { id: 'ktx2-uastc-hdr', bytes: await realKtx2('uastc-hdr'), model: 'uastc-hdr', srgb: false },
204
+ ] as const;
205
+
206
+ for (const input of inputs) {
207
+ const parsed = await parseKtx2(input.bytes);
208
+ expect(parsed.ok, input.id).toBe(true);
209
+ if (!parsed.ok) continue;
210
+ expect(ktx2ColorSpace(parsed.value), input.id).toBe(input.srgb ? 'srgb' : 'linear');
211
+ for (const [caps, arm] of capabilityCells) {
212
+ const target = selectTranscodeTarget(
213
+ { model: input.model, srgb: input.srgb, channels: 'rgba' },
214
+ caps,
215
+ );
216
+ expect(target, `${input.id}:${arm}`).toBe(
217
+ input.model === 'uastc-hdr' ? expectedHdrTarget(arm) : expectedLdrTarget(input.srgb, arm),
218
+ );
219
+ }
220
+ }
221
+
222
+ const raw = await realBasis();
223
+ const transcoder = await (await import(/* @vite-ignore */ TRANSCODER_GLUE.href)).default({
224
+ locateFile: () => new URL('../../pkg/basis_transcoder.wasm', import.meta.url).href,
225
+ });
226
+ transcoder.initializeBasis();
227
+ const file = new transcoder.BasisFile(raw);
228
+ try {
229
+ const inspected = inspectBasisSource(file, { colorSpace: 'srgb' }, 'uastc-ldr');
230
+ expect(inspected.ok, 'raw-basis').toBe(true);
231
+ for (const [caps, arm] of capabilityCells) {
232
+ expect(
233
+ selectTranscodeTarget({ model: 'uastc-ldr', srgb: true, channels: 'rgba' }, caps),
234
+ `raw-basis:${arm}`,
235
+ ).toBe(expectedLdrTarget(true, arm));
236
+ }
237
+ } finally {
238
+ file.close();
239
+ }
240
+ });
241
+ });