@forgeax/engine-image 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +168 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/__tests__/compression-mode.unit.test.d.ts +2 -0
  5. package/dist/__tests__/compression-mode.unit.test.d.ts.map +1 -0
  6. package/dist/__tests__/encode-source-too-large.unit.test.d.ts +2 -0
  7. package/dist/__tests__/encode-source-too-large.unit.test.d.ts.map +1 -0
  8. package/dist/__tests__/errors.test-d.d.ts +2 -0
  9. package/dist/__tests__/errors.test-d.d.ts.map +1 -0
  10. package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts +2 -0
  11. package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts.map +1 -0
  12. package/dist/__tests__/image-importer-hdr-equirect.test.d.ts +2 -0
  13. package/dist/__tests__/image-importer-hdr-equirect.test.d.ts.map +1 -0
  14. package/dist/__tests__/image-importer-topology.unit.test.d.ts +2 -0
  15. package/dist/__tests__/image-importer-topology.unit.test.d.ts.map +1 -0
  16. package/dist/__tests__/image-importer.test.d.ts +2 -0
  17. package/dist/__tests__/image-importer.test.d.ts.map +1 -0
  18. package/dist/__tests__/image-local-artifacts.test.d.ts +2 -0
  19. package/dist/__tests__/image-local-artifacts.test.d.ts.map +1 -0
  20. package/dist/__tests__/image.unit.test.d.ts +2 -0
  21. package/dist/__tests__/image.unit.test.d.ts.map +1 -0
  22. package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts +2 -0
  23. package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts.map +1 -0
  24. package/dist/__tests__/ktx2-encode-mode-owner.test-d.d.ts +2 -0
  25. package/dist/__tests__/ktx2-encode-mode-owner.test-d.d.ts.map +1 -0
  26. package/dist/__tests__/make-fixture.d.ts +12 -0
  27. package/dist/__tests__/make-fixture.d.ts.map +1 -0
  28. package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
  29. package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
  30. package/dist/__tests__/runtime-decode.unit.test.d.ts +2 -0
  31. package/dist/__tests__/runtime-decode.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  33. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/tga.unit.test.d.ts +2 -0
  35. package/dist/__tests__/tga.unit.test.d.ts.map +1 -0
  36. package/dist/decode-image-from-file.d.ts +40 -0
  37. package/dist/decode-image-from-file.d.ts.map +1 -0
  38. package/dist/decode-image-from-file.mjs +590 -0
  39. package/dist/decode-image-from-file.mjs.map +1 -0
  40. package/dist/errors.d.ts +14 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/hdr-decoder.d.ts +28 -0
  43. package/dist/hdr-decoder.d.ts.map +1 -0
  44. package/dist/hdr-decoder.mjs +212 -0
  45. package/dist/hdr-decoder.mjs.map +1 -0
  46. package/dist/image-decoder-browser.d.ts +22 -0
  47. package/dist/image-decoder-browser.d.ts.map +1 -0
  48. package/dist/image-decoder-node.d.ts +50 -0
  49. package/dist/image-decoder-node.d.ts.map +1 -0
  50. package/dist/image-importer.d.ts +15 -0
  51. package/dist/image-importer.d.ts.map +1 -0
  52. package/dist/image-importer.mjs +1107 -0
  53. package/dist/image-importer.mjs.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +503 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/dist/ktx2-encode.d.ts +84 -0
  59. package/dist/ktx2-encode.d.ts.map +1 -0
  60. package/dist/ktx2-encode.mjs +100 -0
  61. package/dist/ktx2-encode.mjs.map +1 -0
  62. package/dist/parse-image.d.ts +48 -0
  63. package/dist/parse-image.d.ts.map +1 -0
  64. package/dist/parse-image.mjs +231 -0
  65. package/dist/parse-image.mjs.map +1 -0
  66. package/dist/reimport-reuse-meta.d.ts +81 -0
  67. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  68. package/dist/resize-image.d.ts +7 -0
  69. package/dist/resize-image.d.ts.map +1 -0
  70. package/dist/result.d.ts +2 -0
  71. package/dist/result.d.ts.map +1 -0
  72. package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts +2 -0
  73. package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts.map +1 -0
  74. package/dist/runtime/asset-decoders.d.ts +4 -0
  75. package/dist/runtime/asset-decoders.d.ts.map +1 -0
  76. package/dist/runtime/decode-image-bytes.d.ts +7 -0
  77. package/dist/runtime/decode-image-bytes.d.ts.map +1 -0
  78. package/dist/runtime/image-error.d.ts +3 -0
  79. package/dist/runtime/image-error.d.ts.map +1 -0
  80. package/dist/source-key.d.ts +21 -0
  81. package/dist/source-key.d.ts.map +1 -0
  82. package/dist/sub-asset-key.d.ts +50 -0
  83. package/dist/sub-asset-key.d.ts.map +1 -0
  84. package/dist/to-asset-pack.d.ts +61 -0
  85. package/dist/to-asset-pack.d.ts.map +1 -0
  86. package/package.json +105 -0
  87. package/src/__tests__/compression-mode.unit.test.ts +104 -0
  88. package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
  89. package/src/__tests__/errors.test-d.ts +148 -0
  90. package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
  91. package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
  92. package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
  93. package/src/__tests__/image-importer.test.ts +12 -0
  94. package/src/__tests__/image-local-artifacts.test.ts +38 -0
  95. package/src/__tests__/image.unit.test.ts +1387 -0
  96. package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
  97. package/src/__tests__/ktx2-encode-mode-owner.test-d.ts +70 -0
  98. package/src/__tests__/make-fixture.ts +100 -0
  99. package/src/__tests__/parse-image-downscale.test.ts +26 -0
  100. package/src/__tests__/runtime-decode.unit.test.ts +22 -0
  101. package/src/__tests__/source-key.unit.test.ts +65 -0
  102. package/src/__tests__/tga.unit.test.ts +59 -0
  103. package/src/decode-image-from-file.ts +298 -0
  104. package/src/errors.ts +60 -0
  105. package/src/hdr-decoder.ts +255 -0
  106. package/src/image-decoder-browser.ts +88 -0
  107. package/src/image-decoder-node.ts +67 -0
  108. package/src/image-decoders.d.ts +23 -0
  109. package/src/image-importer.ts +780 -0
  110. package/src/index.ts +46 -0
  111. package/src/ktx2-encode.ts +221 -0
  112. package/src/parse-image.ts +276 -0
  113. package/src/reimport-reuse-meta.ts +160 -0
  114. package/src/resize-image.ts +39 -0
  115. package/src/result.ts +13 -0
  116. package/src/runtime/__tests__/asset-decoders.unit.test.ts +66 -0
  117. package/src/runtime/asset-decoders.ts +152 -0
  118. package/src/runtime/decode-image-bytes.ts +41 -0
  119. package/src/runtime/image-error.ts +8 -0
  120. package/src/source-key.ts +41 -0
  121. package/src/sub-asset-key.ts +67 -0
  122. package/src/to-asset-pack.ts +95 -0
@@ -0,0 +1,780 @@
1
+ // image-importer.ts - the build-time imageImporter (feat-20260603-asset-import-loader-injection M3 / w23).
2
+ //
3
+ // The `{ key: 'image', import }` Importer the @forgeax/engine-import runner
4
+ // dispatches a `*.meta.json` with `importer: 'image'` to. It absorbs the
5
+ // decode logic that previously lived inline in
6
+ // `@forgeax/engine-vite-plugin-pack`'s generateBundle (research Finding 4,
7
+ // vite-plugin-pack/src/index.ts:354-447): read the source bytes -> parseImage
8
+ // -> tight-packed RGBA `DecodedImage` -> a `TextureAsset` POD whose `data`
9
+ // column carries the imported RGBA bytes under the meta-declared GUID.
10
+ //
11
+ // Why image-importer lives in @forgeax/engine-image (D-9): the image domain
12
+ // logic stays co-located with the package that already owns parseImage (a
13
+ // node-only decoder). Splitting the import into a third package would fracture
14
+ // the SSOT. This module is a NODE-ONLY sub-export
15
+ // (`@forgeax/engine-image/image-importer`, `default: null` under browser
16
+ // conditions) because it statically imports `./parse-image.js` (jpeg-js +
17
+ // upng-js). The browser runtime never reaches it: the texture is decoded at
18
+ // build time and the runtime loader reads the imported `.bin` (M3 strips the
19
+ // runtime decoder edge, AC-15).
20
+ //
21
+ // importSettings folding (colorSpace / mipmap -> TextureAsset.format) mirrors
22
+ // `build-catalog.ts` buildImageMetadata (D-5: `'auto'` -> true / `'none'` ->
23
+ // false, `'srgb'` -> 'rgba8unorm-srgb' / `'linear'` -> 'rgba8unorm') so the
24
+ // importer and the catalog builder derive the same texture metadata. The
25
+ // imported RGBA bytes ride in `TextureAsset.data`; the generateBundle integration (w28)
26
+ // extracts that buffer into a hashed `.bin` and folds width/height/format into
27
+ // the pack-index row.
28
+ //
29
+ // GUID import-stable iron law: every produced `ImportedAsset.guid` comes from
30
+ // `ctx.subAssets[]`, never minted here. A sub-asset of `kind: 'equirect'`
31
+ // (HDR lat-long env map) IS folded by the .hdr arm into an EquirectAsset POD
32
+ // (a single 2D rgba16float image); the cube-to-cube IBL projection is a runtime
33
+ // GPU pass, not a build-time fold (feat-20260630).
34
+
35
+ import { type BasisSourceInspection, ktx2ColorSpace, parseKtx2 } from '@forgeax/engine-codec';
36
+ import type {
37
+ EquirectAsset,
38
+ ImageColorSpace,
39
+ ImportContext,
40
+ ImportedAsset,
41
+ Importer,
42
+ ImportResult,
43
+ TextureAsset,
44
+ } from '@forgeax/engine-types';
45
+ import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
46
+ import type { CompressionMode } from './ktx2-encode.js';
47
+ import { encodeTextureToKtx2, resolveEncodeMode } from './ktx2-encode.js';
48
+ import { parseImage } from './parse-image.js';
49
+
50
+ /** Map a source path / mime hint to the parseImage mime literal. */
51
+ function mimeFromSource(source: string): 'image/png' | 'image/jpeg' | 'image/x-tga' | undefined {
52
+ const lower = source.toLowerCase();
53
+ if (lower.endsWith('.png')) return 'image/png';
54
+ if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
55
+ if (lower.endsWith('.tga')) return 'image/x-tga';
56
+ return undefined;
57
+ }
58
+
59
+ type RequiredImageOutputKind = 'texture' | 'equirect';
60
+
61
+ function requiredImageOutputKind(source: string): RequiredImageOutputKind | undefined {
62
+ const lower = source.toLowerCase();
63
+ if (lower.endsWith('.hdr')) return 'equirect';
64
+ if (mimeFromSource(source) !== undefined || lower.endsWith('.basis') || lower.endsWith('.ktx2')) {
65
+ return 'texture';
66
+ }
67
+ return undefined;
68
+ }
69
+
70
+ function imageOutputTopologyActual(ctx: ImportContext): string {
71
+ if (ctx.subAssets.length === 0) return 'subAssets[] is empty';
72
+ return ctx.subAssets
73
+ .map(
74
+ (sub, index) => `subAssets[${index}]=${sub.kind}:${sub.guid}:sourceIndex=${sub.sourceIndex}`,
75
+ )
76
+ .join(', ');
77
+ }
78
+
79
+ type ImageConversionStage = 'decode' | 'inspect' | 'encode';
80
+
81
+ function imageConversionFailure(
82
+ ctx: ImportContext,
83
+ stage: ImageConversionStage,
84
+ ownerCode: string,
85
+ sourcePath: string,
86
+ expected: string,
87
+ actual: string,
88
+ hint: string,
89
+ ): ImportError {
90
+ const diagnosticSourcePath = sourcePath.length === 0 ? ctx.source : sourcePath;
91
+ return new ImportError({
92
+ code: 'source-validation-failed',
93
+ expected,
94
+ actual,
95
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
96
+ detail: {
97
+ diagnostics: [
98
+ {
99
+ code: `image-conversion-${stage}-${ownerCode}`,
100
+ severity: 'error',
101
+ sourcePath: diagnosticSourcePath,
102
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
103
+ rule: `image-conversion-${stage}`,
104
+ expected,
105
+ actual,
106
+ hint,
107
+ },
108
+ ],
109
+ },
110
+ });
111
+ }
112
+
113
+ function validateImageOutputTopology(
114
+ ctx: ImportContext,
115
+ requiredKind: RequiredImageOutputKind,
116
+ ): ImportError | undefined {
117
+ if (
118
+ ctx.subAssets.length === 1 &&
119
+ ctx.subAssets[0]?.kind === requiredKind &&
120
+ ctx.subAssets[0]?.sourceIndex === 0
121
+ ) {
122
+ return undefined;
123
+ }
124
+
125
+ const expected = `exactly one subAssets[] entry with kind "${requiredKind}" and sourceIndex 0`;
126
+ const actual = imageOutputTopologyActual(ctx);
127
+ return new ImportError({
128
+ code: 'source-validation-failed',
129
+ expected,
130
+ actual,
131
+ hint: IMPORT_ERROR_HINTS['source-validation-failed'],
132
+ detail: {
133
+ diagnostics: [
134
+ {
135
+ code: 'image-subasset-topology',
136
+ severity: 'error',
137
+ sourcePath: `${ctx.source}#subAssets`,
138
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
139
+ rule: 'image-required-single-output',
140
+ expected,
141
+ actual,
142
+ hint: `declare exactly one ${requiredKind} sub-asset with sourceIndex 0 and remove foreign, duplicate, or misplaced entries`,
143
+ },
144
+ ],
145
+ },
146
+ });
147
+ }
148
+
149
+ /** D-5 mipmap token mapping (mirrors build-catalog mipmapTokenToBoolean). */
150
+ function mipmapTokenToBoolean(token: unknown): boolean {
151
+ return token === 'auto' || token === true;
152
+ }
153
+
154
+ /** colorSpace -> GPU format literal (mirrors build-catalog colorSpaceToFormat). */
155
+ function colorSpaceToFormat(colorSpace: ImageColorSpace): GPUTextureFormat {
156
+ return colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm';
157
+ }
158
+
159
+ type BasisProfile = 'etc1s' | 'uastc-ldr' | 'uastc-hdr';
160
+
161
+ async function inspectKtx2Source(
162
+ ctx: ImportContext,
163
+ bytes: Uint8Array,
164
+ metaColorSpace: ImageColorSpace | undefined,
165
+ ): Promise<
166
+ | {
167
+ readonly colorSpace: ImageColorSpace;
168
+ readonly profile: BasisProfile;
169
+ readonly width: number;
170
+ readonly height: number;
171
+ readonly levelCount: number;
172
+ }
173
+ | ImportError
174
+ > {
175
+ const parsed = await parseKtx2(bytes);
176
+ if (!parsed.ok) {
177
+ return imageConversionFailure(
178
+ ctx,
179
+ 'inspect',
180
+ 'ktx2-parse',
181
+ ctx.source,
182
+ 'a valid KTX2 container with a supported Basis profile',
183
+ `codec:${parsed.error.code}`,
184
+ 'repair the KTX2 container and retry the image import',
185
+ );
186
+ }
187
+ const dfdColorSpace = ktx2ColorSpace(parsed.value);
188
+ if (dfdColorSpace === undefined) {
189
+ return imageConversionFailure(
190
+ ctx,
191
+ 'inspect',
192
+ 'ktx2-color-space-missing',
193
+ `${ctx.source}#DFD.transferFunction`,
194
+ 'DFD transfer function to identify sRGB or linear color space',
195
+ 'missing-or-unsupported-transfer-function',
196
+ 'repair the KTX2 DFD color-space declaration before importing',
197
+ );
198
+ }
199
+ if (metaColorSpace !== undefined && metaColorSpace !== dfdColorSpace) {
200
+ return imageConversionFailure(
201
+ ctx,
202
+ 'inspect',
203
+ 'ktx2-color-space-conflict',
204
+ `${ctx.source}#importSettings.colorSpace`,
205
+ 'Meta.colorSpace to match the KTX2 DFD transfer function',
206
+ `meta=${metaColorSpace},dfd=${dfdColorSpace}`,
207
+ 'repair Meta.colorSpace or re-encode the KTX2 source with matching color provenance',
208
+ );
209
+ }
210
+ const { pixelDepth, layerCount, faceCount } = parsed.value.header;
211
+ if (pixelDepth !== 0 || layerCount > 1 || faceCount !== 1) {
212
+ return imageConversionFailure(
213
+ ctx,
214
+ 'inspect',
215
+ 'ktx2-shape-unsupported',
216
+ `${ctx.source}#header`,
217
+ 'a 2D, single-layer, single-face KTX2 texture',
218
+ `pixelDepth=${pixelDepth},layerCount=${layerCount},faceCount=${faceCount}`,
219
+ 're-encode the source as one 2D Basis texture',
220
+ );
221
+ }
222
+ const { initBasisTranscoder } = await import('@forgeax/engine-codec');
223
+ const module = await initBasisTranscoder();
224
+ let file: InstanceType<typeof module.KTX2File>;
225
+ try {
226
+ file = new module.KTX2File(bytes);
227
+ } catch {
228
+ return imageConversionFailure(
229
+ ctx,
230
+ 'inspect',
231
+ 'ktx2-invalid',
232
+ ctx.source,
233
+ 'Basis KTX2File bytes to pass transcoder validation',
234
+ 'basis-file-constructor-failed',
235
+ 'repair or re-encode the KTX2 source before importing',
236
+ );
237
+ }
238
+ try {
239
+ try {
240
+ if (!file.isValid()) {
241
+ return imageConversionFailure(
242
+ ctx,
243
+ 'inspect',
244
+ 'ktx2-invalid',
245
+ ctx.source,
246
+ 'Basis KTX2File bytes to pass transcoder validation',
247
+ 'basis-file-invalid',
248
+ 'repair or re-encode the KTX2 source before importing',
249
+ );
250
+ }
251
+ const profile: BasisProfile | undefined = file.isETC1S()
252
+ ? 'etc1s'
253
+ : file.isHDR() || file.isHDR4x4()
254
+ ? 'uastc-hdr'
255
+ : file.isUASTC_LDR_4x4()
256
+ ? 'uastc-ldr'
257
+ : undefined;
258
+ if (profile === undefined) {
259
+ return imageConversionFailure(
260
+ ctx,
261
+ 'inspect',
262
+ 'ktx2-profile-unsupported',
263
+ `${ctx.source}#Basis.profile`,
264
+ 'ETC1S, UASTC-LDR, or UASTC-HDR Basis profile',
265
+ 'unsupported-basis-profile',
266
+ 're-encode the source with ETC1S, UASTC-LDR, or UASTC-HDR',
267
+ );
268
+ }
269
+ if (profile === 'uastc-hdr' && dfdColorSpace !== 'linear') {
270
+ return imageConversionFailure(
271
+ ctx,
272
+ 'inspect',
273
+ 'ktx2-hdr-color-space-invalid',
274
+ `${ctx.source}#DFD.transferFunction`,
275
+ 'UASTC-HDR source to use linear color space',
276
+ `profile=${profile},dfd=${dfdColorSpace}`,
277
+ 're-encode HDR data with a linear KTX2 DFD transfer function',
278
+ );
279
+ }
280
+ return {
281
+ colorSpace: dfdColorSpace,
282
+ profile,
283
+ width: file.getWidth(),
284
+ height: file.getHeight(),
285
+ levelCount: file.getLevels(),
286
+ };
287
+ } catch {
288
+ return imageConversionFailure(
289
+ ctx,
290
+ 'inspect',
291
+ 'ktx2-invalid',
292
+ ctx.source,
293
+ 'Basis KTX2File bytes to pass transcoder inspection',
294
+ 'basis-inspection-threw',
295
+ 'repair or re-encode the KTX2 source before importing',
296
+ );
297
+ }
298
+ } finally {
299
+ file.close();
300
+ }
301
+ }
302
+
303
+ async function importKtx2Source(ctx: ImportContext, bytes: Uint8Array): Promise<ImportResult> {
304
+ const sourceKey = ctx.subAssets[0]?.sourceKey;
305
+ const metaColorSpace =
306
+ sourceKey === undefined
307
+ ? ctx.importSettings.colorSpace
308
+ : ctx.sourceOverrides?.[sourceKey]?.colorSpace;
309
+ const inspection = await inspectKtx2Source(
310
+ ctx,
311
+ bytes,
312
+ metaColorSpace === 'srgb' || metaColorSpace === 'linear' ? metaColorSpace : undefined,
313
+ );
314
+ if (inspection instanceof ImportError) return { ok: false, error: inspection };
315
+ const out: ImportedAsset[] = [];
316
+ for (const sub of ctx.subAssets) {
317
+ if (sub.kind !== 'texture' || sub.sourceIndex !== 0) continue;
318
+ out.push({
319
+ guid: sub.guid,
320
+ kind: 'texture',
321
+ payload: {
322
+ kind: 'texture',
323
+ width: inspection.width,
324
+ height: inspection.height,
325
+ format: colorSpaceToFormat(inspection.colorSpace),
326
+ data: bytes,
327
+ colorSpace: inspection.colorSpace,
328
+ mipmap: inspection.levelCount > 1,
329
+ mipLevelCount: inspection.levelCount,
330
+ },
331
+ refs: [],
332
+ artifacts: {
333
+ body: {
334
+ mediaType: 'image/ktx2',
335
+ assetCodec: {
336
+ name: 'basis',
337
+ container: 'ktx2',
338
+ profile: inspection.profile,
339
+ version: '1',
340
+ },
341
+ bytes,
342
+ },
343
+ },
344
+ });
345
+ }
346
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
347
+ }
348
+
349
+ function basisMetaColorSpace(ctx: ImportContext): ImageColorSpace | ImportError {
350
+ const sourceKey = ctx.subAssets[0]?.sourceKey;
351
+ const override = sourceKey === undefined ? undefined : ctx.sourceOverrides?.[sourceKey];
352
+ const value = override?.colorSpace ?? ctx.importSettings.colorSpace;
353
+ if (value !== 'srgb' && value !== 'linear') {
354
+ return imageConversionFailure(
355
+ ctx,
356
+ 'inspect',
357
+ 'basis-color-space-invalid',
358
+ `${ctx.source}#Meta.colorSpace`,
359
+ 'Meta.colorSpace to be srgb or linear for raw Basis',
360
+ value === undefined ? 'missing' : `invalid:${String(value)}`,
361
+ 'set Meta.colorSpace to srgb or linear before importing raw Basis bytes',
362
+ );
363
+ }
364
+ return value;
365
+ }
366
+
367
+ async function importBasisSource(ctx: ImportContext, bytes: Uint8Array): Promise<ImportResult> {
368
+ const colorSpace = basisMetaColorSpace(ctx);
369
+ if (colorSpace instanceof ImportError) return { ok: false, error: colorSpace };
370
+ const { initBasisTranscoder, inspectBasisSource } = await import('@forgeax/engine-codec');
371
+ const module = await initBasisTranscoder();
372
+ if (module.BasisFile === undefined) {
373
+ throw new Error('basis-source-inspection-unavailable: transcoder lacks BasisFile');
374
+ }
375
+ let file: InstanceType<typeof module.BasisFile>;
376
+ try {
377
+ file = new module.BasisFile(bytes);
378
+ } catch {
379
+ return {
380
+ ok: false,
381
+ error: imageConversionFailure(
382
+ ctx,
383
+ 'inspect',
384
+ 'basis-source-invalid',
385
+ ctx.source,
386
+ 'raw Basis bytes to pass transcoder validation',
387
+ 'basis-file-invalid',
388
+ 'repair or re-encode the raw Basis source before importing',
389
+ ),
390
+ };
391
+ }
392
+ let inspection: BasisSourceInspection | undefined;
393
+ try {
394
+ try {
395
+ const format = file.getBasisTexFormat();
396
+ const profile =
397
+ format === module.basis_tex_format.cETC1S.value
398
+ ? 'etc1s'
399
+ : format === module.basis_tex_format.cUASTC_LDR_4x4.value
400
+ ? 'uastc-ldr'
401
+ : undefined;
402
+ if (profile === undefined) {
403
+ return {
404
+ ok: false,
405
+ error: imageConversionFailure(
406
+ ctx,
407
+ 'inspect',
408
+ 'basis-profile-unsupported',
409
+ `${ctx.source}#Basis.profile`,
410
+ 'ETC1S or UASTC-LDR raw Basis profile',
411
+ 'unsupported-basis-profile',
412
+ 're-encode the source with ETC1S or UASTC-LDR',
413
+ ),
414
+ };
415
+ }
416
+ const result = inspectBasisSource(file, { colorSpace }, profile);
417
+ if (!result.ok) {
418
+ return {
419
+ ok: false,
420
+ error: imageConversionFailure(
421
+ ctx,
422
+ 'inspect',
423
+ 'basis-inspection-failed',
424
+ ctx.source,
425
+ 'raw Basis source metadata to pass codec inspection',
426
+ `codec:${result.error.code}`,
427
+ 'repair the raw Basis source metadata and retry the import',
428
+ ),
429
+ };
430
+ }
431
+ inspection = result.value;
432
+ } catch {
433
+ return {
434
+ ok: false,
435
+ error: imageConversionFailure(
436
+ ctx,
437
+ 'inspect',
438
+ 'basis-source-invalid',
439
+ ctx.source,
440
+ 'raw Basis bytes to pass transcoder inspection',
441
+ 'basis-inspection-threw',
442
+ 'repair or re-encode the raw Basis source before importing',
443
+ ),
444
+ };
445
+ }
446
+ } finally {
447
+ file.close();
448
+ }
449
+ if (inspection === undefined) {
450
+ return {
451
+ ok: false,
452
+ error: imageConversionFailure(
453
+ ctx,
454
+ 'inspect',
455
+ 'basis-inspection-failed',
456
+ ctx.source,
457
+ 'raw Basis source metadata to be available after codec inspection',
458
+ 'missing-inspection-result',
459
+ 'repair the raw Basis source and retry the import',
460
+ ),
461
+ };
462
+ }
463
+ const out: ImportedAsset[] = [];
464
+ for (const sub of ctx.subAssets) {
465
+ if (sub.kind !== 'texture') continue;
466
+ if (sub.sourceIndex !== 0 || inspection.imageCount !== 1) {
467
+ return {
468
+ ok: false,
469
+ error: imageConversionFailure(
470
+ ctx,
471
+ 'inspect',
472
+ 'basis-image-shape-unsupported',
473
+ `${ctx.source}#Basis.images`,
474
+ 'one raw Basis image at sourceIndex 0',
475
+ `imageCount=${inspection.imageCount},sourceIndex=${sub.sourceIndex}`,
476
+ 're-encode the source as one raw Basis image',
477
+ ),
478
+ };
479
+ }
480
+ out.push({
481
+ guid: sub.guid,
482
+ kind: 'texture',
483
+ payload: {
484
+ kind: 'texture',
485
+ width: inspection.width,
486
+ height: inspection.height,
487
+ format: colorSpaceToFormat(colorSpace),
488
+ data: bytes,
489
+ colorSpace,
490
+ mipmap: inspection.levelCount > 1,
491
+ mipLevelCount: inspection.levelCount,
492
+ },
493
+ refs: [],
494
+ artifacts: {
495
+ body: {
496
+ mediaType: 'application/x-forgeax-basis',
497
+ assetCodec: {
498
+ name: 'basis',
499
+ container: 'basis',
500
+ profile: inspection.profile,
501
+ version: '1',
502
+ },
503
+ bytes,
504
+ },
505
+ },
506
+ });
507
+ }
508
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
509
+ }
510
+
511
+ /**
512
+ * Read the sidecar compressionMode token (D-12 / M3 w18).
513
+ *
514
+ * M3 SEQUENCING CONSTRAINT (plan R-9): the default is hard-wired to `'none'`.
515
+ * An absent / unrecognised token stays `'none'` so existing textures keep the
516
+ * uncompressed `.bin` path -- flipping the default to `'auto'` is M5 (w38).
517
+ */
518
+ function compressionModeToken(token: unknown): CompressionMode {
519
+ if (token === 'auto' || token === 'etc1s' || token === 'uastc' || token === 'none') {
520
+ return token;
521
+ }
522
+ return 'none';
523
+ }
524
+
525
+ /**
526
+ * Basis encode arm (D-5 / M3 w18; HDR arm feat-20260707): when the sidecar
527
+ * requests a compressed delivery (mode resolves to non-'none'), encode the
528
+ * decoded pixels into a Basis KTX2 and return those bytes; the catalog
529
+ * `compression` discriminant is set by the vite-plugin-pack wiring (w20).
530
+ * Returns `null` for the 'none' path so the caller keeps the uncompressed
531
+ * `.bin` bytes unchanged (rgba8 for LDR, rgba16float for HDR).
532
+ *
533
+ * `pixels` is tight-packed RGBA: 8-bit RGBA for LDR (`isHdr: false`),
534
+ * rgba16float bytes for HDR (`isHdr: true`). The `isHdr` signal drives both the
535
+ * 'auto' derivation (-> 'uastc-hdr') and the encoder's HDR source path.
536
+ */
537
+ async function maybeEncodeTextureBytes(
538
+ ctx: ImportContext,
539
+ pixels: Uint8Array,
540
+ width: number,
541
+ height: number,
542
+ compressionMode: CompressionMode,
543
+ colorSpace: ImageColorSpace,
544
+ isHdr: boolean,
545
+ ): Promise<
546
+ | { readonly ok: true; readonly value: Uint8Array | null }
547
+ | { readonly ok: false; readonly error: ImportError }
548
+ > {
549
+ if (resolveEncodeMode(compressionMode, { colorSpace, isHdr }) === 'none') {
550
+ return { ok: true, value: null };
551
+ }
552
+ const result = await encodeTextureToKtx2(pixels, width, height, compressionMode, {
553
+ colorSpace,
554
+ isHdr,
555
+ });
556
+ if (!result.ok) {
557
+ return {
558
+ ok: false,
559
+ error: imageConversionFailure(
560
+ ctx,
561
+ 'encode',
562
+ result.error.code === 'ktx2-encode-source-too-large'
563
+ ? 'ktx2-source-too-large'
564
+ : 'ktx2-encode-refused',
565
+ `${ctx.source}#compressionMode`,
566
+ 'the requested compression mode to accept the decoded image',
567
+ `codec:${result.error.code},mode:${result.error.mode}`,
568
+ result.error.code === 'ktx2-encode-source-too-large'
569
+ ? 'reduce source dimensions or set compressionMode to none'
570
+ : 'repair the source image or compression settings and retry the import',
571
+ ),
572
+ };
573
+ }
574
+ return { ok: true, value: result.value.ktx2 };
575
+ }
576
+
577
+ async function importImage(ctx: ImportContext): Promise<ImportResult> {
578
+ const requiredKind = requiredImageOutputKind(ctx.source);
579
+ if (requiredKind !== undefined) {
580
+ const topologyError = validateImageOutputTopology(ctx, requiredKind);
581
+ if (topologyError !== undefined) return { ok: false, error: topologyError };
582
+ }
583
+
584
+ const read = await ctx.readSource();
585
+ if (!read.ok) {
586
+ return {
587
+ ok: false,
588
+ error: new ImportError({
589
+ code: 'source-read-failed',
590
+ expected: `readable source file at "${ctx.source}"`,
591
+ hint: IMPORT_ERROR_HINTS['source-read-failed'],
592
+ detail: {
593
+ source: ctx.source,
594
+ reason: read.error instanceof Error ? read.error.message : String(read.error),
595
+ },
596
+ }),
597
+ };
598
+ }
599
+ const mime = mimeFromSource(ctx.source);
600
+
601
+ if (ctx.source.toLowerCase().endsWith('.basis')) {
602
+ return importBasisSource(ctx, read.value);
603
+ }
604
+ if (ctx.source.toLowerCase().endsWith('.ktx2')) {
605
+ return importKtx2Source(ctx, read.value);
606
+ }
607
+
608
+ // --- HDR arm (D-6): .hdr equirect source is decoded via decodeHdr -> f16 ---
609
+ if (mime === undefined && ctx.source.toLowerCase().endsWith('.hdr')) {
610
+ const { decodeHdr } = await import('./hdr-decoder.js');
611
+ const decoded = decodeHdr(read.value);
612
+ if (!decoded.ok) {
613
+ return {
614
+ ok: false,
615
+ error: imageConversionFailure(
616
+ ctx,
617
+ 'decode',
618
+ 'hdr',
619
+ ctx.source,
620
+ 'valid Radiance RGBE bytes to decode into an equirect asset',
621
+ `image:${decoded.error.code}`,
622
+ 'repair the HDR header or pixel payload and retry the import',
623
+ ),
624
+ };
625
+ }
626
+ const dec = decoded.value;
627
+ const { halfFloat } = await import('@forgeax/engine-math');
628
+ const f16Bytes = halfFloat.f32ToF16Bytes(
629
+ new Uint8Array(dec.data.buffer, dec.data.byteOffset, dec.data.byteLength),
630
+ );
631
+
632
+ // NO block-compression for equirect (feat-20260707 M5 fix). The .hdr arm
633
+ // folds only `kind:'equirect'` sub-assets, and an equirect is ALWAYS an IBL /
634
+ // skybox source: the runtime drives it through equirect-to-cube / irradiance /
635
+ // prefilter / brdf-lut RENDER passes (Skylight.equirect / SkyboxBackground.
636
+ // equirect). A BC6H (block-compressed) texture is sample-only, never a color-
637
+ // renderable render target, so a BC6H equirect breaks cube projection with a
638
+ // "BC6HRGBUfloat is not color renderable" WebGPU error. The equirect must stay
639
+ // uncompressed rgba16float; the catalog `compression` discriminant is forced to
640
+ // 'none' in import-texture.ts (compressionFor), so the two agree. A purely-
641
+ // sampled HDR 2D texture (never folded by this arm) may still take the
642
+ // UASTC-HDR path via the standard image arm below.
643
+ const out: ImportedAsset[] = [];
644
+ for (const sub of ctx.subAssets) {
645
+ // The .hdr arm folds equirect sub-assets only: a single 2D rgba16float
646
+ // image (the lat-long env map) with a disk identity. The cube-to-cube IBL
647
+ // projection is a GPU-side pass driven by the runtime record arm, not a
648
+ // build-time fold (feat-20260630 w5; orchestrator adjudication: equirect
649
+ // produces a build .bin, unlike the retired cube-texture).
650
+ if (sub.kind !== 'equirect') continue;
651
+ const payload: EquirectAsset = {
652
+ kind: 'equirect',
653
+ width: dec.width,
654
+ height: dec.height,
655
+ format: 'rgba16float',
656
+ data: f16Bytes,
657
+ colorSpace: 'linear',
658
+ };
659
+ out.push({
660
+ guid: sub.guid,
661
+ kind: 'equirect',
662
+ payload,
663
+ refs: [],
664
+ artifacts: {
665
+ body: {
666
+ mediaType: 'application/x-forgeax-rgba16f',
667
+ assetCodec: { name: 'rgba16float' },
668
+ bytes: f16Bytes,
669
+ },
670
+ },
671
+ });
672
+ }
673
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
674
+ }
675
+
676
+ // --- Standard PNG/JPEG path ---
677
+ if (mime === undefined) {
678
+ throw new Error(
679
+ `imageImporter: unsupported source extension for "${ctx.source}" (expected .png / .jpg / .jpeg / .tga / .hdr / .ktx2 / .basis)`,
680
+ );
681
+ }
682
+
683
+ const colorSpace: ImageColorSpace = ctx.importSettings.colorSpace === 'srgb' ? 'srgb' : 'linear';
684
+ const mipmap = mipmapTokenToBoolean(ctx.importSettings.mipmap);
685
+ const compressionMode = compressionModeToken(ctx.importSettings.compressionMode);
686
+ const downscaleMaxDimension =
687
+ typeof ctx.importSettings.downscaleMaxDimension === 'number' &&
688
+ Number.isInteger(ctx.importSettings.downscaleMaxDimension) &&
689
+ ctx.importSettings.downscaleMaxDimension > 0
690
+ ? ctx.importSettings.downscaleMaxDimension
691
+ : undefined;
692
+
693
+ const decoded = parseImage(read.value, mime, {
694
+ colorSpace,
695
+ mipmap,
696
+ ...(downscaleMaxDimension !== undefined ? { downscaleMaxDimension } : {}),
697
+ });
698
+ if (!decoded.ok) {
699
+ return {
700
+ ok: false,
701
+ error: imageConversionFailure(
702
+ ctx,
703
+ 'decode',
704
+ 'ldr',
705
+ ctx.source,
706
+ 'valid PNG, JPEG, or TGA bytes to decode into RGBA pixels',
707
+ `image:${decoded.error.code}`,
708
+ 'repair the source image bytes and retry the import',
709
+ ),
710
+ };
711
+ }
712
+ const dec = decoded.value;
713
+
714
+ // Basis encode arm (M3 w18): null keeps the uncompressed rgba8 `.bin` path.
715
+ const encoded = await maybeEncodeTextureBytes(
716
+ ctx,
717
+ dec.bytes,
718
+ dec.width,
719
+ dec.height,
720
+ compressionMode,
721
+ colorSpace,
722
+ false,
723
+ );
724
+ if (!encoded.ok) return encoded;
725
+ const encodedBytes = encoded.value;
726
+
727
+ const out: ImportedAsset[] = [];
728
+ for (const sub of ctx.subAssets) {
729
+ // Only flat 2D image sub-assets are folded here; cube-texture sub-assets
730
+ // ride the runtime IBL multi-face cook and are intentionally not produced.
731
+ if (sub.kind !== 'texture') continue;
732
+ const payload: TextureAsset = {
733
+ kind: 'texture',
734
+ width: dec.width,
735
+ height: dec.height,
736
+ format: colorSpaceToFormat(colorSpace),
737
+ data: encodedBytes ?? dec.bytes,
738
+ colorSpace,
739
+ mipmap,
740
+ };
741
+ out.push({
742
+ guid: sub.guid,
743
+ kind: 'texture',
744
+ payload,
745
+ refs: [],
746
+ artifacts: {
747
+ body: {
748
+ mediaType: encodedBytes === null ? 'application/x-forgeax-rgba8' : 'image/ktx2',
749
+ assetCodec:
750
+ encodedBytes === null
751
+ ? { name: 'rgba8', version: '1' }
752
+ : {
753
+ name: 'basis',
754
+ container: 'ktx2',
755
+ profile: resolveEncodeMode(compressionMode, { colorSpace, isHdr: false }),
756
+ },
757
+ bytes: encodedBytes ?? dec.bytes,
758
+ },
759
+ },
760
+ });
761
+ }
762
+ return { ok: true, value: { assets: out, sourceDependencies: [] } };
763
+ }
764
+
765
+ /**
766
+ * The image {@link Importer}. Register it into an `ImporterRegistry` so the
767
+ * import runner dispatches `meta.importer === 'image'` sidecars here.
768
+ *
769
+ * @example
770
+ * ```ts
771
+ * import { ImporterRegistry } from '@forgeax/engine-import';
772
+ * import { imageImporter } from '@forgeax/engine-image/image-importer';
773
+ * const importers = new ImporterRegistry();
774
+ * importers.register(imageImporter);
775
+ * ```
776
+ */
777
+ export const imageImporter: Importer = {
778
+ key: 'image',
779
+ import: importImage,
780
+ };