@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.
- package/LICENSE +202 -0
- package/README.md +261 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-encode-fast-preset.unit.test.d.ts.map +1 -0
- package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-encode-lifecycle.unit.test.d.ts.map +1 -0
- package/dist/__tests__/basis-transcoder.unit.test.d.ts +2 -0
- package/dist/__tests__/basis-transcoder.unit.test.d.ts.map +1 -0
- package/dist/__tests__/block-format.unit.test.d.ts +2 -0
- package/dist/__tests__/block-format.unit.test.d.ts.map +1 -0
- package/dist/__tests__/byte-identical-falsification.test.d.ts +2 -0
- package/dist/__tests__/byte-identical-falsification.test.d.ts.map +1 -0
- package/dist/__tests__/codec-errors.test-d.d.ts +2 -0
- package/dist/__tests__/codec-errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/codec-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/codec-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/compression-field.unit.test.d.ts +19 -0
- package/dist/__tests__/compression-field.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-pixel-limit.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-pixel-limit.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-capability-matrix.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-errors.test.d.ts +2 -0
- package/dist/__tests__/ktx2-errors.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-parse-scheme0.test.d.ts +2 -0
- package/dist/__tests__/ktx2-parse-scheme0.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-parse-scheme2.test.d.ts +2 -0
- package/dist/__tests__/ktx2-parse-scheme2.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-to-texture.integration.test.d.ts +2 -0
- package/dist/__tests__/ktx2-to-texture.integration.test.d.ts.map +1 -0
- package/dist/__tests__/transcode-select-target.unit.test.d.ts +2 -0
- package/dist/__tests__/transcode-select-target.unit.test.d.ts.map +1 -0
- package/dist/__tests__/wasm-init-smoke.test.d.ts +2 -0
- package/dist/__tests__/wasm-init-smoke.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-deterministic.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-deterministic.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-encode-singleton.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-roundtrip.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-roundtrip.unit.test.d.ts.map +1 -0
- package/dist/__tests__/zstd-singleton.unit.test.d.ts +2 -0
- package/dist/__tests__/zstd-singleton.unit.test.d.ts.map +1 -0
- package/dist/basis-transcoder.d.ts +86 -0
- package/dist/basis-transcoder.d.ts.map +1 -0
- package/dist/block-format.d.ts +41 -0
- package/dist/block-format.d.ts.map +1 -0
- package/dist/encode/basis-encode.d.ts +68 -0
- package/dist/encode/basis-encode.d.ts.map +1 -0
- package/dist/encode/index.d.ts +10 -0
- package/dist/encode/index.d.ts.map +1 -0
- package/dist/encode/index.mjs +207 -0
- package/dist/encode/index.mjs.map +1 -0
- package/dist/encode-impl.d.ts +30 -0
- package/dist/encode-impl.d.ts.map +1 -0
- package/dist/errors.d.ts +61 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +610 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2.d.ts +111 -0
- package/dist/ktx2.d.ts.map +1 -0
- package/dist/transcode.d.ts +55 -0
- package/dist/transcode.d.ts.map +1 -0
- package/dist/wasm/basis-types.d.ts +179 -0
- package/dist/wasm/basis-types.d.ts.map +1 -0
- package/dist/zstd.d.ts +28 -0
- package/dist/zstd.d.ts.map +1 -0
- package/package.json +71 -0
- package/pkg/basis_transcoder.mjs +2 -0
- package/pkg/basis_transcoder.wasm +0 -0
- package/pkg/encode/basis_encoder.mjs +2 -0
- package/pkg/encode/basis_encoder.wasm +0 -0
- package/scripts/build-wasm.mjs +370 -0
- package/scripts/content-key.mjs +85 -0
- package/scripts/ensure-wasm.mjs +38 -0
- package/scripts/fetch-basis.mjs +150 -0
- package/scripts/fetch-wasm.mjs +97 -0
- package/src/__tests__/basis-encode-fast-preset.unit.test.ts +204 -0
- package/src/__tests__/basis-encode-lifecycle.unit.test.ts +249 -0
- package/src/__tests__/basis-transcoder.unit.test.ts +151 -0
- package/src/__tests__/block-format.unit.test.ts +215 -0
- package/src/__tests__/byte-identical-falsification.test.ts +164 -0
- package/src/__tests__/codec-errors.test-d.ts +67 -0
- package/src/__tests__/codec-errors.unit.test.ts +128 -0
- package/src/__tests__/compression-field.unit.test.ts +245 -0
- package/src/__tests__/encode-pixel-limit.unit.test.ts +117 -0
- package/src/__tests__/encode-roundtrip.unit.test.ts +239 -0
- package/src/__tests__/ktx2-basis-capability-matrix.unit.test.ts +241 -0
- package/src/__tests__/ktx2-errors.test.ts +239 -0
- package/src/__tests__/ktx2-parse-scheme0.test.ts +263 -0
- package/src/__tests__/ktx2-parse-scheme2.test.ts +220 -0
- package/src/__tests__/ktx2-scheme1-and-transcode-errors.unit.test.ts +178 -0
- package/src/__tests__/ktx2-to-texture.integration.test.ts +384 -0
- package/src/__tests__/transcode-select-target.unit.test.ts +167 -0
- package/src/__tests__/wasm-init-smoke.test.ts +102 -0
- package/src/__tests__/zstd-deterministic.unit.test.ts +49 -0
- package/src/__tests__/zstd-encode-singleton.unit.test.ts +63 -0
- package/src/__tests__/zstd-roundtrip.unit.test.ts +87 -0
- package/src/__tests__/zstd-singleton.unit.test.ts +105 -0
- package/src/basis-transcoder.ts +315 -0
- package/src/block-format.ts +128 -0
- package/src/encode/basis-encode.ts +264 -0
- package/src/encode/index.ts +19 -0
- package/src/encode-impl.ts +103 -0
- package/src/errors.ts +79 -0
- package/src/index.ts +38 -0
- package/src/ktx2.ts +469 -0
- package/src/transcode.ts +99 -0
- package/src/wasm/basis-types.ts +220 -0
- package/src/zstd.ts +85 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* w9 TDD: compression field type-domain + no-as access (AC-01).
|
|
3
|
+
*
|
|
4
|
+
* Validates that AssetCompression union type = 'none' | 'zstd' appears at all
|
|
5
|
+
* 5 field locations (research Finding 1), and that consumption paths never
|
|
6
|
+
* require `as` assertion.
|
|
7
|
+
*
|
|
8
|
+
* TDD red state: these tests encode the expected structural contract. After
|
|
9
|
+
* w12 adds the real `compression` fields to types/index.ts and asset-registry.ts,
|
|
10
|
+
* the runtime assertions pass (no TS compile errors, field access without `as`).
|
|
11
|
+
*
|
|
12
|
+
* Strategy: define local mirror types matching the expected API shape, then
|
|
13
|
+
* verify that the shape itself is correct. After w12 lands, the real types
|
|
14
|
+
* will match this contract; the test stays structurally green.
|
|
15
|
+
*
|
|
16
|
+
* D-10: all fixtures programmatic, no binary files.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from 'vitest';
|
|
20
|
+
|
|
21
|
+
// === Expected type contracts (mirrors what w12 will define in real code) ===
|
|
22
|
+
|
|
23
|
+
/** Expected AssetCompression union: literal 'none' | 'zstd', NOT wider `string`. */
|
|
24
|
+
type AssetCompressionMirror = 'none' | 'zstd';
|
|
25
|
+
|
|
26
|
+
/** Expected ImageMetadata shape (field location 1). */
|
|
27
|
+
interface ImageMetadataExpected {
|
|
28
|
+
readonly kind: 'texture';
|
|
29
|
+
readonly width?: number;
|
|
30
|
+
readonly height?: number;
|
|
31
|
+
readonly format: string; // GPUTextureFormat
|
|
32
|
+
readonly colorSpace: 'srgb' | 'linear';
|
|
33
|
+
readonly mipmap: boolean;
|
|
34
|
+
readonly compression?: AssetCompressionMirror;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Expected PackIndexEntry shape (field location 2). */
|
|
38
|
+
interface PackIndexEntryExpected {
|
|
39
|
+
readonly guid: string;
|
|
40
|
+
readonly packageUrl: string;
|
|
41
|
+
readonly kind: string;
|
|
42
|
+
readonly sourcePath: string;
|
|
43
|
+
readonly name?: string;
|
|
44
|
+
readonly metadata?: ImageMetadataExpected | undefined;
|
|
45
|
+
readonly refs?: readonly string[];
|
|
46
|
+
readonly compression?: AssetCompressionMirror;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Expected LoaderEntry shape (field location 3, private in asset-registry.ts:984-989). */
|
|
50
|
+
interface LoaderEntryExpected {
|
|
51
|
+
readonly guidKey: string;
|
|
52
|
+
readonly packageUrl: string;
|
|
53
|
+
readonly kind: string;
|
|
54
|
+
readonly metadata?: ImageMetadataExpected | undefined;
|
|
55
|
+
readonly compression?: AssetCompressionMirror;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Expected listCatalog() return type element shape (field location 4, inline at asset-registry.ts:5059-5065). */
|
|
59
|
+
interface CatalogRowExpected {
|
|
60
|
+
readonly guid: string;
|
|
61
|
+
readonly kind: string;
|
|
62
|
+
readonly name?: string;
|
|
63
|
+
readonly packageUrl: string;
|
|
64
|
+
readonly refs?: readonly string[];
|
|
65
|
+
readonly compression?: AssetCompressionMirror;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// === Tests ===
|
|
69
|
+
|
|
70
|
+
describe('AssetCompression type domain (AC-01)', () => {
|
|
71
|
+
it('compression union values are exactly "none" | "zstd" (not wider string)', () => {
|
|
72
|
+
// Verify that the literal values are correct.
|
|
73
|
+
// A wider type (e.g., `string`) would allow bogus values — this test
|
|
74
|
+
// encodes the contract that `compression` must be a closed literal union.
|
|
75
|
+
|
|
76
|
+
const noneVal: AssetCompressionMirror = 'none';
|
|
77
|
+
const zstdVal: AssetCompressionMirror = 'zstd';
|
|
78
|
+
|
|
79
|
+
expect(noneVal).toBe('none');
|
|
80
|
+
expect(zstdVal).toBe('zstd');
|
|
81
|
+
|
|
82
|
+
// TypeScript compile-time check: 'bogus' should NOT be assignable.
|
|
83
|
+
// If AssetCompression were `string`, this would compile.
|
|
84
|
+
// const _bogus: AssetCompressionMirror = 'bogus'; // uncomment to verify TS error
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('ImageMetadata has readonly compression?: AssetCompression (field location 1)', () => {
|
|
88
|
+
// ImageMetadata must carry the compression hint for texture rows.
|
|
89
|
+
const meta: ImageMetadataExpected = {
|
|
90
|
+
kind: 'texture',
|
|
91
|
+
format: 'rgba8unorm',
|
|
92
|
+
colorSpace: 'srgb',
|
|
93
|
+
mipmap: true,
|
|
94
|
+
compression: 'none',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Access without `as`
|
|
98
|
+
expect(meta.compression).toBe('none');
|
|
99
|
+
|
|
100
|
+
// Optional: when absent, undefined is returned
|
|
101
|
+
const metaWithout: ImageMetadataExpected = {
|
|
102
|
+
kind: 'texture',
|
|
103
|
+
format: 'rgba8unorm',
|
|
104
|
+
colorSpace: 'linear',
|
|
105
|
+
mipmap: false,
|
|
106
|
+
};
|
|
107
|
+
expect(metaWithout.compression).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('PackIndexEntry has compression?: AssetCompression (field location 2)', () => {
|
|
111
|
+
// PackIndexEntry must have optional `compression` field.
|
|
112
|
+
const entry: PackIndexEntryExpected = {
|
|
113
|
+
guid: 'aaaaaaaa-bbbb-4ccc-dddd-eeeeeeeeeeee',
|
|
114
|
+
packageUrl: 'assets/test.bin',
|
|
115
|
+
kind: 'mesh',
|
|
116
|
+
sourcePath: 'test.glb',
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// Read compression without `as`
|
|
120
|
+
const comp = entry.compression;
|
|
121
|
+
expect(comp).toBeUndefined();
|
|
122
|
+
|
|
123
|
+
// When set, must be a literal 'none' or 'zstd'
|
|
124
|
+
const withComp: PackIndexEntryExpected = {
|
|
125
|
+
...entry,
|
|
126
|
+
compression: 'zstd',
|
|
127
|
+
};
|
|
128
|
+
expect(withComp.compression).toBe('zstd');
|
|
129
|
+
|
|
130
|
+
// The 'none' value is also valid
|
|
131
|
+
const withNone: PackIndexEntryExpected = {
|
|
132
|
+
...entry,
|
|
133
|
+
compression: 'none',
|
|
134
|
+
};
|
|
135
|
+
expect(withNone.compression).toBe('none');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('LoaderEntry has compression?: AssetCompression (field location 3)', () => {
|
|
139
|
+
// LoaderEntry is a PRIVATE interface at asset-registry.ts:984-989.
|
|
140
|
+
// We verify that its structural contract accepts compression.
|
|
141
|
+
const entry: LoaderEntryExpected = {
|
|
142
|
+
guidKey: 'abc123',
|
|
143
|
+
packageUrl: 'test.bin',
|
|
144
|
+
kind: 'mesh',
|
|
145
|
+
compression: 'zstd',
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
// Read without `as`
|
|
149
|
+
expect(entry.compression).toBe('zstd');
|
|
150
|
+
|
|
151
|
+
// Compression is optional
|
|
152
|
+
const entryWithout: LoaderEntryExpected = {
|
|
153
|
+
guidKey: 'abc123',
|
|
154
|
+
packageUrl: 'test.bin',
|
|
155
|
+
kind: 'texture',
|
|
156
|
+
metadata: {
|
|
157
|
+
kind: 'texture',
|
|
158
|
+
format: 'rgba8unorm',
|
|
159
|
+
colorSpace: 'srgb',
|
|
160
|
+
mipmap: true,
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
expect(entryWithout.compression).toBeUndefined();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('listCatalog() return type has compression?: AssetCompression (field location 4)', () => {
|
|
167
|
+
// listCatalog() at asset-registry.ts:5059-5065 with inline return type.
|
|
168
|
+
// The returned array element must accept compression.
|
|
169
|
+
const row: CatalogRowExpected = {
|
|
170
|
+
guid: 'test-guid',
|
|
171
|
+
kind: 'mesh',
|
|
172
|
+
packageUrl: 'test.bin',
|
|
173
|
+
compression: 'none',
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Read without `as`
|
|
177
|
+
expect(row.compression).toBe('none');
|
|
178
|
+
|
|
179
|
+
// Optional: absent compression = undefined
|
|
180
|
+
const rowWithout: CatalogRowExpected = {
|
|
181
|
+
guid: 'test-guid',
|
|
182
|
+
kind: 'texture',
|
|
183
|
+
packageUrl: 'test.bin',
|
|
184
|
+
};
|
|
185
|
+
expect(rowWithout.compression).toBeUndefined();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('ImageImportSettings naturally passes compression through importSettings spread (field location 5)', () => {
|
|
189
|
+
// ImageImportSettings extends Readonly<Record<string, unknown>>.
|
|
190
|
+
// Compression is passed as part of importSettings via the Record base.
|
|
191
|
+
// We verify the spread pattern works without type widening.
|
|
192
|
+
|
|
193
|
+
const baseSettings = {
|
|
194
|
+
colorSpace: 'srgb' as const,
|
|
195
|
+
mipmap: true as const,
|
|
196
|
+
addressMode: 'clamp-to-edge' as const,
|
|
197
|
+
filterMode: 'linear' as const,
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const withCompression = {
|
|
201
|
+
...baseSettings,
|
|
202
|
+
compression: 'zstd' as const,
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// Access via the Record<string, unknown> spread mechanism (dot access for typed).
|
|
206
|
+
const comp = withCompression.compression;
|
|
207
|
+
expect(comp).toBe('zstd');
|
|
208
|
+
|
|
209
|
+
// The literal 'zstd' narrows correctly via type narrowing
|
|
210
|
+
if (withCompression.compression === 'zstd') {
|
|
211
|
+
// correctly narrows — no `as` needed
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('zero `as` assertions in compression consumption paths (sentinel)', () => {
|
|
216
|
+
// This test encodes the AC-01 requirement: no `as AssetCompression` or
|
|
217
|
+
// `as any` in any compression-related code path.
|
|
218
|
+
//
|
|
219
|
+
// We verify by constructing every expected shape and reading compression
|
|
220
|
+
// directly (without `as` in the test body itself).
|
|
221
|
+
//
|
|
222
|
+
// After w12: the real acceptanceCheck runs `git grep 'as AssetCompression'`
|
|
223
|
+
// and `git grep 'as any'` near compression usage — this test is a
|
|
224
|
+
// doc-sentinel that those greps must pass.
|
|
225
|
+
|
|
226
|
+
const entry: PackIndexEntryExpected = {
|
|
227
|
+
guid: 'g',
|
|
228
|
+
packageUrl: 'u',
|
|
229
|
+
kind: 'mesh',
|
|
230
|
+
sourcePath: 's',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
// Read without `as` — the type system narrows naturally
|
|
234
|
+
const c: AssetCompressionMirror | undefined = entry.compression;
|
|
235
|
+
expect(c).toBeUndefined();
|
|
236
|
+
|
|
237
|
+
// Set without `as`
|
|
238
|
+
const withComp: PackIndexEntryExpected = {
|
|
239
|
+
...entry,
|
|
240
|
+
compression: 'zstd',
|
|
241
|
+
};
|
|
242
|
+
const c2 = withComp.compression;
|
|
243
|
+
expect(c2).toBe('zstd');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
@@ -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
|
+
});
|