@forgeax/engine-image 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +166 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts +2 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts +2 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer.test.d.ts +2 -0
- package/dist/__tests__/image-importer.test.d.ts.map +1 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/image.unit.test.d.ts +2 -0
- package/dist/__tests__/image.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/make-fixture.d.ts +12 -0
- package/dist/__tests__/make-fixture.d.ts.map +1 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
- package/dist/__tests__/source-key.unit.test.d.ts +2 -0
- package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
- package/dist/decode-image-from-file.d.ts +40 -0
- package/dist/decode-image-from-file.d.ts.map +1 -0
- package/dist/decode-image-from-file.mjs +460 -0
- package/dist/decode-image-from-file.mjs.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/hdr-decoder.d.ts +28 -0
- package/dist/hdr-decoder.d.ts.map +1 -0
- package/dist/hdr-decoder.mjs +212 -0
- package/dist/hdr-decoder.mjs.map +1 -0
- package/dist/image-decoder-browser.d.ts +22 -0
- package/dist/image-decoder-browser.d.ts.map +1 -0
- package/dist/image-decoder-node.d.ts +50 -0
- package/dist/image-decoder-node.d.ts.map +1 -0
- package/dist/image-importer.d.ts +18 -0
- package/dist/image-importer.d.ts.map +1 -0
- package/dist/image-importer.mjs +1085 -0
- package/dist/image-importer.mjs.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +469 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2-encode.d.ts +81 -0
- package/dist/ktx2-encode.d.ts.map +1 -0
- package/dist/ktx2-encode.mjs +98 -0
- package/dist/ktx2-encode.mjs.map +1 -0
- package/dist/parse-image.d.ts +47 -0
- package/dist/parse-image.d.ts.map +1 -0
- package/dist/parse-image.mjs +154 -0
- package/dist/parse-image.mjs.map +1 -0
- package/dist/reimport-reuse-meta.d.ts +81 -0
- package/dist/reimport-reuse-meta.d.ts.map +1 -0
- package/dist/resize-image.d.ts +7 -0
- package/dist/resize-image.d.ts.map +1 -0
- package/dist/result.d.ts +2 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/runtime/asset-decoders.d.ts +4 -0
- package/dist/runtime/asset-decoders.d.ts.map +1 -0
- package/dist/source-key.d.ts +21 -0
- package/dist/source-key.d.ts.map +1 -0
- package/dist/sub-asset-key.d.ts +50 -0
- package/dist/sub-asset-key.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +61 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/package.json +105 -0
- package/src/__tests__/compression-mode.unit.test.ts +104 -0
- package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
- package/src/__tests__/errors.test-d.ts +148 -0
- package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
- package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
- package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
- package/src/__tests__/image-importer.test.ts +12 -0
- package/src/__tests__/image-local-artifacts.test.ts +38 -0
- package/src/__tests__/image.unit.test.ts +1253 -0
- package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
- package/src/__tests__/make-fixture.ts +100 -0
- package/src/__tests__/parse-image-downscale.test.ts +26 -0
- package/src/__tests__/source-key.unit.test.ts +65 -0
- package/src/decode-image-from-file.ts +233 -0
- package/src/errors.ts +60 -0
- package/src/hdr-decoder.ts +255 -0
- package/src/image-decoder-browser.ts +88 -0
- package/src/image-decoder-node.ts +67 -0
- package/src/image-decoders.d.ts +23 -0
- package/src/image-importer.ts +858 -0
- package/src/index.ts +44 -0
- package/src/ktx2-encode.ts +217 -0
- package/src/parse-image.ts +176 -0
- package/src/reimport-reuse-meta.ts +160 -0
- package/src/resize-image.ts +39 -0
- package/src/result.ts +13 -0
- package/src/runtime/asset-decoders.ts +152 -0
- package/src/source-key.ts +41 -0
- package/src/sub-asset-key.ts +67 -0
- package/src/to-asset-pack.ts +95 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import type { ImageError } from '@forgeax/engine-types';
|
|
2
|
+
import { imageError } from './errors.js';
|
|
3
|
+
import type { Result } from './result.js';
|
|
4
|
+
import { err, ok } from './result.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Decoded HDR image POD.
|
|
8
|
+
*
|
|
9
|
+
* `data` is an interleaved RGBA Float32Array (4 floats per pixel, row-major).
|
|
10
|
+
* Color space is always linear (RGBE is radiance).
|
|
11
|
+
*/
|
|
12
|
+
export interface HdrDecoded {
|
|
13
|
+
readonly width: number;
|
|
14
|
+
readonly height: number;
|
|
15
|
+
readonly data: Float32Array;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ASCII code points
|
|
19
|
+
const LF = 0x0a;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Decode a Radiance RGBE (.hdr) byte stream into linear RGBA float pixels.
|
|
23
|
+
*
|
|
24
|
+
* Steps per research F-6:
|
|
25
|
+
* 1. Parse ASCII header: magic (#?RADIANCE), FORMAT=32-bit_rle_rgbe,
|
|
26
|
+
* resolution line (-Y height +X width).
|
|
27
|
+
* 2. Decode per-scanline new-RLE pixel data: 4 independent channel runs.
|
|
28
|
+
* 3. Convert RGBE to float: if E==0 -> 0; else f=2^(E-136), R=(Rm+0.5)*f.
|
|
29
|
+
* 4. Output as interleaved RGBA Float32Array (alpha=1.0).
|
|
30
|
+
*
|
|
31
|
+
* Old-RLE scanlines (prefix not matching 0x02,0x02,hi,lo with correct width)
|
|
32
|
+
* return image-hdr-decode-failed.
|
|
33
|
+
*/
|
|
34
|
+
export function decodeHdr(bytes: Uint8Array): Result<HdrDecoded, ImageError> {
|
|
35
|
+
let pos = 0;
|
|
36
|
+
|
|
37
|
+
// Step 1a: magic -- must start with "#?RADIANCE" or "#?RGBE"
|
|
38
|
+
if (bytes.length < 11) {
|
|
39
|
+
return err(hdrDecodeError('file too short for Radiance HDR header'));
|
|
40
|
+
}
|
|
41
|
+
const magic = String.fromCharCode(...bytes.subarray(0, 11));
|
|
42
|
+
if (magic !== '#?RADIANCE\n' && magic !== '#?RGBE\n') {
|
|
43
|
+
return err(
|
|
44
|
+
hdrDecodeError('missing or invalid Radiance HDR magic; expected #?RADIANCE or #?RGBE'),
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
pos = 11;
|
|
48
|
+
|
|
49
|
+
// Step 1b: key=value lines until empty line
|
|
50
|
+
let formatFound = false;
|
|
51
|
+
while (pos < bytes.length) {
|
|
52
|
+
const lineEnd = findByte(bytes, LF, pos);
|
|
53
|
+
if (lineEnd === -1) return err(hdrDecodeError('header truncated before empty line'));
|
|
54
|
+
const line = asciiSubstring(bytes, pos, lineEnd);
|
|
55
|
+
pos = lineEnd + 1;
|
|
56
|
+
if (line === '') break;
|
|
57
|
+
if (line.startsWith('FORMAT=')) {
|
|
58
|
+
const val = line.slice(7);
|
|
59
|
+
if (val === '32-bit_rle_rgbe') {
|
|
60
|
+
formatFound = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!formatFound) {
|
|
65
|
+
return err(hdrDecodeError('missing or unsupported FORMAT; expected FORMAT=32-bit_rle_rgbe'));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Step 1c: resolution line (-Y height +X width)
|
|
69
|
+
if (pos >= bytes.length) return err(hdrDecodeError('missing resolution line'));
|
|
70
|
+
const resLineEnd = findByte(bytes, LF, pos);
|
|
71
|
+
if (resLineEnd === -1) return err(hdrDecodeError('resolution line truncated'));
|
|
72
|
+
const resLine = asciiSubstring(bytes, pos, resLineEnd);
|
|
73
|
+
pos = resLineEnd + 1;
|
|
74
|
+
const resMatch = /^-Y\s+(\d+)\s+\+X\s+(\d+)\s*$/.exec(resLine);
|
|
75
|
+
if (resMatch === null) {
|
|
76
|
+
return err(
|
|
77
|
+
hdrDecodeError(`unexpected resolution line format: "${resLine}"; expected "-Y H +X W"`),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const height = Number(resMatch[1]);
|
|
81
|
+
const width = Number(resMatch[2]);
|
|
82
|
+
if (height === undefined || width === undefined || height <= 0 || width <= 0) {
|
|
83
|
+
return err(hdrDecodeError(`invalid dimensions: ${width}x${height}`));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Step 2: decode per-scanline new-RLE pixel data
|
|
87
|
+
const pixelCount = width * height;
|
|
88
|
+
const rgbe = new Uint8Array(pixelCount * 4);
|
|
89
|
+
const scanlineBytes = width * 4;
|
|
90
|
+
|
|
91
|
+
for (let y = 0; y < height; y++) {
|
|
92
|
+
if (pos + 4 > bytes.length) {
|
|
93
|
+
return err(hdrDecodeError(`truncated at scanline ${y}: expected 4-byte prefix`));
|
|
94
|
+
}
|
|
95
|
+
const p0 = bytes[pos];
|
|
96
|
+
const p1 = bytes[pos + 1];
|
|
97
|
+
const pHi = bytes[pos + 2];
|
|
98
|
+
const pLo = bytes[pos + 3];
|
|
99
|
+
if (p0 === undefined || p1 === undefined || pHi === undefined || pLo === undefined) {
|
|
100
|
+
return err(hdrDecodeError(`truncated at scanline ${y}`));
|
|
101
|
+
}
|
|
102
|
+
// biome-ignore lint/style/noNonNullAssertion: checked above
|
|
103
|
+
const prefixWidth = (pHi! << 8) | pLo!;
|
|
104
|
+
|
|
105
|
+
if (p0 === 0x02 && p1 === 0x02 && prefixWidth === width && width >= 8 && width <= 32767) {
|
|
106
|
+
// New-RLE: 4 channels independently RLE-encoded, then interleaved
|
|
107
|
+
const decoded = decodeNewRleScanline(bytes, pos + 4, width);
|
|
108
|
+
if (decoded === null) {
|
|
109
|
+
return err(hdrDecodeError(`RLE decode failed at scanline ${y}`));
|
|
110
|
+
}
|
|
111
|
+
// Copy into rgbe interleaved buffer
|
|
112
|
+
const base = y * scanlineBytes;
|
|
113
|
+
const channels = [decoded[0], decoded[1], decoded[2], decoded[3]];
|
|
114
|
+
for (let x = 0; x < width; x++) {
|
|
115
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
116
|
+
// biome-ignore lint/style/noNonNullAssertion: checked at allocation
|
|
117
|
+
rgbe[base + x * 4 + ch] = channels[ch]![x]!;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Advance pos past the 4-byte prefix + the total RLE bytes consumed
|
|
121
|
+
pos += 4 + decoded.totalBytes;
|
|
122
|
+
} else {
|
|
123
|
+
// Old-RLE: not supported
|
|
124
|
+
return err(
|
|
125
|
+
hdrDecodeError(
|
|
126
|
+
'old-RLE format is not supported; only new-RLE (32-bit_rle_rgbe) is accepted',
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Step 3: RGBE to float
|
|
133
|
+
const out = new Float32Array(pixelCount * 4);
|
|
134
|
+
for (let i = 0; i < pixelCount; i++) {
|
|
135
|
+
const base = i * 4;
|
|
136
|
+
// biome-ignore lint/style/noNonNullAssertion: checked at allocation
|
|
137
|
+
const r = rgbe[base]!;
|
|
138
|
+
// biome-ignore lint/style/noNonNullAssertion: checked at allocation
|
|
139
|
+
const g = rgbe[base + 1]!;
|
|
140
|
+
// biome-ignore lint/style/noNonNullAssertion: checked at allocation
|
|
141
|
+
const b = rgbe[base + 2]!;
|
|
142
|
+
// biome-ignore lint/style/noNonNullAssertion: checked at allocation
|
|
143
|
+
const e = rgbe[base + 3]!;
|
|
144
|
+
out[base + 3] = 1.0;
|
|
145
|
+
|
|
146
|
+
if (e === 0) {
|
|
147
|
+
out[base] = 0;
|
|
148
|
+
out[base + 1] = 0;
|
|
149
|
+
out[base + 2] = 0;
|
|
150
|
+
} else {
|
|
151
|
+
// f = 2^(E - 136) = ldexp(1.0, E - 128 - 8)
|
|
152
|
+
// Equivalent: ldexp(1.0 / 256.0, E - 128)
|
|
153
|
+
// But simpler: const f = (1.0 / 256.0) * Math.pow(2, e - 128);
|
|
154
|
+
// Using ldexp: Math.pow(2, e - 136)
|
|
155
|
+
const f = 2 ** (e - 136);
|
|
156
|
+
out[base] = (r + 0.5) * f;
|
|
157
|
+
out[base + 1] = (g + 0.5) * f;
|
|
158
|
+
out[base + 2] = (b + 0.5) * f;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return ok({ width, height, data: out });
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
interface DecodedScanline {
|
|
166
|
+
/** 4 channels, each width bytes */
|
|
167
|
+
readonly 0: Uint8Array;
|
|
168
|
+
readonly 1: Uint8Array;
|
|
169
|
+
readonly 2: Uint8Array;
|
|
170
|
+
readonly 3: Uint8Array;
|
|
171
|
+
readonly totalBytes: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Decode one scanline of new-RLE data into 4 channel arrays.
|
|
176
|
+
* Returns null on failure (data underrun).
|
|
177
|
+
*/
|
|
178
|
+
function decodeNewRleScanline(
|
|
179
|
+
bytes: Uint8Array,
|
|
180
|
+
start: number,
|
|
181
|
+
width: number,
|
|
182
|
+
): DecodedScanline | null {
|
|
183
|
+
const channels: Uint8Array[] = [];
|
|
184
|
+
let cursor = start;
|
|
185
|
+
|
|
186
|
+
for (let ch = 0; ch < 4; ch++) {
|
|
187
|
+
const data = new Uint8Array(width);
|
|
188
|
+
let x = 0;
|
|
189
|
+
while (x < width) {
|
|
190
|
+
if (cursor >= bytes.length) return null;
|
|
191
|
+
// biome-ignore lint/style/noNonNullAssertion: checked above
|
|
192
|
+
const code = bytes[cursor]!;
|
|
193
|
+
cursor++;
|
|
194
|
+
if (code > 128) {
|
|
195
|
+
// RLE run: next byte repeated (code - 128) times
|
|
196
|
+
const count = code - 128;
|
|
197
|
+
if (cursor >= bytes.length || x + count > width) return null;
|
|
198
|
+
// biome-ignore lint/style/noNonNullAssertion: checked above
|
|
199
|
+
const val = bytes[cursor]!;
|
|
200
|
+
cursor++;
|
|
201
|
+
data.fill(val, x, x + count);
|
|
202
|
+
x += count;
|
|
203
|
+
} else {
|
|
204
|
+
// Literal run: next `code` bytes copied verbatim
|
|
205
|
+
if (code === 0) continue;
|
|
206
|
+
if (cursor + code > bytes.length || x + code > width) return null;
|
|
207
|
+
for (let k = 0; k < code; k++) {
|
|
208
|
+
// biome-ignore lint/style/noNonNullAssertion: checked above
|
|
209
|
+
data[x + k] = bytes[cursor + k]!;
|
|
210
|
+
}
|
|
211
|
+
cursor += code;
|
|
212
|
+
x += code;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
channels.push(data);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ch0 = channels[0];
|
|
219
|
+
const ch1 = channels[1];
|
|
220
|
+
const ch2 = channels[2];
|
|
221
|
+
const ch3 = channels[3];
|
|
222
|
+
if (ch0 === undefined || ch1 === undefined || ch2 === undefined || ch3 === undefined) {
|
|
223
|
+
return null; // channels should always have 4 entries
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
0: ch0,
|
|
227
|
+
1: ch1,
|
|
228
|
+
2: ch2,
|
|
229
|
+
3: ch3,
|
|
230
|
+
totalBytes: cursor - start,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function findByte(bytes: Uint8Array, target: number, start: number): number {
|
|
235
|
+
for (let i = start; i < bytes.length; i++) {
|
|
236
|
+
if (bytes[i] === target) return i;
|
|
237
|
+
}
|
|
238
|
+
return -1;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function asciiSubstring(bytes: Uint8Array, start: number, end: number): string {
|
|
242
|
+
let s = '';
|
|
243
|
+
for (let i = start; i < end; i++) {
|
|
244
|
+
// biome-ignore lint/style/noNonNullAssertion: in bounds
|
|
245
|
+
s += String.fromCharCode(bytes[i]!);
|
|
246
|
+
}
|
|
247
|
+
return s;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function hdrDecodeError(reason: string): ImageError {
|
|
251
|
+
return imageError({
|
|
252
|
+
code: 'image-hdr-decode-failed',
|
|
253
|
+
reason,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { DecodedImage, ImageColorSpace, ImageError } from '@forgeax/engine-types';
|
|
2
|
+
import { imageError } from './errors.js';
|
|
3
|
+
import type { Result } from './result.js';
|
|
4
|
+
import { err, ok } from './result.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Browser image decoder (plan-strategy section 3.3 + section 2.4 D Open Q-3).
|
|
8
|
+
*
|
|
9
|
+
* Uses `createImageBitmap(blob, { colorSpaceConversion: 'none' })` to keep
|
|
10
|
+
* the bytes in their authored color space (sRGB / linear); the runtime
|
|
11
|
+
* uploadTexture entry asserts `format <-> colorSpace` consistency at GPU
|
|
12
|
+
* upload time (plan-strategy section 2.5 D Open Q-4 (c)).
|
|
13
|
+
*
|
|
14
|
+
* Pixel readback uses an offscreen 2D canvas + `getImageData`; AI users
|
|
15
|
+
* never call this directly -- it is the browser-mode counterpart of
|
|
16
|
+
* `parseImage` (Node-mode upng / jpeg-js path) consumed by future M2b
|
|
17
|
+
* vite-plugin overlay paths and direct browser-mode loaders. The Node
|
|
18
|
+
* decoder remains the SSOT for parseImage; this file is the browser
|
|
19
|
+
* fallback when running inside vitest browser mode or the Vite dev server.
|
|
20
|
+
*/
|
|
21
|
+
export async function decodeImageInBrowser(
|
|
22
|
+
bytes: Uint8Array,
|
|
23
|
+
mime: 'image/png' | 'image/jpeg',
|
|
24
|
+
opts: { colorSpace?: ImageColorSpace; mipmap?: boolean } = {},
|
|
25
|
+
): Promise<Result<DecodedImage, ImageError>> {
|
|
26
|
+
if (typeof createImageBitmap !== 'function') {
|
|
27
|
+
return err(
|
|
28
|
+
imageError({
|
|
29
|
+
code: 'image-decode-failed',
|
|
30
|
+
reason: 'createImageBitmap is not available in this environment',
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let bitmap: ImageBitmap;
|
|
36
|
+
try {
|
|
37
|
+
const blob = new Blob([bytes as BlobPart], { type: mime });
|
|
38
|
+
bitmap = await createImageBitmap(blob, {
|
|
39
|
+
colorSpaceConversion: 'none',
|
|
40
|
+
premultiplyAlpha: 'none',
|
|
41
|
+
});
|
|
42
|
+
} catch (e) {
|
|
43
|
+
return err(
|
|
44
|
+
imageError({
|
|
45
|
+
code: 'image-decode-failed',
|
|
46
|
+
reason: e instanceof Error ? e.message : String(e),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Read pixels back via OffscreenCanvas (browser context) -- 2D context is
|
|
52
|
+
// ubiquitous and avoids the WebGPU readback round-trip during disk-side
|
|
53
|
+
// decode (charter P5 producer / consumer split: GPU upload happens inside
|
|
54
|
+
// @forgeax/engine-runtime AssetRegistry.uploadTexture, not here).
|
|
55
|
+
const canvas =
|
|
56
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
57
|
+
? new OffscreenCanvas(bitmap.width, bitmap.height)
|
|
58
|
+
: (() => {
|
|
59
|
+
const c = document.createElement('canvas');
|
|
60
|
+
c.width = bitmap.width;
|
|
61
|
+
c.height = bitmap.height;
|
|
62
|
+
return c;
|
|
63
|
+
})();
|
|
64
|
+
const ctx = canvas.getContext('2d') as
|
|
65
|
+
| OffscreenCanvasRenderingContext2D
|
|
66
|
+
| CanvasRenderingContext2D
|
|
67
|
+
| null;
|
|
68
|
+
if (ctx === null) {
|
|
69
|
+
return err(
|
|
70
|
+
imageError({
|
|
71
|
+
code: 'image-decode-failed',
|
|
72
|
+
reason: 'failed to acquire 2d canvas context for pixel readback',
|
|
73
|
+
}),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
77
|
+
const data = ctx.getImageData(0, 0, bitmap.width, bitmap.height);
|
|
78
|
+
bitmap.close();
|
|
79
|
+
|
|
80
|
+
return ok({
|
|
81
|
+
bytes: new Uint8Array(data.data.buffer.slice(0)),
|
|
82
|
+
width: data.width,
|
|
83
|
+
height: data.height,
|
|
84
|
+
mime,
|
|
85
|
+
colorSpace: opts.colorSpace ?? 'srgb',
|
|
86
|
+
mipmap: opts.mipmap ?? true,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Node-only image decoder shell for @forgeax/engine-image.
|
|
2
|
+
//
|
|
3
|
+
// Lazy-loads `upng-js` (PNG) and `jpeg-js` (JPEG) via dynamic imports so the
|
|
4
|
+
// browser bundle tree-shakes both decoders out of the ESM output (the
|
|
5
|
+
// browser path uses createImageBitmap instead). Migrated from the original
|
|
6
|
+
// packages/runtime/src/ home in feat-20260515-learn-render-getting-started
|
|
7
|
+
// M2 T-M2-02 (Open Q-3 option a; plan-strategy section 2.4) -- runtime now
|
|
8
|
+
// only consumes DecodedImage POD and never imports the decoders.
|
|
9
|
+
//
|
|
10
|
+
// The implementation surface is filled out by T-M2-05; this file is staged
|
|
11
|
+
// here in T-M2-02 so the migration touches a single owner directory and the
|
|
12
|
+
// runtime package never ships a half-migrated state.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Local widening of the upng-js dynamic-import surface (the package ships
|
|
16
|
+
* without a usable @types entry; the d.ts in image-decoders.d.ts only adds
|
|
17
|
+
* the module declaration). Lists the two functions consumed by the Node
|
|
18
|
+
* decoder path; widening here keeps the import site free of `as any` casts
|
|
19
|
+
* (charter P3 explicit failure -- structural typing > escape hatches).
|
|
20
|
+
*/
|
|
21
|
+
export interface UpngModule {
|
|
22
|
+
decode: (bytes: Uint8Array | ArrayBuffer) => {
|
|
23
|
+
width: number;
|
|
24
|
+
height: number;
|
|
25
|
+
depth: number;
|
|
26
|
+
ctype: number;
|
|
27
|
+
data: Uint8Array;
|
|
28
|
+
frames?: unknown[];
|
|
29
|
+
};
|
|
30
|
+
toRGBA8: (img: { width: number; height: number; data: Uint8Array }) => ArrayBuffer[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Local widening of the jpeg-js dynamic-import surface. Mirrors the subset
|
|
35
|
+
* used by the Node decoder; jpeg-js has @types/jpeg-js but we keep a local
|
|
36
|
+
* shape so the d.ts ambient declaration stays the SSOT for runtime resolution
|
|
37
|
+
* (charter P5 producer / consumer split).
|
|
38
|
+
*/
|
|
39
|
+
export interface JpegModule {
|
|
40
|
+
decode: (
|
|
41
|
+
bytes: Uint8Array | ArrayBuffer,
|
|
42
|
+
opts?: { useTArray?: boolean; formatAsRGBA?: boolean },
|
|
43
|
+
) => {
|
|
44
|
+
width: number;
|
|
45
|
+
height: number;
|
|
46
|
+
data: Uint8Array;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Lazy-load the upng-js Node decoder module. Browser bundlers tree-shake
|
|
52
|
+
* this dynamic import out when only the createImageBitmap path is reached
|
|
53
|
+
* at runtime; tsup external keeps the package name from being inlined.
|
|
54
|
+
*/
|
|
55
|
+
export async function loadUpng(): Promise<UpngModule> {
|
|
56
|
+
const mod = (await import('upng-js')) as { default?: UpngModule } & UpngModule;
|
|
57
|
+
return (mod.default ?? mod) as UpngModule;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Lazy-load the jpeg-js Node decoder module. Same tree-shake guarantee as
|
|
62
|
+
* loadUpng -- browser bundles never reach this path.
|
|
63
|
+
*/
|
|
64
|
+
export async function loadJpeg(): Promise<JpegModule> {
|
|
65
|
+
const mod = (await import('jpeg-js')) as { default?: JpegModule } & JpegModule;
|
|
66
|
+
return (mod.default ?? mod) as JpegModule;
|
|
67
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Ambient module declarations for the upng-js (PNG decoder) + jpeg-js (JPEG
|
|
2
|
+
// decoder) dependencies. Both packages ship without shipped .d.ts files
|
|
3
|
+
// (upng-js has no @types; jpeg-js has @types/jpeg-js but we cast through
|
|
4
|
+
// local interfaces to match the subset the Node path uses).
|
|
5
|
+
//
|
|
6
|
+
// The import sites live in src/image-decoder-node.ts (T-M2-05) and are
|
|
7
|
+
// lazy dynamic imports (`await import('upng-js')`) so browser bundles
|
|
8
|
+
// tree-shake them out; tsc needs the module name to resolve nonetheless.
|
|
9
|
+
//
|
|
10
|
+
// For each module we declare only the surface consumed by the Node decoder;
|
|
11
|
+
// widening is handled in consumer site via explicit interface casts.
|
|
12
|
+
//
|
|
13
|
+
// Anchors:
|
|
14
|
+
// - Open Q-3 option a (plan-strategy section 2.4): runtime decoupled from
|
|
15
|
+
// decode APIs; the d.ts + lazy decoder code + upng / jpeg deps were
|
|
16
|
+
// migrated from packages/runtime/src/ to packages/image/src/ in
|
|
17
|
+
// feat-20260515-learn-render-getting-started M2 T-M2-02.
|
|
18
|
+
// - AC-15 grep gate: packages/runtime/src/ contains zero hits of
|
|
19
|
+
// `image-decode` / `decodeImage` after this migration; CI fail-fast
|
|
20
|
+
// enforces the disk-to-memory pipeline isolation (charter P5).
|
|
21
|
+
|
|
22
|
+
declare module 'upng-js';
|
|
23
|
+
declare module 'jpeg-js';
|