@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.
- package/LICENSE +202 -0
- package/README.md +168 -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__/ktx2-encode-mode-owner.test-d.d.ts +2 -0
- package/dist/__tests__/ktx2-encode-mode-owner.test-d.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__/runtime-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/runtime-decode.unit.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/__tests__/tga.unit.test.d.ts +2 -0
- package/dist/__tests__/tga.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 +590 -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 +15 -0
- package/dist/image-importer.d.ts.map +1 -0
- package/dist/image-importer.mjs +1107 -0
- package/dist/image-importer.mjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +503 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2-encode.d.ts +84 -0
- package/dist/ktx2-encode.d.ts.map +1 -0
- package/dist/ktx2-encode.mjs +100 -0
- package/dist/ktx2-encode.mjs.map +1 -0
- package/dist/parse-image.d.ts +48 -0
- package/dist/parse-image.d.ts.map +1 -0
- package/dist/parse-image.mjs +231 -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/__tests__/asset-decoders.unit.test.d.ts +2 -0
- package/dist/runtime/__tests__/asset-decoders.unit.test.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/runtime/decode-image-bytes.d.ts +7 -0
- package/dist/runtime/decode-image-bytes.d.ts.map +1 -0
- package/dist/runtime/image-error.d.ts +3 -0
- package/dist/runtime/image-error.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 +1387 -0
- package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
- package/src/__tests__/ktx2-encode-mode-owner.test-d.ts +70 -0
- package/src/__tests__/make-fixture.ts +100 -0
- package/src/__tests__/parse-image-downscale.test.ts +26 -0
- package/src/__tests__/runtime-decode.unit.test.ts +22 -0
- package/src/__tests__/source-key.unit.test.ts +65 -0
- package/src/__tests__/tga.unit.test.ts +59 -0
- package/src/decode-image-from-file.ts +298 -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 +780 -0
- package/src/index.ts +46 -0
- package/src/ktx2-encode.ts +221 -0
- package/src/parse-image.ts +276 -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/__tests__/asset-decoders.unit.test.ts +66 -0
- package/src/runtime/asset-decoders.ts +152 -0
- package/src/runtime/decode-image-bytes.ts +41 -0
- package/src/runtime/image-error.ts +8 -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,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
|