@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.
Files changed (104) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +166 -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__/make-fixture.d.ts +12 -0
  25. package/dist/__tests__/make-fixture.d.ts.map +1 -0
  26. package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
  27. package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
  28. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  29. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  30. package/dist/decode-image-from-file.d.ts +40 -0
  31. package/dist/decode-image-from-file.d.ts.map +1 -0
  32. package/dist/decode-image-from-file.mjs +460 -0
  33. package/dist/decode-image-from-file.mjs.map +1 -0
  34. package/dist/errors.d.ts +14 -0
  35. package/dist/errors.d.ts.map +1 -0
  36. package/dist/hdr-decoder.d.ts +28 -0
  37. package/dist/hdr-decoder.d.ts.map +1 -0
  38. package/dist/hdr-decoder.mjs +212 -0
  39. package/dist/hdr-decoder.mjs.map +1 -0
  40. package/dist/image-decoder-browser.d.ts +22 -0
  41. package/dist/image-decoder-browser.d.ts.map +1 -0
  42. package/dist/image-decoder-node.d.ts +50 -0
  43. package/dist/image-decoder-node.d.ts.map +1 -0
  44. package/dist/image-importer.d.ts +18 -0
  45. package/dist/image-importer.d.ts.map +1 -0
  46. package/dist/image-importer.mjs +1085 -0
  47. package/dist/image-importer.mjs.map +1 -0
  48. package/dist/index.d.ts +17 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.mjs +469 -0
  51. package/dist/index.mjs.map +1 -0
  52. package/dist/ktx2-encode.d.ts +81 -0
  53. package/dist/ktx2-encode.d.ts.map +1 -0
  54. package/dist/ktx2-encode.mjs +98 -0
  55. package/dist/ktx2-encode.mjs.map +1 -0
  56. package/dist/parse-image.d.ts +47 -0
  57. package/dist/parse-image.d.ts.map +1 -0
  58. package/dist/parse-image.mjs +154 -0
  59. package/dist/parse-image.mjs.map +1 -0
  60. package/dist/reimport-reuse-meta.d.ts +81 -0
  61. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  62. package/dist/resize-image.d.ts +7 -0
  63. package/dist/resize-image.d.ts.map +1 -0
  64. package/dist/result.d.ts +2 -0
  65. package/dist/result.d.ts.map +1 -0
  66. package/dist/runtime/asset-decoders.d.ts +4 -0
  67. package/dist/runtime/asset-decoders.d.ts.map +1 -0
  68. package/dist/source-key.d.ts +21 -0
  69. package/dist/source-key.d.ts.map +1 -0
  70. package/dist/sub-asset-key.d.ts +50 -0
  71. package/dist/sub-asset-key.d.ts.map +1 -0
  72. package/dist/to-asset-pack.d.ts +61 -0
  73. package/dist/to-asset-pack.d.ts.map +1 -0
  74. package/package.json +105 -0
  75. package/src/__tests__/compression-mode.unit.test.ts +104 -0
  76. package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
  77. package/src/__tests__/errors.test-d.ts +148 -0
  78. package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
  79. package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
  80. package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
  81. package/src/__tests__/image-importer.test.ts +12 -0
  82. package/src/__tests__/image-local-artifacts.test.ts +38 -0
  83. package/src/__tests__/image.unit.test.ts +1253 -0
  84. package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
  85. package/src/__tests__/make-fixture.ts +100 -0
  86. package/src/__tests__/parse-image-downscale.test.ts +26 -0
  87. package/src/__tests__/source-key.unit.test.ts +65 -0
  88. package/src/decode-image-from-file.ts +233 -0
  89. package/src/errors.ts +60 -0
  90. package/src/hdr-decoder.ts +255 -0
  91. package/src/image-decoder-browser.ts +88 -0
  92. package/src/image-decoder-node.ts +67 -0
  93. package/src/image-decoders.d.ts +23 -0
  94. package/src/image-importer.ts +858 -0
  95. package/src/index.ts +44 -0
  96. package/src/ktx2-encode.ts +217 -0
  97. package/src/parse-image.ts +176 -0
  98. package/src/reimport-reuse-meta.ts +160 -0
  99. package/src/resize-image.ts +39 -0
  100. package/src/result.ts +13 -0
  101. package/src/runtime/asset-decoders.ts +152 -0
  102. package/src/source-key.ts +41 -0
  103. package/src/sub-asset-key.ts +67 -0
  104. 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';