@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,590 @@
1
+ import { stat, readFile } from 'fs/promises';
2
+ import { extname, dirname, join } from 'path';
3
+ import { validateMeta } from '@forgeax/engine-pack';
4
+ import { err, ok, IMAGE_ERROR_HINTS } from '@forgeax/engine-types';
5
+ import * as jpeg from 'jpeg-js';
6
+ import * as UPNG from 'upng-js';
7
+
8
+ // src/decode-image-from-file.ts
9
+ var IMAGE_ERROR_EXPECTED = {
10
+ "image-decode-failed": "PNG / JPG byte stream decodes successfully",
11
+ "image-format-unsupported": "mime is one of ['image/png', 'image/jpeg', 'image/x-tga']; uploadTexture format <-> colorSpace family agrees",
12
+ "image-dimension-out-of-bounds": "width and height fall under device caps maxTextureDimension2D (or 16384 hard cap)",
13
+ "image-meta-missing": "<source>.meta.json sidecar (importer: 'image') exists in the same directory",
14
+ "image-hdr-decode-failed": "Radiance RGBE header is valid and pixel data decodes successfully",
15
+ // feat-20260521-sprite-atlas-animation M1 T-03 — vite-plugin-image atlas
16
+ // hook .expected literals (plan-strategy section 2 D-2 + AC-10 a/b/c).
17
+ // ImageErrorImpl construction path is unchanged: the new atlas-* errors
18
+ // flow through `new ImageErrorImpl({ code: 'atlas-...', ...detail })` and
19
+ // pick the .expected string up from this Record at construction time
20
+ // (charter P3 explicit failure SSOT — AI users surface .expected next to
21
+ // .hint after switch (err.code) without parsing the message).
22
+ "atlas-empty-input": "images.length >= 1",
23
+ "atlas-size-exceeded": "image width x height <= maxAtlasSize^2 and each image fits in the atlas footprint",
24
+ "atlas-region-mismatch": "sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight"
25
+ };
26
+ var ImageErrorImpl = class extends Error {
27
+ code;
28
+ expected;
29
+ hint;
30
+ detail;
31
+ constructor(detail) {
32
+ const code = detail.code;
33
+ const expected = IMAGE_ERROR_EXPECTED[code];
34
+ const hint = IMAGE_ERROR_HINTS[code];
35
+ super(`[ImageError ${code}] expected: ${expected}; hint: ${hint}`);
36
+ this.name = "ImageError";
37
+ this.code = code;
38
+ this.expected = expected;
39
+ this.hint = hint;
40
+ this.detail = detail;
41
+ }
42
+ };
43
+ function imageError(detail) {
44
+ return new ImageErrorImpl(detail);
45
+ }
46
+
47
+ // src/hdr-decoder.ts
48
+ var LF = 10;
49
+ function decodeHdr(bytes) {
50
+ let pos = 0;
51
+ if (bytes.length < 11) {
52
+ return err(hdrDecodeError("file too short for Radiance HDR header"));
53
+ }
54
+ const magic = String.fromCharCode(...bytes.subarray(0, 11));
55
+ if (magic !== "#?RADIANCE\n" && magic !== "#?RGBE\n") {
56
+ return err(
57
+ hdrDecodeError("missing or invalid Radiance HDR magic; expected #?RADIANCE or #?RGBE")
58
+ );
59
+ }
60
+ pos = 11;
61
+ let formatFound = false;
62
+ while (pos < bytes.length) {
63
+ const lineEnd = findByte(bytes, LF, pos);
64
+ if (lineEnd === -1) return err(hdrDecodeError("header truncated before empty line"));
65
+ const line = asciiSubstring(bytes, pos, lineEnd);
66
+ pos = lineEnd + 1;
67
+ if (line === "") break;
68
+ if (line.startsWith("FORMAT=")) {
69
+ const val = line.slice(7);
70
+ if (val === "32-bit_rle_rgbe") {
71
+ formatFound = true;
72
+ }
73
+ }
74
+ }
75
+ if (!formatFound) {
76
+ return err(hdrDecodeError("missing or unsupported FORMAT; expected FORMAT=32-bit_rle_rgbe"));
77
+ }
78
+ if (pos >= bytes.length) return err(hdrDecodeError("missing resolution line"));
79
+ const resLineEnd = findByte(bytes, LF, pos);
80
+ if (resLineEnd === -1) return err(hdrDecodeError("resolution line truncated"));
81
+ const resLine = asciiSubstring(bytes, pos, resLineEnd);
82
+ pos = resLineEnd + 1;
83
+ const resMatch = /^-Y\s+(\d+)\s+\+X\s+(\d+)\s*$/.exec(resLine);
84
+ if (resMatch === null) {
85
+ return err(
86
+ hdrDecodeError(`unexpected resolution line format: "${resLine}"; expected "-Y H +X W"`)
87
+ );
88
+ }
89
+ const height = Number(resMatch[1]);
90
+ const width = Number(resMatch[2]);
91
+ if (height === void 0 || width === void 0 || height <= 0 || width <= 0) {
92
+ return err(hdrDecodeError(`invalid dimensions: ${width}x${height}`));
93
+ }
94
+ const pixelCount = width * height;
95
+ const rgbe = new Uint8Array(pixelCount * 4);
96
+ const scanlineBytes = width * 4;
97
+ for (let y = 0; y < height; y++) {
98
+ if (pos + 4 > bytes.length) {
99
+ return err(hdrDecodeError(`truncated at scanline ${y}: expected 4-byte prefix`));
100
+ }
101
+ const p0 = bytes[pos];
102
+ const p1 = bytes[pos + 1];
103
+ const pHi = bytes[pos + 2];
104
+ const pLo = bytes[pos + 3];
105
+ if (p0 === void 0 || p1 === void 0 || pHi === void 0 || pLo === void 0) {
106
+ return err(hdrDecodeError(`truncated at scanline ${y}`));
107
+ }
108
+ const prefixWidth = pHi << 8 | pLo;
109
+ if (p0 === 2 && p1 === 2 && prefixWidth === width && width >= 8 && width <= 32767) {
110
+ const decoded = decodeNewRleScanline(bytes, pos + 4, width);
111
+ if (decoded === null) {
112
+ return err(hdrDecodeError(`RLE decode failed at scanline ${y}`));
113
+ }
114
+ const base = y * scanlineBytes;
115
+ const channels = [decoded[0], decoded[1], decoded[2], decoded[3]];
116
+ for (let x = 0; x < width; x++) {
117
+ for (let ch = 0; ch < 4; ch++) {
118
+ rgbe[base + x * 4 + ch] = channels[ch][x];
119
+ }
120
+ }
121
+ pos += 4 + decoded.totalBytes;
122
+ } else {
123
+ return err(
124
+ hdrDecodeError(
125
+ "old-RLE format is not supported; only new-RLE (32-bit_rle_rgbe) is accepted"
126
+ )
127
+ );
128
+ }
129
+ }
130
+ const out = new Float32Array(pixelCount * 4);
131
+ for (let i = 0; i < pixelCount; i++) {
132
+ const base = i * 4;
133
+ const r = rgbe[base];
134
+ const g = rgbe[base + 1];
135
+ const b = rgbe[base + 2];
136
+ const e = rgbe[base + 3];
137
+ out[base + 3] = 1;
138
+ if (e === 0) {
139
+ out[base] = 0;
140
+ out[base + 1] = 0;
141
+ out[base + 2] = 0;
142
+ } else {
143
+ const f = 2 ** (e - 136);
144
+ out[base] = (r + 0.5) * f;
145
+ out[base + 1] = (g + 0.5) * f;
146
+ out[base + 2] = (b + 0.5) * f;
147
+ }
148
+ }
149
+ return ok({ width, height, data: out });
150
+ }
151
+ function decodeNewRleScanline(bytes, start, width) {
152
+ const channels = [];
153
+ let cursor = start;
154
+ for (let ch = 0; ch < 4; ch++) {
155
+ const data = new Uint8Array(width);
156
+ let x = 0;
157
+ while (x < width) {
158
+ if (cursor >= bytes.length) return null;
159
+ const code = bytes[cursor];
160
+ cursor++;
161
+ if (code > 128) {
162
+ const count = code - 128;
163
+ if (cursor >= bytes.length || x + count > width) return null;
164
+ const val = bytes[cursor];
165
+ cursor++;
166
+ data.fill(val, x, x + count);
167
+ x += count;
168
+ } else {
169
+ if (code === 0) continue;
170
+ if (cursor + code > bytes.length || x + code > width) return null;
171
+ for (let k = 0; k < code; k++) {
172
+ data[x + k] = bytes[cursor + k];
173
+ }
174
+ cursor += code;
175
+ x += code;
176
+ }
177
+ }
178
+ channels.push(data);
179
+ }
180
+ const ch0 = channels[0];
181
+ const ch1 = channels[1];
182
+ const ch2 = channels[2];
183
+ const ch3 = channels[3];
184
+ if (ch0 === void 0 || ch1 === void 0 || ch2 === void 0 || ch3 === void 0) {
185
+ return null;
186
+ }
187
+ return {
188
+ 0: ch0,
189
+ 1: ch1,
190
+ 2: ch2,
191
+ 3: ch3,
192
+ totalBytes: cursor - start
193
+ };
194
+ }
195
+ function findByte(bytes, target, start) {
196
+ for (let i = start; i < bytes.length; i++) {
197
+ if (bytes[i] === target) return i;
198
+ }
199
+ return -1;
200
+ }
201
+ function asciiSubstring(bytes, start, end) {
202
+ let s = "";
203
+ for (let i = start; i < end; i++) {
204
+ s += String.fromCharCode(bytes[i]);
205
+ }
206
+ return s;
207
+ }
208
+ function hdrDecodeError(reason) {
209
+ return imageError({
210
+ code: "image-hdr-decode-failed",
211
+ reason
212
+ });
213
+ }
214
+
215
+ // src/resize-image.ts
216
+ function downscaleRgba(bytes, width, height, maxDimension) {
217
+ if (width <= maxDimension && height <= maxDimension) {
218
+ return { bytes, width, height };
219
+ }
220
+ const scale = maxDimension / Math.max(width, height);
221
+ const targetWidth = Math.max(1, Math.round(width * scale));
222
+ const targetHeight = Math.max(1, Math.round(height * scale));
223
+ const output = new Uint8Array(targetWidth * targetHeight * 4);
224
+ const source = (x, y, channel) => bytes[(y * width + x) * 4 + channel] ?? 0;
225
+ for (let y = 0; y < targetHeight; y += 1) {
226
+ const sourceY = (y + 0.5) * height / targetHeight - 0.5;
227
+ const y0 = Math.max(0, Math.floor(sourceY));
228
+ const y1 = Math.min(height - 1, y0 + 1);
229
+ const yWeight = Math.max(0, Math.min(1, sourceY - y0));
230
+ for (let x = 0; x < targetWidth; x += 1) {
231
+ const sourceX = (x + 0.5) * width / targetWidth - 0.5;
232
+ const x0 = Math.max(0, Math.floor(sourceX));
233
+ const x1 = Math.min(width - 1, x0 + 1);
234
+ const xWeight = Math.max(0, Math.min(1, sourceX - x0));
235
+ const outputOffset = (y * targetWidth + x) * 4;
236
+ for (let channel = 0; channel < 4; channel += 1) {
237
+ const top = source(x0, y0, channel) * (1 - xWeight) + source(x1, y0, channel) * xWeight;
238
+ const bottom = source(x0, y1, channel) * (1 - xWeight) + source(x1, y1, channel) * xWeight;
239
+ output[outputOffset + channel] = Math.round(top * (1 - yWeight) + bottom * yWeight);
240
+ }
241
+ }
242
+ }
243
+ return { bytes: output, width: targetWidth, height: targetHeight };
244
+ }
245
+
246
+ // src/parse-image.ts
247
+ var DEFAULT_MAX_DIMENSION = 16384;
248
+ var SUPPORTED_MIMES = ["image/png", "image/jpeg", "image/x-tga"];
249
+ function parseImage(bytes, mime, opts = {}) {
250
+ if (!SUPPORTED_MIMES.includes(mime)) {
251
+ return err(
252
+ imageError({
253
+ code: "image-format-unsupported",
254
+ actualMime: mime,
255
+ ...opts.path !== void 0 ? { path: opts.path } : {}
256
+ })
257
+ );
258
+ }
259
+ let width = 0;
260
+ let height = 0;
261
+ let rgba;
262
+ try {
263
+ if (mime === "image/png") {
264
+ const upngMod = UPNG.default ?? UPNG;
265
+ const decoded = upngMod.decode(bytes);
266
+ width = decoded.width;
267
+ height = decoded.height;
268
+ const frames = upngMod.toRGBA8(decoded);
269
+ const first = frames[0];
270
+ if (first === void 0) {
271
+ return err(
272
+ imageError({
273
+ code: "image-decode-failed",
274
+ reason: "UPNG.toRGBA8 returned no frames",
275
+ ...opts.path !== void 0 ? { path: opts.path } : {}
276
+ })
277
+ );
278
+ }
279
+ rgba = new Uint8Array(first);
280
+ } else if (mime === "image/jpeg") {
281
+ const jpegMod = jpeg.default ?? jpeg;
282
+ const decoded = jpegMod.decode(bytes, { useTArray: true, formatAsRGBA: true });
283
+ width = decoded.width;
284
+ height = decoded.height;
285
+ rgba = decoded.data;
286
+ } else {
287
+ const decoded = decodeTga(bytes);
288
+ width = decoded.width;
289
+ height = decoded.height;
290
+ rgba = decoded.bytes;
291
+ }
292
+ } catch (e) {
293
+ return err(
294
+ imageError({
295
+ code: "image-decode-failed",
296
+ reason: e instanceof Error ? e.message : String(e),
297
+ ...opts.path !== void 0 ? { path: opts.path } : {}
298
+ })
299
+ );
300
+ }
301
+ const downscaleLimit = opts.downscaleMaxDimension;
302
+ if (downscaleLimit !== void 0 && Number.isInteger(downscaleLimit) && downscaleLimit > 0) {
303
+ const downscaled = downscaleRgba(rgba, width, height, downscaleLimit);
304
+ rgba = downscaled.bytes;
305
+ width = downscaled.width;
306
+ height = downscaled.height;
307
+ }
308
+ const limit = opts.maxDimension ?? DEFAULT_MAX_DIMENSION;
309
+ if (width > limit || height > limit) {
310
+ return err(
311
+ imageError({
312
+ code: "image-dimension-out-of-bounds",
313
+ requested: { width, height },
314
+ limit
315
+ })
316
+ );
317
+ }
318
+ return ok({
319
+ bytes: rgba,
320
+ width,
321
+ height,
322
+ mime,
323
+ colorSpace: opts.colorSpace ?? "srgb",
324
+ mipmap: opts.mipmap ?? true
325
+ });
326
+ }
327
+ function decodeTga(bytes) {
328
+ if (bytes.length < 18) throw new Error("TGA header is truncated");
329
+ const colorMapType = bytes[1];
330
+ const imageType = bytes[2];
331
+ const idLength = bytes[0] ?? 0;
332
+ const width = (bytes[12] ?? 0) | (bytes[13] ?? 0) << 8;
333
+ const height = (bytes[14] ?? 0) | (bytes[15] ?? 0) << 8;
334
+ const pixelDepth = bytes[16] ?? 0;
335
+ const descriptor = bytes[17] ?? 0;
336
+ if (colorMapType !== 0) throw new Error("color-mapped TGA is unsupported");
337
+ if (width < 1 || height < 1) throw new Error("TGA dimensions must be positive");
338
+ const isTrueColor = imageType === 2 || imageType === 10;
339
+ const isGray = imageType === 3 || imageType === 11;
340
+ if (!isTrueColor && !isGray) throw new Error(`TGA image type ${imageType} is unsupported`);
341
+ if (isTrueColor && pixelDepth !== 24 && pixelDepth !== 32) {
342
+ throw new Error(`TGA true-color depth ${pixelDepth} is unsupported`);
343
+ }
344
+ if (isGray && pixelDepth !== 8)
345
+ throw new Error(`TGA grayscale depth ${pixelDepth} is unsupported`);
346
+ const bytesPerPixel = pixelDepth / 8;
347
+ let offset = 18 + idLength;
348
+ const pixelCount = width * height;
349
+ const out = new Uint8Array(pixelCount * 4);
350
+ const topOrigin = (descriptor & 32) !== 0;
351
+ const rightOrigin = (descriptor & 16) !== 0;
352
+ let filePixel = 0;
353
+ const writePixel = (pixel) => {
354
+ if (filePixel >= pixelCount) throw new Error("TGA pixel data has too many pixels");
355
+ const fileX = filePixel % width;
356
+ const fileY = Math.floor(filePixel / width);
357
+ const x = rightOrigin ? width - 1 - fileX : fileX;
358
+ const y = topOrigin ? fileY : height - 1 - fileY;
359
+ const destination = (y * width + x) * 4;
360
+ if (isGray) {
361
+ const value = pixel[0] ?? 0;
362
+ out[destination] = value;
363
+ out[destination + 1] = value;
364
+ out[destination + 2] = value;
365
+ out[destination + 3] = 255;
366
+ } else {
367
+ out[destination] = pixel[2] ?? 0;
368
+ out[destination + 1] = pixel[1] ?? 0;
369
+ out[destination + 2] = pixel[0] ?? 0;
370
+ out[destination + 3] = bytesPerPixel === 4 ? pixel[3] ?? 255 : 255;
371
+ }
372
+ filePixel += 1;
373
+ };
374
+ const readPixel = () => {
375
+ if (offset + bytesPerPixel > bytes.length) throw new Error("TGA pixel data is truncated");
376
+ const pixel = bytes.slice(offset, offset + bytesPerPixel);
377
+ offset += bytesPerPixel;
378
+ return pixel;
379
+ };
380
+ if (imageType === 2 || imageType === 3) {
381
+ while (filePixel < pixelCount) writePixel(readPixel());
382
+ } else {
383
+ while (filePixel < pixelCount) {
384
+ if (offset >= bytes.length) throw new Error("TGA RLE packet header is truncated");
385
+ const packet = bytes[offset++] ?? 0;
386
+ const count = (packet & 127) + 1;
387
+ if (filePixel + count > pixelCount) throw new Error("TGA RLE packet exceeds image bounds");
388
+ if ((packet & 128) !== 0) {
389
+ const pixel = readPixel();
390
+ for (let i = 0; i < count; i++) writePixel(pixel);
391
+ } else {
392
+ for (let i = 0; i < count; i++) writePixel(readPixel());
393
+ }
394
+ }
395
+ }
396
+ if (filePixel !== pixelCount) throw new Error("TGA pixel count mismatch");
397
+ return { width, height, bytes: out };
398
+ }
399
+
400
+ // src/decode-image-from-file.ts
401
+ var EXT_TO_MIME = {
402
+ ".png": "image/png",
403
+ ".jpg": "image/jpeg",
404
+ ".jpeg": "image/jpeg",
405
+ ".tga": "image/x-tga",
406
+ ".hdr": "image/vnd.radiance"
407
+ };
408
+ function deriveSidecarPath(sourcePath) {
409
+ const dir = dirname(sourcePath);
410
+ const base = sourcePath.slice(dir.length + 1);
411
+ return join(dir, `${base}.meta.json`);
412
+ }
413
+ function sidecarSchemaFailureReason() {
414
+ const firstError = validateMeta.errors?.[0];
415
+ if (firstError === void 0) return "sidecar schema validation failed";
416
+ const location = firstError.instancePath === "" ? "/" : firstError.instancePath;
417
+ return `sidecar schema validation failed: ${firstError.keyword} at ${location}`.slice(0, 256);
418
+ }
419
+ function imageSidecarFailureReason(sidecar, mime) {
420
+ const subAsset = sidecar.subAssets[0];
421
+ const settings = sidecar.importSettings;
422
+ const expectedKind = mime === "image/vnd.radiance" ? "equirect" : "texture";
423
+ if (sidecar.importer !== "image") return "image sidecar importer must be image";
424
+ if (sidecar.subAssets.length !== 1 || subAsset === void 0) {
425
+ return "image sidecar must declare one sub-asset";
426
+ }
427
+ if (subAsset.kind !== expectedKind || subAsset.sourceIndex !== 0) {
428
+ return `image sidecar sub-asset must be ${expectedKind} at sourceIndex 0`;
429
+ }
430
+ if (settings.colorSpace !== void 0 && !["srgb", "linear"].includes(settings.colorSpace)) {
431
+ return "image sidecar colorSpace must be srgb or linear";
432
+ }
433
+ if (settings.mipmap !== void 0 && !["auto", "none"].includes(settings.mipmap)) {
434
+ return "image sidecar mipmap must be auto or none";
435
+ }
436
+ if (settings.addressMode !== void 0 && !["repeat", "clamp-to-edge", "mirror-repeat"].includes(settings.addressMode)) {
437
+ return "image sidecar addressMode is unsupported";
438
+ }
439
+ if (settings.filterMode !== void 0 && !["nearest", "linear"].includes(settings.filterMode)) {
440
+ return "image sidecar filterMode is unsupported";
441
+ }
442
+ return void 0;
443
+ }
444
+ async function decodeImageFromFile(sourcePath) {
445
+ const ext = extname(sourcePath).toLowerCase();
446
+ const mime = EXT_TO_MIME[ext];
447
+ if (mime === void 0) {
448
+ return err(
449
+ imageError({
450
+ code: "image-format-unsupported",
451
+ actualMime: `extension '${ext || "<none>"}'`,
452
+ path: sourcePath
453
+ })
454
+ );
455
+ }
456
+ const sidecarPath = deriveSidecarPath(sourcePath);
457
+ try {
458
+ await stat(sourcePath);
459
+ } catch (e) {
460
+ return err(
461
+ imageError({
462
+ code: "image-decode-failed",
463
+ reason: `failed to read source: ${e instanceof Error ? e.message : String(e)}`,
464
+ path: sourcePath
465
+ })
466
+ );
467
+ }
468
+ try {
469
+ await stat(sidecarPath);
470
+ } catch {
471
+ return err(
472
+ imageError({
473
+ code: "image-meta-missing",
474
+ sourcePath,
475
+ expectedSidecarPath: sidecarPath
476
+ })
477
+ );
478
+ }
479
+ let sidecarText;
480
+ try {
481
+ sidecarText = (await readFile(sidecarPath)).toString("utf8");
482
+ } catch (e) {
483
+ return err(
484
+ imageError({
485
+ code: "image-decode-failed",
486
+ reason: `failed to read sidecar: ${e instanceof Error ? e.message : String(e)}`,
487
+ path: sidecarPath
488
+ })
489
+ );
490
+ }
491
+ let parsedSidecar;
492
+ try {
493
+ parsedSidecar = JSON.parse(sidecarText);
494
+ } catch (e) {
495
+ return err(
496
+ imageError({
497
+ code: "image-decode-failed",
498
+ reason: `sidecar JSON parse failed: ${e instanceof Error ? e.message : String(e)}`,
499
+ path: sidecarPath
500
+ })
501
+ );
502
+ }
503
+ if (!validateMeta(parsedSidecar)) {
504
+ return err(
505
+ imageError({
506
+ code: "image-decode-failed",
507
+ reason: sidecarSchemaFailureReason(),
508
+ path: sidecarPath
509
+ })
510
+ );
511
+ }
512
+ const sidecar = parsedSidecar;
513
+ const imageSidecarFailure = imageSidecarFailureReason(sidecar, mime);
514
+ if (imageSidecarFailure !== void 0) {
515
+ return err(
516
+ imageError({
517
+ code: "image-decode-failed",
518
+ reason: imageSidecarFailure,
519
+ path: sidecarPath
520
+ })
521
+ );
522
+ }
523
+ const settings = sidecar.importSettings ?? {};
524
+ const colorSpace = settings.colorSpace ?? "srgb";
525
+ const mipmap = settings.mipmap ?? "auto";
526
+ const addressMode = settings.addressMode ?? "repeat";
527
+ const filterMode = settings.filterMode ?? "linear";
528
+ const guid = sidecar.subAssets[0]?.guid ?? "";
529
+ const meta = {
530
+ guid,
531
+ colorSpace,
532
+ mipmap,
533
+ addressMode,
534
+ filterMode
535
+ };
536
+ let bytes;
537
+ try {
538
+ const buf = await readFile(sourcePath);
539
+ bytes = new Uint8Array(buf);
540
+ } catch (e) {
541
+ return err(
542
+ imageError({
543
+ code: "image-decode-failed",
544
+ reason: `failed to read source: ${e instanceof Error ? e.message : String(e)}`,
545
+ path: sourcePath
546
+ })
547
+ );
548
+ }
549
+ if (mime === "image/vnd.radiance") {
550
+ if (colorSpace !== "linear") {
551
+ return err(
552
+ imageError({
553
+ code: "image-format-unsupported",
554
+ actualMime: `colorSpace conflict: HDR requires linear, got ${colorSpace}`,
555
+ path: sourcePath
556
+ })
557
+ );
558
+ }
559
+ const hdrRes = decodeHdr(bytes);
560
+ if (!hdrRes.ok) return err(hdrRes.error);
561
+ const rgba8 = new Uint8Array(hdrRes.value.width * hdrRes.value.height * 4);
562
+ for (let i = 0; i < hdrRes.value.data.length; i++) {
563
+ const v = hdrRes.value.data[i];
564
+ const clamped = v !== void 0 ? Math.max(0, Math.min(1, v)) : 0;
565
+ rgba8[i] = Math.round(clamped * 255);
566
+ }
567
+ const decoded = {
568
+ bytes: rgba8,
569
+ width: hdrRes.value.width,
570
+ height: hdrRes.value.height,
571
+ mime: "image/jpeg",
572
+ colorSpace: "linear",
573
+ mipmap: false
574
+ };
575
+ return ok({ decoded, meta });
576
+ }
577
+ const r = parseImage(bytes, mime, {
578
+ colorSpace,
579
+ mipmap: mipmap === "auto",
580
+ path: sourcePath
581
+ });
582
+ if (!r.ok) {
583
+ return err(r.error);
584
+ }
585
+ return ok({ decoded: r.value, meta });
586
+ }
587
+
588
+ export { decodeImageFromFile };
589
+ //# sourceMappingURL=decode-image-from-file.mjs.map
590
+ //# sourceMappingURL=decode-image-from-file.mjs.map