@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,97 @@
1
+ #!/usr/bin/env node
2
+ // fetch-wasm.mjs — Download the pre-built basis-wasm pkg/ bundle from GitHub
3
+ // Releases so no-Emscripten consumers (Studio setup, editor standalone, CI
4
+ // typecheck) can resolve @forgeax/engine-codec without an emcc toolchain and,
5
+ // crucially, without the multi-minute -O3 basis_universal encoder compile.
6
+ //
7
+ // Like @forgeax/engine-wgpu-wasm, the codec pkg/ carries multiple files (two
8
+ // emscripten glue .mjs + two .wasm, nested one level under encode/) that cannot
9
+ // be regenerated from a single .wasm, so the release asset is the WHOLE pkg/
10
+ // packed as a content-keyed .tar.gz; this script downloads and extracts it into
11
+ // packages/codec/pkg/.
12
+ //
13
+ // The content key (see content-key.mjs) is the SSOT shared with the CI publish
14
+ // job, so producer and consumer always agree on the asset name.
15
+ //
16
+ // Usage:
17
+ // pnpm -F @forgeax/engine-codec fetch-wasm
18
+ // node scripts/fetch-wasm.mjs
19
+ //
20
+ // Environment:
21
+ // GITHUB_TOKEN — optional Bearer token for private-repo auth. Falls back to
22
+ // `gh auth token`. Anonymous requests work for public repos.
23
+ // FORGEAX_SKIP_CODEC_WASM_FETCH — if set, exit 0 immediately (used by the
24
+ // non-fatal opt-out path when the toolchain owner wants to build locally).
25
+ //
26
+ // Exit codes: 0 on success (or skip); 1 on any FetchError. Callers that want a
27
+ // build fallback treat a non-zero exit as "release unavailable, compile via
28
+ // build:wasm" (graceful degradation: architecture-principles #9).
29
+
30
+ import { rm, readdir } from 'node:fs/promises';
31
+ import { join } from 'node:path';
32
+
33
+ import { FetchError, getGitOrigin, getReleaseAsset, downloadAsset, extractTarball } from '../../../scripts/lib/fetch-wasm-lib.mjs';
34
+ import { PKG_ROOT, RELEASE_TAG, resolveAsset } from './content-key.mjs';
35
+
36
+ const PKG_DIR = join(PKG_ROOT, 'pkg');
37
+ const TMP_TARBALL = join(PKG_ROOT, '.pkg-fetch.tar.gz');
38
+
39
+ const PKG_LABEL = 'basis-wasm';
40
+ const BUILD_HINT =
41
+ 'compile locally with `pnpm -F @forgeax/engine-codec build:wasm` (needs the Emscripten emcc toolchain)';
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Main
45
+ // ---------------------------------------------------------------------------
46
+
47
+ async function main() {
48
+ if (process.env.FORGEAX_SKIP_CODEC_WASM_FETCH) {
49
+ console.log('fetch-wasm: FORGEAX_SKIP_CODEC_WASM_FETCH set — skipping.');
50
+ return;
51
+ }
52
+
53
+ console.log('Resolving git origin...');
54
+ const { owner, repo } = getGitOrigin();
55
+ console.log(` origin: ${owner}/${repo}`);
56
+
57
+ console.log('Computing content key (fetch-basis.mjs + build-wasm.mjs)...');
58
+ const { sha256, assetName } = await resolveAsset();
59
+ console.log(` content SHA256: ${sha256}`);
60
+ console.log(` asset name: ${assetName}`);
61
+
62
+ console.log(`Fetching release tag "${RELEASE_TAG}" from ${owner}/${repo}...`);
63
+ const asset = await getReleaseAsset(owner, repo, RELEASE_TAG, assetName, {
64
+ pkgLabel: PKG_LABEL,
65
+ buildHint: BUILD_HINT,
66
+ missingAssetHint:
67
+ 'The codec build scripts have uncommitted changes, or the release for this content is not yet published.',
68
+ });
69
+
70
+ console.log(`Downloading ${assetName} (${(asset.size / 1024).toFixed(0)} KB)...`);
71
+ await downloadAsset(asset, TMP_TARBALL, {
72
+ owner,
73
+ repo,
74
+ tag: RELEASE_TAG,
75
+ pkgLabel: PKG_LABEL,
76
+ buildHint: BUILD_HINT,
77
+ });
78
+
79
+ console.log(`Extracting into ${PKG_DIR} ...`);
80
+ await extractTarball(TMP_TARBALL, PKG_DIR);
81
+ await rm(TMP_TARBALL, { force: true });
82
+
83
+ const members = await readdir(PKG_DIR);
84
+ console.log(` pkg/ now holds: ${members.join(', ')}`);
85
+ console.log('Done.');
86
+ }
87
+
88
+ main().catch(async (e) => {
89
+ await rm(TMP_TARBALL, { force: true }).catch(() => {});
90
+ if (e instanceof FetchError) {
91
+ console.error(`\n[${e.code}] ${e.message}`);
92
+ console.error(`Hint: ${e.hint}`);
93
+ } else {
94
+ console.error('\nUnexpected error:', e.message || e);
95
+ }
96
+ process.exit(1);
97
+ });
@@ -0,0 +1,204 @@
1
+ // basis-encode-fast-preset.unit.test.ts -- R-9 fast-preset gate for the w17
2
+ // basisEncode wrapper.
3
+ //
4
+ // M5/w38 flipped the sidecar compressionMode default to 'auto', routing every
5
+ // existing texture through Basis encode. The single-threaded encoder's default
6
+ // effort tiers pushed the model-loading demo build past 10 minutes. R-9 remedy
7
+ // (option 2) drops each arm to its lowest effort tier inside basisEncode:
8
+ // etc1s -> setETC1SCompressionLevel(0) (perf lever, quality unchanged)
9
+ // uastc-ldr -> setPackUASTCFlags(cPackUASTCLevelFastest)
10
+ // uastc-hdr -> setUASTCHDRQualityLevel(0)
11
+ //
12
+ // This gate guards the two load-bearing invariants the speedup must NOT break:
13
+ // (a) the fast-preset output is still a valid Basis KTX2 -- parseKtx2 accepts
14
+ // it and transcodeKtx2 produces a nonempty BC7 mip (functional).
15
+ // (b) AC-02 / R-11 determinism survives the effort change -- encoding the same
16
+ // pixels twice through basisEncode is byte-identical.
17
+ //
18
+ // This exercises the w17 basisEncode WRAPPER (not the raw encoder like w15), so
19
+ // it locks the preset wiring the wrapper adds. pkg/ is a gitignored emcc build
20
+ // artifact (AC-12); the WASM cases skip when it is not built (contributor
21
+ // without emsdk) and run in CI's build-artifacts job.
22
+
23
+ import { existsSync } from 'node:fs';
24
+ import { fileURLToPath } from 'node:url';
25
+ import { parseKtx2, transcodeKtx2 } from '@forgeax/engine-codec';
26
+ import { basisEncode } from '@forgeax/engine-codec/encode';
27
+ import { beforeAll, describe, expect, it } from 'vitest';
28
+ import { _setBasisEncoderImporter } from '../encode/basis-encode.js';
29
+
30
+ const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
31
+ const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
32
+ const pkgBuilt =
33
+ existsSync(fileURLToPath(ENCODER_GLUE)) && existsSync(fileURLToPath(TRANSCODER_GLUE));
34
+
35
+ /** A deterministic WxH tight-packed RGBA image. */
36
+ function makeRgba(w: number, h: number): Uint8Array {
37
+ const img = new Uint8Array(w * h * 4);
38
+ for (let i = 0; i < w * h; i++) {
39
+ img[i * 4] = (i * 7) & 255;
40
+ img[i * 4 + 1] = (i * 13) & 255;
41
+ img[i * 4 + 2] = (i * 29) & 255;
42
+ img[i * 4 + 3] = 255;
43
+ }
44
+ return img;
45
+ }
46
+
47
+ /** A deterministic WxH rgba16float image (HDR arm source bytes). */
48
+ function makeHdrF16(w: number, h: number): Uint8Array {
49
+ const out = new Uint16Array(w * h * 4);
50
+ const toF16 = (value: number): number => {
51
+ const f32 = new Float32Array(1);
52
+ const u32 = new Uint32Array(f32.buffer);
53
+ f32[0] = value;
54
+ const x = u32[0] ?? 0;
55
+ const sign = (x >>> 16) & 0x8000;
56
+ const exp = (x >>> 23) & 0xff;
57
+ const mant = x & 0x7fffff;
58
+ if (exp === 0) return sign;
59
+ const e = exp - 127 + 15;
60
+ if (e <= 0) return sign;
61
+ if (e >= 0x1f) return sign | 0x7c00;
62
+ return sign | (e << 10) | (mant >> 13);
63
+ };
64
+ for (let i = 0; i < w * h; i++) {
65
+ out[i * 4] = toF16((i % 8) / 8);
66
+ out[i * 4 + 1] = toF16((i % 4) / 4);
67
+ out[i * 4 + 2] = toF16(0.5);
68
+ out[i * 4 + 3] = toF16(1);
69
+ }
70
+ return new Uint8Array(out.buffer);
71
+ }
72
+
73
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
74
+ if (a.length !== b.length) return false;
75
+ for (let i = 0; i < a.length; i++) {
76
+ if (a[i] !== b[i]) return false;
77
+ }
78
+ return true;
79
+ }
80
+
81
+ describe.skipIf(!pkgBuilt)('basisEncode fast preset (R-9 / w17)', () => {
82
+ beforeAll(() => {
83
+ // Use the real WASM importer (default). Reset the singleton so this file's
84
+ // first encode drives a clean init regardless of test-file ordering.
85
+ _setBasisEncoderImporter();
86
+ });
87
+
88
+ it('etc1s fast preset round-trips through parseKtx2 + transcodeKtx2', async () => {
89
+ const r = await basisEncode(makeRgba(16, 16), {
90
+ mode: 'etc1s',
91
+ width: 16,
92
+ height: 16,
93
+ srgb: true,
94
+ perceptual: true,
95
+ uastcSupercompression: false,
96
+ mipGen: false,
97
+ });
98
+ expect(r.ok).toBe(true);
99
+ if (!r.ok) return;
100
+ const parsed = await parseKtx2(r.value);
101
+ expect(parsed.ok).toBe(true);
102
+ if (!parsed.ok) return;
103
+ const transcoded = await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
104
+ expect(transcoded.ok).toBe(true);
105
+ if (!transcoded.ok) return;
106
+ expect(transcoded.value.mips[0]?.data.length).toBeGreaterThan(0);
107
+ });
108
+
109
+ it('uastc-ldr fast preset round-trips through parseKtx2 + transcodeKtx2', async () => {
110
+ const r = await basisEncode(makeRgba(16, 16), {
111
+ mode: 'uastc-ldr',
112
+ width: 16,
113
+ height: 16,
114
+ srgb: true,
115
+ perceptual: true,
116
+ uastcSupercompression: true,
117
+ mipGen: false,
118
+ });
119
+ expect(r.ok).toBe(true);
120
+ if (!r.ok) return;
121
+ const parsed = await parseKtx2(r.value);
122
+ expect(parsed.ok).toBe(true);
123
+ if (!parsed.ok) return;
124
+ const transcoded = await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
125
+ expect(transcoded.ok).toBe(true);
126
+ if (!transcoded.ok) return;
127
+ expect(transcoded.value.mips[0]?.data.length).toBeGreaterThan(0);
128
+ });
129
+
130
+ it('uastc-hdr fast preset round-trips through parseKtx2 + transcodeKtx2', async () => {
131
+ const r = await basisEncode(makeHdrF16(16, 16), {
132
+ mode: 'uastc-hdr',
133
+ width: 16,
134
+ height: 16,
135
+ srgb: false,
136
+ perceptual: false,
137
+ uastcSupercompression: false,
138
+ mipGen: false,
139
+ });
140
+ expect(r.ok).toBe(true);
141
+ if (!r.ok) return;
142
+ const parsed = await parseKtx2(r.value);
143
+ expect(parsed.ok).toBe(true);
144
+ if (!parsed.ok) return;
145
+ const transcoded = await transcodeKtx2(parsed.value, 'bc6h-rgb-ufloat');
146
+ expect(transcoded.ok).toBe(true);
147
+ if (!transcoded.ok) return;
148
+ expect(transcoded.value.mips[0]?.data.length).toBeGreaterThan(0);
149
+ });
150
+
151
+ it('AC-02: double encode with fast preset is byte-equal (etc1s)', async () => {
152
+ const img = makeRgba(16, 16);
153
+ const opts = {
154
+ mode: 'etc1s',
155
+ width: 16,
156
+ height: 16,
157
+ srgb: true,
158
+ perceptual: true,
159
+ uastcSupercompression: false,
160
+ mipGen: false,
161
+ } as const;
162
+ const a = await basisEncode(img, opts);
163
+ const b = await basisEncode(img, opts);
164
+ expect(a.ok && b.ok).toBe(true);
165
+ if (!(a.ok && b.ok)) return;
166
+ expect(bytesEqual(a.value, b.value)).toBe(true);
167
+ });
168
+
169
+ it('AC-02: double encode with fast preset is byte-equal (uastc-ldr)', async () => {
170
+ const img = makeRgba(16, 16);
171
+ const opts = {
172
+ mode: 'uastc-ldr',
173
+ width: 16,
174
+ height: 16,
175
+ srgb: true,
176
+ perceptual: true,
177
+ uastcSupercompression: true,
178
+ mipGen: false,
179
+ } as const;
180
+ const a = await basisEncode(img, opts);
181
+ const b = await basisEncode(img, opts);
182
+ expect(a.ok && b.ok).toBe(true);
183
+ if (!(a.ok && b.ok)) return;
184
+ expect(bytesEqual(a.value, b.value)).toBe(true);
185
+ });
186
+
187
+ it('AC-02: double encode with fast preset is byte-equal (uastc-hdr)', async () => {
188
+ const img = makeHdrF16(16, 16);
189
+ const opts = {
190
+ mode: 'uastc-hdr',
191
+ width: 16,
192
+ height: 16,
193
+ srgb: false,
194
+ perceptual: false,
195
+ uastcSupercompression: false,
196
+ mipGen: false,
197
+ } as const;
198
+ const a = await basisEncode(img, opts);
199
+ const b = await basisEncode(img, opts);
200
+ expect(a.ok && b.ok).toBe(true);
201
+ if (!(a.ok && b.ok)) return;
202
+ expect(bytesEqual(a.value, b.value)).toBe(true);
203
+ });
204
+ });
@@ -0,0 +1,249 @@
1
+ import type { BasisEncodeMode, BasisEncodeOptions } from '@forgeax/engine-codec/encode';
2
+ import {
3
+ _basisEncoderInitCount,
4
+ _setBasisEncoderImporter,
5
+ basisEncode,
6
+ } from '@forgeax/engine-codec/encode';
7
+ import { afterEach, describe, expect, it } from 'vitest';
8
+
9
+ const TEST_CONSTRUCTOR_SENTINEL = 'm120-controlled-constructor-throw';
10
+ const TEST_DELETE_SENTINEL = 'm120-controlled-delete-throw';
11
+ const TEST_OPERATION_SENTINEL = 'm120-controlled-operation-throw';
12
+ const CLEANUP_FAILURE_REASON = 'encoder cleanup failed';
13
+ const CONSTRUCTOR_FAILURE_REASON = 'encoder constructor failed';
14
+ const MODES = ['etc1s', 'uastc-ldr', 'uastc-hdr'] as const;
15
+
16
+ interface FakeEncoderState {
17
+ readonly constructorIds: number[];
18
+ readonly deleteIds: number[];
19
+ readonly sourceCalls: { image: Uint8Array; width: number; height: number; hdr: boolean }[];
20
+ readonly formatModes: number[];
21
+ encodeCalls: number;
22
+ shouldConstructThrow: boolean;
23
+ shouldDeleteThrow: boolean;
24
+ shouldSourceFail: boolean;
25
+ shouldOperationThrow: boolean;
26
+ }
27
+
28
+ function makeOptions(mode: BasisEncodeMode): BasisEncodeOptions {
29
+ return {
30
+ mode,
31
+ width: 2,
32
+ height: 2,
33
+ srgb: mode !== 'uastc-hdr',
34
+ perceptual: mode !== 'uastc-hdr',
35
+ uastcSupercompression: mode === 'uastc-ldr',
36
+ mipGen: false,
37
+ };
38
+ }
39
+
40
+ function makePixels(mode: BasisEncodeMode): Uint8Array {
41
+ return mode === 'uastc-hdr'
42
+ ? new Uint8Array(2 * 2 * 4 * 2).fill(0x3c)
43
+ : new Uint8Array(2 * 2 * 4).fill(0x7f);
44
+ }
45
+
46
+ function createFakeEncoderModule() {
47
+ const state: FakeEncoderState = {
48
+ constructorIds: [],
49
+ deleteIds: [],
50
+ sourceCalls: [],
51
+ formatModes: [],
52
+ encodeCalls: 0,
53
+ shouldConstructThrow: true,
54
+ shouldDeleteThrow: false,
55
+ shouldSourceFail: false,
56
+ shouldOperationThrow: false,
57
+ };
58
+
59
+ class FakeBasisEncoder {
60
+ readonly id: number;
61
+
62
+ constructor() {
63
+ const id = state.constructorIds.length + 1;
64
+ state.constructorIds.push(id);
65
+ if (state.shouldConstructThrow) throw TEST_CONSTRUCTOR_SENTINEL;
66
+ this.id = id;
67
+ }
68
+
69
+ controlThreading(_enabled: boolean, _workers: number): void {}
70
+
71
+ setSliceSourceImage(
72
+ _slice: number,
73
+ image: Uint8Array,
74
+ width: number,
75
+ height: number,
76
+ _imageType: number,
77
+ ): boolean {
78
+ state.sourceCalls.push({ image, width, height, hdr: false });
79
+ return !state.shouldSourceFail;
80
+ }
81
+
82
+ setSliceSourceImageHDR(
83
+ _slice: number,
84
+ image: Uint8Array,
85
+ width: number,
86
+ height: number,
87
+ _imageType: number,
88
+ _ldrSrgbToLinear: boolean,
89
+ _ldrToHdrNitMultiplier: number,
90
+ ): boolean {
91
+ state.sourceCalls.push({ image, width, height, hdr: true });
92
+ return !state.shouldSourceFail;
93
+ }
94
+
95
+ setFormatMode(mode: number): void {
96
+ state.formatModes.push(mode);
97
+ }
98
+
99
+ setCreateKTX2File(_enabled: boolean): void {}
100
+
101
+ setKTX2UASTCSupercompression(_enabled: boolean): void {}
102
+
103
+ setKTX2AndBasisSRGBTransferFunc(_enabled: boolean): void {}
104
+
105
+ setPerceptual(_enabled: boolean): void {}
106
+
107
+ setMipGen(_enabled: boolean): void {}
108
+
109
+ setQualityLevel(_quality: number): void {}
110
+
111
+ setETC1SCompressionLevel(_level: number): void {}
112
+
113
+ setPackUASTCFlags(_flags: number): void {}
114
+
115
+ setUASTCHDRQualityLevel(_level: number): void {}
116
+
117
+ encode(destination: Uint8Array): number {
118
+ state.encodeCalls++;
119
+ if (state.shouldOperationThrow) throw TEST_OPERATION_SENTINEL;
120
+ const bytes = new Uint8Array([0xa1, state.formatModes.at(-1) ?? 0, 0x42, 0x99]);
121
+ destination.set(bytes);
122
+ return bytes.length;
123
+ }
124
+
125
+ delete(): void {
126
+ state.deleteIds.push(this.id);
127
+ if (state.shouldDeleteThrow) throw TEST_DELETE_SENTINEL;
128
+ }
129
+ }
130
+
131
+ return {
132
+ state,
133
+ module: {
134
+ initializeBasis(): void {},
135
+ basis_tex_format: {
136
+ cETC1S: { value: 1 },
137
+ cUASTC_LDR_4x4: { value: 2 },
138
+ cUASTC_HDR_4x4: { value: 3 },
139
+ },
140
+ BasisEncoder: FakeBasisEncoder,
141
+ KTX2File: class {},
142
+ } as never,
143
+ };
144
+ }
145
+
146
+ afterEach(() => {
147
+ _setBasisEncoderImporter();
148
+ });
149
+
150
+ describe('basisEncode lifecycle throw containment (M120)', () => {
151
+ it.each(
152
+ MODES,
153
+ )('contains lifecycle throws and retries the same %s module deterministically', async (mode) => {
154
+ const pixels = makePixels(mode);
155
+ const options = makeOptions(mode);
156
+ const fixture = createFakeEncoderModule();
157
+ let importerCalls = 0;
158
+ _setBasisEncoderImporter(() => {
159
+ importerCalls++;
160
+ return Promise.resolve(fixture.module);
161
+ });
162
+
163
+ const constructorFailure = await basisEncode(pixels, options);
164
+ expect(constructorFailure.ok).toBe(false);
165
+ if (constructorFailure.ok) throw new Error('expected constructor refusal');
166
+ expect(constructorFailure.error.code).toBe('ktx2-encode-failed');
167
+ expect(constructorFailure.error.detail).toEqual({
168
+ mode,
169
+ reason: CONSTRUCTOR_FAILURE_REASON,
170
+ });
171
+ expect(JSON.stringify(constructorFailure)).not.toContain(TEST_CONSTRUCTOR_SENTINEL);
172
+ expect(fixture.state.deleteIds).toEqual([]);
173
+
174
+ fixture.state.shouldConstructThrow = false;
175
+ fixture.state.shouldDeleteThrow = true;
176
+ const cleanupFailure = await basisEncode(pixels, options);
177
+ expect(cleanupFailure.ok).toBe(false);
178
+ if (cleanupFailure.ok) throw new Error('expected cleanup refusal');
179
+ expect(cleanupFailure.error.code).toBe('ktx2-encode-failed');
180
+ expect(cleanupFailure.error.detail).toEqual({
181
+ mode,
182
+ reason: CLEANUP_FAILURE_REASON,
183
+ });
184
+ expect(JSON.stringify(cleanupFailure)).not.toContain(TEST_DELETE_SENTINEL);
185
+ expect(fixture.state.deleteIds).toEqual([2]);
186
+
187
+ fixture.state.shouldOperationThrow = true;
188
+ const primaryFailure = await basisEncode(pixels, options);
189
+ expect(primaryFailure.ok).toBe(false);
190
+ if (primaryFailure.ok) throw new Error('expected primary operation refusal');
191
+ expect(primaryFailure.error.code).toBe('ktx2-encode-failed');
192
+ expect(primaryFailure.error.detail).toEqual({
193
+ mode,
194
+ reason: TEST_OPERATION_SENTINEL,
195
+ });
196
+ expect(JSON.stringify(primaryFailure)).toContain(TEST_OPERATION_SENTINEL);
197
+ expect(JSON.stringify(primaryFailure)).not.toContain(TEST_DELETE_SENTINEL);
198
+ expect(fixture.state.deleteIds).toEqual([2, 3]);
199
+
200
+ fixture.state.shouldOperationThrow = false;
201
+ fixture.state.shouldDeleteThrow = false;
202
+ const repaired = await basisEncode(pixels, options);
203
+ expect(repaired.ok).toBe(true);
204
+ if (!repaired.ok) throw new Error('expected repaired encode to succeed');
205
+ expect([...repaired.value]).toEqual([
206
+ 0xa1,
207
+ mode === 'etc1s' ? 1 : mode === 'uastc-ldr' ? 2 : 3,
208
+ 0x42,
209
+ 0x99,
210
+ ]);
211
+
212
+ const third = await basisEncode(pixels, options);
213
+ expect(third).toEqual(repaired);
214
+ expect(importerCalls).toBe(1);
215
+ expect(_basisEncoderInitCount()).toBe(1);
216
+ expect(fixture.state.constructorIds).toEqual([1, 2, 3, 4, 5]);
217
+ expect(fixture.state.deleteIds).toEqual([2, 3, 4, 5]);
218
+ expect(fixture.state.sourceCalls).toHaveLength(4);
219
+ expect(fixture.state.sourceCalls.every(({ image }) => image === pixels)).toBe(true);
220
+ expect(
221
+ fixture.state.sourceCalls.every(({ width, height }) => width === 2 && height === 2),
222
+ ).toBe(true);
223
+ expect(fixture.state.sourceCalls.every(({ hdr }) => hdr === (mode === 'uastc-hdr'))).toBe(true);
224
+ expect(fixture.state.formatModes).toEqual([
225
+ mode === 'etc1s' ? 1 : mode === 'uastc-ldr' ? 2 : 3,
226
+ mode === 'etc1s' ? 1 : mode === 'uastc-ldr' ? 2 : 3,
227
+ mode === 'etc1s' ? 1 : mode === 'uastc-ldr' ? 2 : 3,
228
+ mode === 'etc1s' ? 1 : mode === 'uastc-ldr' ? 2 : 3,
229
+ ]);
230
+ expect(fixture.state.encodeCalls).toBe(4);
231
+ });
232
+
233
+ it.each(MODES)('does not construct an encoder for invalid %s dimensions', async (mode) => {
234
+ const fixture = createFakeEncoderModule();
235
+ fixture.state.shouldConstructThrow = false;
236
+ _setBasisEncoderImporter(() => Promise.resolve(fixture.module));
237
+
238
+ const result = await basisEncode(makePixels(mode), {
239
+ ...makeOptions(mode),
240
+ width: 0,
241
+ });
242
+ expect(result.ok).toBe(false);
243
+ if (result.ok) throw new Error('expected invalid dimensions to fail');
244
+ expect(result.error.code).toBe('ktx2-encode-failed');
245
+ expect(result.error.detail).toEqual({ mode, reason: 'invalid dimensions 0x2' });
246
+ expect(fixture.state.constructorIds).toEqual([]);
247
+ expect(fixture.state.deleteIds).toEqual([]);
248
+ });
249
+ });
@@ -0,0 +1,151 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { afterEach, beforeAll, describe, expect, it } from 'vitest';
4
+ import {
5
+ _basisTranscoderInitCount,
6
+ _setBasisTranscoderImporter,
7
+ initBasisTranscoder,
8
+ transcodeKtx2,
9
+ } from '../basis-transcoder.js';
10
+ import { parseKtx2 } from '../ktx2.js';
11
+ import type { BasisEncoderModule, BasisModuleFactory } from '../wasm/basis-types.js';
12
+
13
+ /**
14
+ * basis-transcoder glue unit tests (w14).
15
+ *
16
+ * Validates:
17
+ * 1. Lazy-init singleton (D-10): first initBasisTranscoder() loads the WASM
18
+ * module once; a second call returns the cache without re-importing.
19
+ * 2. transcodeKtx2() on a real UASTC KTX2 (encoder-produced) returns non-empty
20
+ * per-mip block bytes for a BC7 target.
21
+ * 3. Module imports cleanly in node (this file runs under the node vitest
22
+ * project); the same dynamic-import .mjs path is browser-legal (M5/e2e).
23
+ *
24
+ * pkg/ is a gitignored emcc build artifact (AC-12). When it is not built
25
+ * (contributor without emsdk) the WASM-dependent cases skip; CI builds it in the
26
+ * build-artifacts job so the gate runs there. The init-counting case uses an
27
+ * injected fake importer, so it runs regardless of pkg/ presence.
28
+ */
29
+
30
+ const TRANSCODER_GLUE = new URL('../../pkg/basis_transcoder.mjs', import.meta.url);
31
+ const ENCODER_GLUE = new URL('../../pkg/encode/basis_encoder.mjs', import.meta.url);
32
+ const pkgBuilt =
33
+ existsSync(fileURLToPath(TRANSCODER_GLUE)) && existsSync(fileURLToPath(ENCODER_GLUE));
34
+
35
+ async function loadEncoder(): Promise<BasisEncoderModule> {
36
+ const factory = (
37
+ (await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
38
+ default: BasisModuleFactory<BasisEncoderModule>;
39
+ }
40
+ ).default;
41
+ const mod = await factory({
42
+ locateFile: () => new URL('../../pkg/encode/basis_encoder.wasm', import.meta.url).href,
43
+ });
44
+ mod.initializeBasis();
45
+ return mod;
46
+ }
47
+
48
+ /** A deterministic WxH RGBA image. */
49
+ function makeRgba(w: number, h: number): Uint8Array {
50
+ const img = new Uint8Array(w * h * 4);
51
+ for (let i = 0; i < w * h; i++) {
52
+ img[i * 4] = (i * 7) & 255;
53
+ img[i * 4 + 1] = (i * 13) & 255;
54
+ img[i * 4 + 2] = (i * 29) & 255;
55
+ img[i * 4 + 3] = 255;
56
+ }
57
+ return img;
58
+ }
59
+
60
+ /** Encode a WxH RGBA image into a UASTC KTX2 container (Basis payload). */
61
+ async function encodeUastcKtx2(w: number, h: number): Promise<Uint8Array> {
62
+ const enc = await loadEncoder();
63
+ const encoder = new enc.BasisEncoder();
64
+ encoder.setSliceSourceImage(0, makeRgba(w, h), w, h, 0);
65
+ encoder.setCreateKTX2File(true);
66
+ encoder.setKTX2UASTCSupercompression(true);
67
+ encoder.setFormatMode(enc.basis_tex_format.cUASTC_LDR_4x4.value);
68
+ encoder.setPerceptual(true);
69
+ encoder.setMipGen(false);
70
+ const out = new Uint8Array(1 << 20);
71
+ const n = encoder.encode(out);
72
+ encoder.delete();
73
+ if (n <= 0) throw new Error('encode failed for test setup');
74
+ return out.slice(0, n);
75
+ }
76
+
77
+ afterEach(() => {
78
+ _setBasisTranscoderImporter();
79
+ });
80
+
81
+ describe('initBasisTranscoder — lazy-init singleton (D-10, w14)', () => {
82
+ it('importer not invoked until first init call', () => {
83
+ _setBasisTranscoderImporter(() => Promise.resolve({} as never));
84
+ expect(_basisTranscoderInitCount()).toBe(0);
85
+ });
86
+
87
+ it('loads exactly once and returns the cache on the second call', async () => {
88
+ let calls = 0;
89
+ const fake = { transcoder_texture_format: {}, KTX2File: null } as never;
90
+ _setBasisTranscoderImporter(() => {
91
+ calls++;
92
+ return Promise.resolve(fake);
93
+ });
94
+
95
+ const a = await initBasisTranscoder();
96
+ const b = await initBasisTranscoder();
97
+ expect(a).toBe(b);
98
+ expect(calls).toBe(1);
99
+ expect(_basisTranscoderInitCount()).toBe(1);
100
+ });
101
+
102
+ it('clears the cached failure so the next call retries', async () => {
103
+ let calls = 0;
104
+ _setBasisTranscoderImporter(() => {
105
+ calls++;
106
+ return Promise.reject(new Error('boom'));
107
+ });
108
+ await expect(initBasisTranscoder()).rejects.toThrow();
109
+ await expect(initBasisTranscoder()).rejects.toThrow();
110
+ expect(calls).toBe(2);
111
+ });
112
+ });
113
+
114
+ describe.skipIf(!pkgBuilt)('transcodeKtx2 — real UASTC -> BC7 (w14)', () => {
115
+ let uastcKtx2: Uint8Array;
116
+
117
+ beforeAll(async () => {
118
+ uastcKtx2 = await encodeUastcKtx2(16, 16);
119
+ });
120
+
121
+ it('transcodes every mip level into non-empty BC7 block bytes', async () => {
122
+ _setBasisTranscoderImporter();
123
+ const parsed = await parseKtx2(uastcKtx2);
124
+ expect(parsed.ok).toBe(true);
125
+ if (!parsed.ok) throw new Error(`parse failed: ${parsed.error.code}`);
126
+
127
+ const result = await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
128
+ expect(result.ok).toBe(true);
129
+ if (!result.ok) throw new Error(`transcode failed: ${result.error.code}`);
130
+
131
+ expect(result.value.format).toBe('bc7-rgba-unorm');
132
+ expect(result.value.width).toBe(16);
133
+ expect(result.value.height).toBe(16);
134
+ expect(result.value.mips.length).toBeGreaterThanOrEqual(1);
135
+ const base = result.value.mips[0];
136
+ expect(base).toBeDefined();
137
+ if (!base) throw new Error('no base mip');
138
+ // 16x16 BC7 = 4x4 blocks * 16 bytes = 256 bytes.
139
+ expect(base.data.length).toBe(256);
140
+ expect(base.data.byteLength).toBeGreaterThan(0);
141
+ });
142
+
143
+ it('the real WASM importer loads once across two transcodes', async () => {
144
+ _setBasisTranscoderImporter();
145
+ const parsed = await parseKtx2(uastcKtx2);
146
+ if (!parsed.ok) throw new Error('parse failed');
147
+ await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
148
+ await transcodeKtx2(parsed.value, 'bc7-rgba-unorm');
149
+ expect(_basisTranscoderInitCount()).toBe(1);
150
+ });
151
+ });