@forgeax/engine-image 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 +166 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts +2 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts +2 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer.test.d.ts +2 -0
- package/dist/__tests__/image-importer.test.d.ts.map +1 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/image.unit.test.d.ts +2 -0
- package/dist/__tests__/image.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/make-fixture.d.ts +12 -0
- package/dist/__tests__/make-fixture.d.ts.map +1 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
- package/dist/__tests__/source-key.unit.test.d.ts +2 -0
- package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
- package/dist/decode-image-from-file.d.ts +40 -0
- package/dist/decode-image-from-file.d.ts.map +1 -0
- package/dist/decode-image-from-file.mjs +460 -0
- package/dist/decode-image-from-file.mjs.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/hdr-decoder.d.ts +28 -0
- package/dist/hdr-decoder.d.ts.map +1 -0
- package/dist/hdr-decoder.mjs +212 -0
- package/dist/hdr-decoder.mjs.map +1 -0
- package/dist/image-decoder-browser.d.ts +22 -0
- package/dist/image-decoder-browser.d.ts.map +1 -0
- package/dist/image-decoder-node.d.ts +50 -0
- package/dist/image-decoder-node.d.ts.map +1 -0
- package/dist/image-importer.d.ts +18 -0
- package/dist/image-importer.d.ts.map +1 -0
- package/dist/image-importer.mjs +1085 -0
- package/dist/image-importer.mjs.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +469 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2-encode.d.ts +81 -0
- package/dist/ktx2-encode.d.ts.map +1 -0
- package/dist/ktx2-encode.mjs +98 -0
- package/dist/ktx2-encode.mjs.map +1 -0
- package/dist/parse-image.d.ts +47 -0
- package/dist/parse-image.d.ts.map +1 -0
- package/dist/parse-image.mjs +154 -0
- package/dist/parse-image.mjs.map +1 -0
- package/dist/reimport-reuse-meta.d.ts +81 -0
- package/dist/reimport-reuse-meta.d.ts.map +1 -0
- package/dist/resize-image.d.ts +7 -0
- package/dist/resize-image.d.ts.map +1 -0
- package/dist/result.d.ts +2 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/runtime/asset-decoders.d.ts +4 -0
- package/dist/runtime/asset-decoders.d.ts.map +1 -0
- package/dist/source-key.d.ts +21 -0
- package/dist/source-key.d.ts.map +1 -0
- package/dist/sub-asset-key.d.ts +50 -0
- package/dist/sub-asset-key.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +61 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/package.json +105 -0
- package/src/__tests__/compression-mode.unit.test.ts +104 -0
- package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
- package/src/__tests__/errors.test-d.ts +148 -0
- package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
- package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
- package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
- package/src/__tests__/image-importer.test.ts +12 -0
- package/src/__tests__/image-local-artifacts.test.ts +38 -0
- package/src/__tests__/image.unit.test.ts +1253 -0
- package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
- package/src/__tests__/make-fixture.ts +100 -0
- package/src/__tests__/parse-image-downscale.test.ts +26 -0
- package/src/__tests__/source-key.unit.test.ts +65 -0
- package/src/decode-image-from-file.ts +233 -0
- package/src/errors.ts +60 -0
- package/src/hdr-decoder.ts +255 -0
- package/src/image-decoder-browser.ts +88 -0
- package/src/image-decoder-node.ts +67 -0
- package/src/image-decoders.d.ts +23 -0
- package/src/image-importer.ts +858 -0
- package/src/index.ts +44 -0
- package/src/ktx2-encode.ts +217 -0
- package/src/parse-image.ts +176 -0
- package/src/reimport-reuse-meta.ts +160 -0
- package/src/resize-image.ts +39 -0
- package/src/result.ts +13 -0
- package/src/runtime/asset-decoders.ts +152 -0
- package/src/source-key.ts +41 -0
- package/src/sub-asset-key.ts +67 -0
- package/src/to-asset-pack.ts +95 -0
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { basisEncode } from '@forgeax/engine-codec/encode';
|
|
4
|
+
import { ImporterRegistry, type RunImportMeta, runImport } from '@forgeax/engine-import';
|
|
5
|
+
import type { ImportContext } from '@forgeax/engine-types';
|
|
6
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import type {
|
|
8
|
+
BasisEncoderModule,
|
|
9
|
+
BasisModuleFactory,
|
|
10
|
+
} from '../../../codec/src/wasm/basis-types.js';
|
|
11
|
+
import { imageImporter } from '../image-importer.js';
|
|
12
|
+
import { makeCorruptPng, makeJpg, makePng } from './make-fixture.js';
|
|
13
|
+
|
|
14
|
+
const GUID = '019f0000-0000-7000-8000-000000000301';
|
|
15
|
+
const TRANSCODER_GLUE = new URL('../../../codec/pkg/basis_transcoder.mjs', import.meta.url);
|
|
16
|
+
const ENCODER_GLUE = new URL('../../../codec/pkg/encode/basis_encoder.mjs', import.meta.url);
|
|
17
|
+
const pkgBuilt =
|
|
18
|
+
existsSync(fileURLToPath(TRANSCODER_GLUE)) && existsSync(fileURLToPath(ENCODER_GLUE));
|
|
19
|
+
|
|
20
|
+
function registry(): ImporterRegistry {
|
|
21
|
+
const result = new ImporterRegistry();
|
|
22
|
+
result.register(imageImporter);
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function meta(
|
|
27
|
+
source = 'retry.png',
|
|
28
|
+
importSettings: Readonly<Record<string, unknown>> = {
|
|
29
|
+
colorSpace: 'srgb',
|
|
30
|
+
mipmap: 'none',
|
|
31
|
+
},
|
|
32
|
+
sourceOverrides: RunImportMeta['sourceOverrides'] = undefined,
|
|
33
|
+
kind: 'texture' | 'equirect' = 'texture',
|
|
34
|
+
): RunImportMeta {
|
|
35
|
+
return {
|
|
36
|
+
importer: 'image',
|
|
37
|
+
source,
|
|
38
|
+
importSettings,
|
|
39
|
+
subAssets: [{ guid: GUID, sourceIndex: 0, sourceKey: 'image:texture', kind }],
|
|
40
|
+
...(sourceOverrides === undefined ? {} : { sourceOverrides }),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function source(state: { bytes: Uint8Array; reads: number }): ImportContext['readSource'] {
|
|
45
|
+
return vi.fn(async () => {
|
|
46
|
+
state.reads += 1;
|
|
47
|
+
return { ok: true as const, value: state.bytes };
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function expectConversionFailure(
|
|
52
|
+
result: Awaited<ReturnType<typeof runImport>>,
|
|
53
|
+
diagnosticCode: string,
|
|
54
|
+
): void {
|
|
55
|
+
expect(result.ok).toBe(false);
|
|
56
|
+
if (result.ok) return;
|
|
57
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
58
|
+
expect(result.error.detail).toMatchObject({
|
|
59
|
+
diagnostics: [
|
|
60
|
+
expect.objectContaining({
|
|
61
|
+
code: diagnosticCode,
|
|
62
|
+
rule: expect.stringMatching(/^image-conversion-/),
|
|
63
|
+
severity: 'error',
|
|
64
|
+
}),
|
|
65
|
+
],
|
|
66
|
+
});
|
|
67
|
+
expect(result).not.toHaveProperty('value');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function loadEncoder(): Promise<BasisEncoderModule> {
|
|
71
|
+
const factory = (
|
|
72
|
+
(await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
|
|
73
|
+
default: BasisModuleFactory<BasisEncoderModule>;
|
|
74
|
+
}
|
|
75
|
+
).default;
|
|
76
|
+
const module = await factory({
|
|
77
|
+
locateFile: () => new URL('../../../codec/pkg/encode/basis_encoder.wasm', import.meta.url).href,
|
|
78
|
+
});
|
|
79
|
+
module.initializeBasis();
|
|
80
|
+
return module;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function makeRawBasis(hdr = false): Promise<Uint8Array> {
|
|
84
|
+
const module = await loadEncoder();
|
|
85
|
+
const encoder = new module.BasisEncoder();
|
|
86
|
+
try {
|
|
87
|
+
const width = 4;
|
|
88
|
+
const height = 4;
|
|
89
|
+
if (hdr) {
|
|
90
|
+
encoder.setSliceSourceImageHDR(
|
|
91
|
+
0,
|
|
92
|
+
new Uint8Array(width * height * 8),
|
|
93
|
+
width,
|
|
94
|
+
height,
|
|
95
|
+
0,
|
|
96
|
+
false,
|
|
97
|
+
1,
|
|
98
|
+
);
|
|
99
|
+
encoder.setFormatMode(module.basis_tex_format.cUASTC_HDR_4x4.value);
|
|
100
|
+
} else {
|
|
101
|
+
encoder.setSliceSourceImage(
|
|
102
|
+
0,
|
|
103
|
+
new Uint8Array(width * height * 4).fill(127),
|
|
104
|
+
width,
|
|
105
|
+
height,
|
|
106
|
+
0,
|
|
107
|
+
);
|
|
108
|
+
encoder.setFormatMode(module.basis_tex_format.cUASTC_LDR_4x4.value);
|
|
109
|
+
}
|
|
110
|
+
encoder.setCreateKTX2File(false);
|
|
111
|
+
encoder.setMipGen(false);
|
|
112
|
+
const output = new Uint8Array(1 << 20);
|
|
113
|
+
const length = encoder.encode(output);
|
|
114
|
+
if (length <= 0) throw new Error('raw Basis encode failed');
|
|
115
|
+
return output.slice(0, length);
|
|
116
|
+
} finally {
|
|
117
|
+
encoder.delete();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function makeKtx2(): Promise<Uint8Array> {
|
|
122
|
+
const result = await basisEncode(new Uint8Array(4 * 4 * 4).fill(127), {
|
|
123
|
+
mode: 'uastc-ldr',
|
|
124
|
+
width: 4,
|
|
125
|
+
height: 4,
|
|
126
|
+
srgb: false,
|
|
127
|
+
perceptual: false,
|
|
128
|
+
uastcSupercompression: true,
|
|
129
|
+
mipGen: false,
|
|
130
|
+
});
|
|
131
|
+
if (!result.ok) throw new Error(result.error.code);
|
|
132
|
+
return result.value;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe('image importer conversion failure through the public runner', () => {
|
|
136
|
+
it.each([
|
|
137
|
+
{ source: 'corrupt.png', bytes: makeCorruptPng(), code: 'image-conversion-decode-ldr' },
|
|
138
|
+
{
|
|
139
|
+
source: 'corrupt.jpeg',
|
|
140
|
+
bytes: makeJpg(2, 2, [10, 20, 30, 255]).slice(0, 8),
|
|
141
|
+
code: 'image-conversion-decode-ldr',
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
source: 'corrupt.hdr',
|
|
145
|
+
bytes: new Uint8Array([0, 0, 0, 0]),
|
|
146
|
+
code: 'image-conversion-decode-hdr',
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
source: 'corrupt.ktx2',
|
|
150
|
+
bytes: new Uint8Array([0, 0, 0, 0]),
|
|
151
|
+
code: 'image-conversion-inspect-ktx2-parse',
|
|
152
|
+
},
|
|
153
|
+
])('returns stable source-validation-failed diagnostics for corrupt $source', async (input) => {
|
|
154
|
+
const state = { bytes: input.bytes, reads: 0 };
|
|
155
|
+
const result = await runImport(
|
|
156
|
+
meta(
|
|
157
|
+
input.source,
|
|
158
|
+
input.source.endsWith('.hdr') ? { colorSpace: 'linear' } : undefined,
|
|
159
|
+
undefined,
|
|
160
|
+
input.source.endsWith('.hdr') ? 'equirect' : 'texture',
|
|
161
|
+
),
|
|
162
|
+
registry(),
|
|
163
|
+
{
|
|
164
|
+
readSource: source(state),
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
expectConversionFailure(result, input.code);
|
|
169
|
+
expect(state.reads).toBe(2);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it.skipIf(!pkgBuilt)('returns a stable diagnostic for corrupt raw Basis bytes', async () => {
|
|
173
|
+
const state = { bytes: new Uint8Array([0, 0, 0, 0]), reads: 0 };
|
|
174
|
+
const result = await runImport(meta('corrupt.basis', { colorSpace: 'linear' }), registry(), {
|
|
175
|
+
readSource: source(state),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
expectConversionFailure(result, 'image-conversion-inspect-basis-inspection-failed');
|
|
179
|
+
expect(state.reads).toBe(2);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('retries corrected bytes through the same registry and preserves the GUID artifact', async () => {
|
|
183
|
+
const state = { bytes: makeCorruptPng(), reads: 0 };
|
|
184
|
+
const importerRegistry = registry();
|
|
185
|
+
const input = meta();
|
|
186
|
+
|
|
187
|
+
const readSource = source(state);
|
|
188
|
+
const rejected = await runImport(input, importerRegistry, { readSource });
|
|
189
|
+
expectConversionFailure(rejected, 'image-conversion-decode-ldr');
|
|
190
|
+
|
|
191
|
+
state.bytes = makePng(1, 1, [1, 2, 3, 255]);
|
|
192
|
+
const repaired = await runImport(input, importerRegistry, { readSource });
|
|
193
|
+
|
|
194
|
+
expect(repaired.ok).toBe(true);
|
|
195
|
+
if (!repaired.ok || 'skipped' in repaired.value) return;
|
|
196
|
+
expect(repaired.value.pack.assets).toHaveLength(1);
|
|
197
|
+
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
198
|
+
guid: GUID,
|
|
199
|
+
artifacts: { body: { mediaType: 'application/x-forgeax-rgba8' } },
|
|
200
|
+
});
|
|
201
|
+
expect(repaired.value.pack).not.toHaveProperty('diagnostics');
|
|
202
|
+
expect(state.reads).toBe(4);
|
|
203
|
+
|
|
204
|
+
const repeated = await runImport(input, importerRegistry, { readSource });
|
|
205
|
+
expect(repeated).toEqual(repaired);
|
|
206
|
+
expect(state.reads).toBe(6);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it.skipIf(!pkgBuilt)('repairs raw Basis color-space settings in the same registry', async () => {
|
|
210
|
+
const state = { bytes: await makeRawBasis(), reads: 0 };
|
|
211
|
+
const importerRegistry = registry();
|
|
212
|
+
let settings: Readonly<Record<string, unknown>> = {};
|
|
213
|
+
const readSource = source(state);
|
|
214
|
+
|
|
215
|
+
const rejected = await runImport(meta('texture.basis', settings), importerRegistry, {
|
|
216
|
+
readSource,
|
|
217
|
+
});
|
|
218
|
+
expectConversionFailure(rejected, 'image-conversion-inspect-basis-color-space-invalid');
|
|
219
|
+
|
|
220
|
+
settings = { colorSpace: 'linear' };
|
|
221
|
+
const repaired = await runImport(meta('texture.basis', settings), importerRegistry, {
|
|
222
|
+
readSource,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
expect(repaired.ok).toBe(true);
|
|
226
|
+
if (!repaired.ok || 'skipped' in repaired.value) return;
|
|
227
|
+
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
228
|
+
guid: GUID,
|
|
229
|
+
payload: { colorSpace: 'linear', width: 4, height: 4 },
|
|
230
|
+
artifacts: {
|
|
231
|
+
body: {
|
|
232
|
+
mediaType: 'application/x-forgeax-basis',
|
|
233
|
+
assetCodec: { name: 'basis', container: 'basis', profile: 'uastc-ldr' },
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it.skipIf(!pkgBuilt)('rejects an unsupported raw Basis profile before publication', async () => {
|
|
240
|
+
const state = { bytes: await makeRawBasis(true), reads: 0 };
|
|
241
|
+
const result = await runImport(
|
|
242
|
+
meta('hdr-profile.basis', { colorSpace: 'linear' }),
|
|
243
|
+
registry(),
|
|
244
|
+
{
|
|
245
|
+
readSource: source(state),
|
|
246
|
+
},
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expectConversionFailure(result, 'image-conversion-inspect-basis-profile-unsupported');
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it.skipIf(!pkgBuilt)('repairs KTX2 color-space provenance in the same registry', async () => {
|
|
253
|
+
const state = { bytes: await makeKtx2(), reads: 0 };
|
|
254
|
+
const importerRegistry = registry();
|
|
255
|
+
const readSource = source(state);
|
|
256
|
+
const rejected = await runImport(
|
|
257
|
+
meta('texture.ktx2', { colorSpace: 'srgb' }, { 'image:texture': { colorSpace: 'srgb' } }),
|
|
258
|
+
importerRegistry,
|
|
259
|
+
{ readSource },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expectConversionFailure(rejected, 'image-conversion-inspect-ktx2-color-space-conflict');
|
|
263
|
+
|
|
264
|
+
const repaired = await runImport(
|
|
265
|
+
meta('texture.ktx2', { colorSpace: 'linear' }, { 'image:texture': { colorSpace: 'linear' } }),
|
|
266
|
+
importerRegistry,
|
|
267
|
+
{ readSource },
|
|
268
|
+
);
|
|
269
|
+
expect(repaired.ok).toBe(true);
|
|
270
|
+
if (!repaired.ok || 'skipped' in repaired.value) return;
|
|
271
|
+
expect(repaired.value.pack.assets[0]).toMatchObject({
|
|
272
|
+
guid: GUID,
|
|
273
|
+
payload: { colorSpace: 'linear', width: 4, height: 4, mipmap: false },
|
|
274
|
+
artifacts: {
|
|
275
|
+
body: {
|
|
276
|
+
mediaType: 'image/ktx2',
|
|
277
|
+
assetCodec: { name: 'basis', container: 'ktx2', profile: 'uastc-ldr' },
|
|
278
|
+
},
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it.skipIf(!pkgBuilt)(
|
|
284
|
+
'returns a structured encode refusal before Pack publication',
|
|
285
|
+
async () => {
|
|
286
|
+
const oversized = makePng(4097, 4096, [1, 2, 3, 255]);
|
|
287
|
+
const result = await runImport(
|
|
288
|
+
meta('oversized.png', { colorSpace: 'linear', compressionMode: 'uastc' }),
|
|
289
|
+
registry(),
|
|
290
|
+
{ readSource: source({ bytes: oversized, reads: 0 }) },
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
expectConversionFailure(result, 'image-conversion-encode-ktx2-source-too-large');
|
|
294
|
+
},
|
|
295
|
+
15_000,
|
|
296
|
+
);
|
|
297
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// image-importer-hdr-equirect.test.ts
|
|
2
|
+
// feat-20260630-equirect-kind-internalized-ibl-declarative-skyligh M1 / w1.
|
|
3
|
+
//
|
|
4
|
+
// TDD red->green: locks the .hdr arm of imageImporter producing an
|
|
5
|
+
// EquirectAsset POD (kind:'equirect', rgba16float 2D) for an 'equirect'
|
|
6
|
+
// sub-asset, instead of the prior TextureAsset (kind:'texture') for a
|
|
7
|
+
// 'texture' sub-asset. research F-10 + plan-strategy D-9; orchestrator
|
|
8
|
+
// adjudication: equirect produces a build-time .bin (2D rgba16float image).
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
EquirectAsset,
|
|
12
|
+
ImportContext,
|
|
13
|
+
ImportedAsset,
|
|
14
|
+
ImportSubAsset,
|
|
15
|
+
} from '@forgeax/engine-types';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
import { imageImporter } from '../image-importer.js';
|
|
18
|
+
|
|
19
|
+
const HDR_GUID = '019e3969-1d43-7610-8810-e80dbd491d91';
|
|
20
|
+
|
|
21
|
+
function unwrap(result: {
|
|
22
|
+
readonly ok: boolean;
|
|
23
|
+
readonly value?: { readonly assets: readonly ImportedAsset[] };
|
|
24
|
+
}): readonly ImportedAsset[] {
|
|
25
|
+
return result.ok && result.value !== undefined ? result.value.assets : [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function makeHdrFixture(width: number, height: number): Uint8Array {
|
|
29
|
+
const header = `#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y ${height} +X ${width}\n`;
|
|
30
|
+
const encoder = new TextEncoder();
|
|
31
|
+
const headerBytes = encoder.encode(header);
|
|
32
|
+
|
|
33
|
+
const hi = (width >> 8) & 0xff;
|
|
34
|
+
const lo = width & 0xff;
|
|
35
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
36
|
+
|
|
37
|
+
const chRun = new Uint8Array([128 + width, 128]);
|
|
38
|
+
const scanlineBytes = 4 + 4 * 2;
|
|
39
|
+
const pixelBytes = new Uint8Array(height * scanlineBytes);
|
|
40
|
+
|
|
41
|
+
for (let y = 0; y < height; y++) {
|
|
42
|
+
let off = y * scanlineBytes;
|
|
43
|
+
pixelBytes.set(prefix, off);
|
|
44
|
+
off += 4;
|
|
45
|
+
pixelBytes.set(chRun, off);
|
|
46
|
+
off += 2;
|
|
47
|
+
pixelBytes.set(chRun, off);
|
|
48
|
+
off += 2;
|
|
49
|
+
pixelBytes.set(chRun, off);
|
|
50
|
+
off += 2;
|
|
51
|
+
pixelBytes.set(chRun, off);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const total = new Uint8Array(headerBytes.length + pixelBytes.length);
|
|
55
|
+
total.set(headerBytes);
|
|
56
|
+
total.set(pixelBytes, headerBytes.length);
|
|
57
|
+
return total;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeHdrCtx(
|
|
61
|
+
source: string,
|
|
62
|
+
bytes: Uint8Array,
|
|
63
|
+
subAssets: readonly ImportSubAsset[],
|
|
64
|
+
importSettings: Readonly<Record<string, unknown>> = {},
|
|
65
|
+
): ImportContext {
|
|
66
|
+
return {
|
|
67
|
+
source,
|
|
68
|
+
readSource: async () => ({ ok: true, value: bytes }),
|
|
69
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
70
|
+
decodeImage: async () => {
|
|
71
|
+
throw new Error('decodeImage not used by imageImporter');
|
|
72
|
+
},
|
|
73
|
+
subAssets,
|
|
74
|
+
importSettings,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe('imageImporter HDR arm produces EquirectAsset (w1)', () => {
|
|
79
|
+
it('(a) .hdr + equirect sub-asset -> EquirectAsset POD kind:"equirect" + rgba16float', async () => {
|
|
80
|
+
const width = 8;
|
|
81
|
+
const height = 4;
|
|
82
|
+
const ctx = makeHdrCtx('env.hdr', makeHdrFixture(width, height), [
|
|
83
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
87
|
+
expect(produced.length).toBe(1);
|
|
88
|
+
const asset = produced[0];
|
|
89
|
+
expect(asset?.guid).toBe(HDR_GUID);
|
|
90
|
+
expect(asset?.kind).toBe('equirect');
|
|
91
|
+
|
|
92
|
+
const payload = asset?.payload as EquirectAsset;
|
|
93
|
+
expect(payload.kind).toBe('equirect');
|
|
94
|
+
expect(payload.format).toBe('rgba16float');
|
|
95
|
+
expect(payload.colorSpace).toBe('linear');
|
|
96
|
+
expect(payload.width).toBe(width);
|
|
97
|
+
expect(payload.height).toBe(height);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('(b) byte length is width * height * 4 * 2 (rgba16f = 8 bytes/pixel)', async () => {
|
|
101
|
+
const width = 8;
|
|
102
|
+
const height = 4;
|
|
103
|
+
const ctx = makeHdrCtx('env.hdr', makeHdrFixture(width, height), [
|
|
104
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
105
|
+
]);
|
|
106
|
+
|
|
107
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
108
|
+
const payload = (produced[0] as { payload: EquirectAsset })?.payload;
|
|
109
|
+
expect(payload.data.length).toBe(width * height * 4 * 2);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('(c) a texture sub-asset with .hdr source is NOT folded (only equirect is)', async () => {
|
|
113
|
+
const ctx = makeHdrCtx('env.hdr', makeHdrFixture(8, 4), [
|
|
114
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'texture' },
|
|
115
|
+
]);
|
|
116
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
117
|
+
expect(produced.length).toBe(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('(d) .hdr extension upper-case is still recognized via toLowerCase()', async () => {
|
|
121
|
+
const ctx = makeHdrCtx('ENV.HDR', makeHdrFixture(8, 2), [
|
|
122
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
123
|
+
]);
|
|
124
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
125
|
+
expect(produced.length).toBe(1);
|
|
126
|
+
expect(produced[0]?.kind).toBe('equirect');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('(e) returns a structured validation error for a corrupt .hdr source (invalid RGBE header)', async () => {
|
|
130
|
+
const corrupt = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
|
131
|
+
const ctx = makeHdrCtx('bad.hdr', corrupt, [
|
|
132
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
133
|
+
]);
|
|
134
|
+
const result = await imageImporter.import(ctx);
|
|
135
|
+
expect(result.ok).toBe(false);
|
|
136
|
+
if (result.ok) return;
|
|
137
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
138
|
+
expect(result.error.detail).toMatchObject({
|
|
139
|
+
diagnostics: [expect.objectContaining({ code: 'image-conversion-decode-hdr' })],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// KTX2 magic identifier (first 12 bytes of every KTX 2.0 container). Used to
|
|
145
|
+
// assert the HDR encode arm produced a real UASTC-HDR KTX2, not raw f16.
|
|
146
|
+
const KTX2_IDENTIFIER = new Uint8Array([
|
|
147
|
+
0xab, 0x4b, 0x54, 0x58, 0x20, 0x32, 0x30, 0xbb, 0x0d, 0x0a, 0x1a, 0x0a,
|
|
148
|
+
]);
|
|
149
|
+
|
|
150
|
+
describe('imageImporter HDR equirect is never block-compressed (feat-20260707 fix)', () => {
|
|
151
|
+
// An equirect is ALWAYS an IBL / skybox source: it drives equirect-to-cube /
|
|
152
|
+
// irradiance / prefilter RENDER passes, and a BC6H (block-compressed) texture
|
|
153
|
+
// is sample-only, never color-renderable. So the HDR arm never encodes an
|
|
154
|
+
// equirect sub-asset -- it always ships raw rgba16float, regardless of the
|
|
155
|
+
// requested compressionMode ('auto' / 'uastc' / 'none' all yield rgba16float).
|
|
156
|
+
it("compressionMode:'auto' HDR equirect -> raw rgba16float (never a KTX2)", async () => {
|
|
157
|
+
const width = 8;
|
|
158
|
+
const height = 4;
|
|
159
|
+
const ctx = makeHdrCtx(
|
|
160
|
+
'env.hdr',
|
|
161
|
+
makeHdrFixture(width, height),
|
|
162
|
+
[{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' }],
|
|
163
|
+
{ colorSpace: 'linear', mipmap: 'none', compressionMode: 'auto' },
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
167
|
+
const payload = (produced[0] as { payload: EquirectAsset })?.payload;
|
|
168
|
+
// Raw rgba16float byte count (w*h*8) and NOT a KTX2 container.
|
|
169
|
+
expect(payload.data.length).toBe(width * height * 4 * 2);
|
|
170
|
+
expect(Array.from(payload.data.subarray(0, 12))).not.toEqual(Array.from(KTX2_IDENTIFIER));
|
|
171
|
+
expect(payload.format).toBe('rgba16float');
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("explicit 'uastc' HDR equirect -> raw rgba16float (never a KTX2)", async () => {
|
|
175
|
+
const width = 8;
|
|
176
|
+
const height = 4;
|
|
177
|
+
const ctx = makeHdrCtx(
|
|
178
|
+
'env.hdr',
|
|
179
|
+
makeHdrFixture(width, height),
|
|
180
|
+
[{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' }],
|
|
181
|
+
{ colorSpace: 'linear', mipmap: 'none', compressionMode: 'uastc' },
|
|
182
|
+
);
|
|
183
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
184
|
+
const payload = (produced[0] as { payload: EquirectAsset })?.payload;
|
|
185
|
+
expect(payload.data.length).toBe(width * height * 4 * 2);
|
|
186
|
+
expect(Array.from(payload.data.subarray(0, 12))).not.toEqual(Array.from(KTX2_IDENTIFIER));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("compressionMode:'none' HDR equirect -> raw rgba16float .bin path", async () => {
|
|
190
|
+
const width = 8;
|
|
191
|
+
const height = 4;
|
|
192
|
+
const ctx = makeHdrCtx(
|
|
193
|
+
'env.hdr',
|
|
194
|
+
makeHdrFixture(width, height),
|
|
195
|
+
[{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' }],
|
|
196
|
+
{ colorSpace: 'linear', mipmap: 'none', compressionMode: 'none' },
|
|
197
|
+
);
|
|
198
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
199
|
+
const payload = (produced[0] as { payload: EquirectAsset })?.payload;
|
|
200
|
+
expect(payload.data.length).toBe(width * height * 4 * 2);
|
|
201
|
+
expect(Array.from(payload.data.subarray(0, 12))).not.toEqual(Array.from(KTX2_IDENTIFIER));
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('AC-02: HDR equirect import is deterministic (double import byte-identical)', async () => {
|
|
205
|
+
const mk = () =>
|
|
206
|
+
makeHdrCtx(
|
|
207
|
+
'env.hdr',
|
|
208
|
+
makeHdrFixture(8, 4),
|
|
209
|
+
[{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' }],
|
|
210
|
+
{ colorSpace: 'linear', mipmap: 'none', compressionMode: 'auto' },
|
|
211
|
+
);
|
|
212
|
+
const a = (unwrap(await imageImporter.import(mk()))[0] as { payload: EquirectAsset }).payload
|
|
213
|
+
.data;
|
|
214
|
+
const b = (unwrap(await imageImporter.import(mk()))[0] as { payload: EquirectAsset }).payload
|
|
215
|
+
.data;
|
|
216
|
+
expect(Array.from(a)).toEqual(Array.from(b));
|
|
217
|
+
});
|
|
218
|
+
});
|