@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
package/src/index.ts ADDED
@@ -0,0 +1,44 @@
1
+ // @forgeax/engine-image -- main entry public surface.
2
+ //
3
+ // Browser-safe pure functions translate disk-side image bytes + sidecar
4
+ // settings into POD that the runtime AssetRegistry.uploadTexture path
5
+ // consumes (plan-strategy section 3.2 sequence A; charter P5 producer /
6
+ // consumer split). Node-only decoders (parseImage / decodeImageFromFile)
7
+ // are split into Node-only sub-exports per feat-20260524-browser-safe-subexports
8
+ // and are NOT re-exported from this main entry -- import them via:
9
+ // - `@forgeax/engine-image/parse-image` (Node-only sub-export)
10
+ // - `@forgeax/engine-image/decode-image-from-file` (Node-only sub-export)
11
+ //
12
+ // Main entry surface (browser-safe):
13
+ // - decodeImageInBrowser -- browser-mode createImageBitmap path
14
+ // - toAssetPack(decoded, meta) -- POD -> external-asset-package envelope
15
+ // - subAssetKey / subAssetKeyEqual -- gltf-aligned sub-asset matching
16
+ // - reimportReuseMeta -- two-phase GUID preservation
17
+ // - imageError / ImageErrorImpl -- structured 4-field error class
18
+ // - decodeHdr -- HDR sub-export passthrough (legacy)
19
+ // - loadJpeg / loadUpng -- lazy Node decoder loaders (legacy; OOS-2)
20
+
21
+ export { IMAGE_ERROR_EXPECTED, ImageErrorImpl, imageError } from './errors.js';
22
+ export { decodeHdr } from './hdr-decoder.js';
23
+ export { decodeImageInBrowser } from './image-decoder-browser.js';
24
+ export type { JpegModule, UpngModule } from './image-decoder-node.js';
25
+ export { loadJpeg, loadUpng } from './image-decoder-node.js';
26
+ export type {
27
+ EmittedSubAsset,
28
+ ExistingExternalAssetPackage,
29
+ ExistingSubAsset,
30
+ } from './reimport-reuse-meta.js';
31
+ export { reimportReuseMeta } from './reimport-reuse-meta.js';
32
+ export type { Result, ResultErr, ResultOk } from './result.js';
33
+ export { err, ok } from './result.js';
34
+ export { equirectContribution, textureContribution } from './runtime/asset-decoders';
35
+ export type { ImageSourceKeyLocator } from './source-key.js';
36
+ export { deriveImageSourceKey } from './source-key.js';
37
+ export type { SubAssetKey, SubAssetKeyInput } from './sub-asset-key.js';
38
+ export { subAssetKey, subAssetKeyEqual } from './sub-asset-key.js';
39
+ export type {
40
+ ExternalAssetPackage,
41
+ ExternalSubAsset,
42
+ ImageImportSettings,
43
+ } from './to-asset-pack.js';
44
+ export { toAssetPack } from './to-asset-pack.js';
@@ -0,0 +1,217 @@
1
+ // ktx2-encode.ts -- the image-side offline texture encode arm (M3 w18, D-5).
2
+ //
3
+ // This module is the image-semantic layer of the Basis encode path: it maps the
4
+ // sidecar `compressionMode` control-plane value + the source's colorSpace / HDR
5
+ // signal into a delivery encoding (D-12 'auto' derivation), maps that to the
6
+ // encoder parameters, and drives the low-level `basisEncode` binding that lives
7
+ // in `@forgeax/engine-codec/encode` (the WASM + enum plumbing stays in codec;
8
+ // the image semantics stay here -- D-5 "arm in image, binary in codec/encode").
9
+ //
10
+ // It is a NODE-ONLY build-time module (consumed by imageImporter, itself a
11
+ // node-only sub-export). It never touches GPU (isolation path b) and is never
12
+ // bundled into the runtime (isolation path a/d: runtime does not import
13
+ // @forgeax/engine-image nor @forgeax/engine-codec/encode).
14
+ //
15
+ // M3 sequencing constraint (plan R-9): the 'auto' derivation is implemented and
16
+ // tested here, but the sidecar DEFAULT stays 'none'. The default flip to 'auto'
17
+ // is M5 (w38); doing it here would make every existing texture encode to Basis
18
+ // ahead of loader support and redden the whole fixture fleet.
19
+
20
+ import type { BasisEncodeMode } from '@forgeax/engine-codec/encode';
21
+ import { basisEncode } from '@forgeax/engine-codec/encode';
22
+
23
+ /** The sidecar control-plane value (ImageMetadata.compressionMode). */
24
+ export type CompressionMode = 'auto' | 'etc1s' | 'uastc' | 'none';
25
+
26
+ /** The resolved delivery encoding after 'auto' derivation. `'none'` = no encode. */
27
+ export type ResolvedEncodeMode = 'etc1s' | 'uastc' | 'uastc-hdr' | 'none';
28
+
29
+ /** Source signals that drive 'auto' derivation (D-12) and encoder params. */
30
+ export interface EncodeSourceInfo {
31
+ /** Color transfer of the source (from the sidecar importSettings). */
32
+ readonly colorSpace: 'srgb' | 'linear';
33
+ /** Whether the source is an HDR image (.hdr -> rgba16float). */
34
+ readonly isHdr: boolean;
35
+ }
36
+
37
+ /**
38
+ * Resolve the sidecar `compressionMode` into a concrete delivery encoding.
39
+ *
40
+ * D-12 'auto' derivation (zero new required inputs, derived from existing
41
+ * sidecar signals):
42
+ * - HDR source -> 'uastc-hdr' (BC6H target downstream)
43
+ * - colorSpace 'srgb' -> 'etc1s' (albedo / UI color)
44
+ * - colorSpace 'linear' -> 'uastc' (normals / ORM / data)
45
+ *
46
+ * Explicit modes pass through, except an explicit 'uastc' on an HDR source
47
+ * resolves to 'uastc-hdr' (there is no LDR UASTC path for HDR pixels). 'none'
48
+ * and 'etc1s' pass through verbatim.
49
+ */
50
+ export function resolveEncodeMode(
51
+ mode: CompressionMode,
52
+ source: EncodeSourceInfo,
53
+ ): ResolvedEncodeMode {
54
+ switch (mode) {
55
+ case 'none':
56
+ return 'none';
57
+ case 'etc1s':
58
+ return 'etc1s';
59
+ case 'uastc':
60
+ return source.isHdr ? 'uastc-hdr' : 'uastc';
61
+ case 'auto':
62
+ if (source.isHdr) return 'uastc-hdr';
63
+ return source.colorSpace === 'srgb' ? 'etc1s' : 'uastc';
64
+ }
65
+ }
66
+
67
+ /** Encoder parameters for a resolved delivery encoding. */
68
+ export interface BasisEncodeParams {
69
+ /** The codec-side delivery encoding. */
70
+ readonly mode: BasisEncodeMode;
71
+ /** Write the sRGB transfer function (LDR color only). */
72
+ readonly srgb: boolean;
73
+ /** ETC1S perceptual (sRGB) metric. */
74
+ readonly perceptual: boolean;
75
+ /** Wrap the UASTC-LDR payload in KTX2 zstd supercompression. */
76
+ readonly uastcSupercompression: boolean;
77
+ /** Encoder-side mip generation. M3 keeps this false (offline mips land in M5). */
78
+ readonly mipGen: boolean;
79
+ }
80
+
81
+ /**
82
+ * Map the sidecar mode + source into encoder parameters. Returns `null` for the
83
+ * 'none' path -- the caller keeps the uncompressed `.bin` path and never calls
84
+ * the encoder.
85
+ */
86
+ export function basisEncodeParamsFor(
87
+ mode: CompressionMode,
88
+ source: EncodeSourceInfo,
89
+ ): BasisEncodeParams | null {
90
+ const resolved = resolveEncodeMode(mode, source);
91
+ const srgbColor = source.colorSpace === 'srgb';
92
+ switch (resolved) {
93
+ case 'none':
94
+ return null;
95
+ case 'etc1s':
96
+ return {
97
+ mode: 'etc1s',
98
+ srgb: srgbColor,
99
+ perceptual: srgbColor,
100
+ uastcSupercompression: false,
101
+ mipGen: false,
102
+ };
103
+ case 'uastc':
104
+ return {
105
+ mode: 'uastc-ldr',
106
+ srgb: srgbColor,
107
+ perceptual: srgbColor,
108
+ uastcSupercompression: true,
109
+ mipGen: false,
110
+ };
111
+ case 'uastc-hdr':
112
+ return {
113
+ mode: 'uastc-hdr',
114
+ srgb: false,
115
+ perceptual: false,
116
+ uastcSupercompression: false,
117
+ mipGen: false,
118
+ };
119
+ }
120
+ }
121
+
122
+ /** A successful encode result: KTX2 bytes + the resolved delivery encoding. */
123
+ export interface EncodedTexture {
124
+ readonly ktx2: Uint8Array;
125
+ readonly mode: ResolvedEncodeMode;
126
+ }
127
+
128
+ /**
129
+ * Maximum source pixels the wasm32 Basis encoder accepts (4096x4096 = 16.78 Mpx
130
+ * = 1024*1024*16). This mirrors the raised `BASISU_ENCODER_MAX_SOURCE_IMAGE_PIXELS`
131
+ * ceiling patched into the encoder WASM (packages/codec/scripts/build-wasm.mjs).
132
+ * Sources above this are rejected fast with a structured `ktx2-encode-source-too-large`
133
+ * error rather than reaching the encoder and getting a silent 0-byte failure.
134
+ */
135
+ export const MAX_ENCODE_SOURCE_PIXELS = 4096 * 4096;
136
+
137
+ /** Failure result carrying a short reason (encoder-side or invalid request). */
138
+ export interface EncodeFailure {
139
+ readonly code: 'ktx2-encode-failed' | 'codec-init-failed' | 'ktx2-encode-source-too-large';
140
+ readonly mode: ResolvedEncodeMode;
141
+ readonly reason: string;
142
+ }
143
+
144
+ export type EncodeTextureResult =
145
+ | { readonly ok: true; readonly value: EncodedTexture }
146
+ | { readonly ok: false; readonly error: EncodeFailure };
147
+
148
+ /**
149
+ * Encode source pixels into a Basis KTX2 texture per the sidecar mode.
150
+ *
151
+ * `pixels` is tight-packed RGBA: 8-bit RGBA for LDR sources, rgba16float bytes
152
+ * for HDR sources. Returns `{ ok: false }` with `mode: 'none'` if the sidecar
153
+ * mode resolves to 'none' -- the importer treats that as "no encode, keep the
154
+ * .bin path" rather than an error (see importer callsite w18).
155
+ */
156
+ export async function encodeTextureToKtx2(
157
+ pixels: Uint8Array,
158
+ width: number,
159
+ height: number,
160
+ mode: CompressionMode,
161
+ source: EncodeSourceInfo,
162
+ ): Promise<EncodeTextureResult> {
163
+ const params = basisEncodeParamsFor(mode, source);
164
+ if (params === null) {
165
+ return {
166
+ ok: false,
167
+ error: {
168
+ code: 'ktx2-encode-failed',
169
+ mode: 'none',
170
+ reason: 'compressionMode resolved to none',
171
+ },
172
+ };
173
+ }
174
+ const resolved = resolveEncodeMode(mode, source);
175
+ // Fail fast above the wasm32 encoder's source-pixel ceiling (4096^2). Above
176
+ // this the encoder returns 0 bytes silently; surface a structured, actionable
177
+ // error instead so the importer reports "reduce resolution or set
178
+ // compressionMode:'none'" rather than an opaque encode failure.
179
+ if (width * height > MAX_ENCODE_SOURCE_PIXELS) {
180
+ return {
181
+ ok: false,
182
+ error: {
183
+ code: 'ktx2-encode-source-too-large',
184
+ mode: resolved,
185
+ reason:
186
+ `source ${width}x${height} (${width * height} px) exceeds the ${MAX_ENCODE_SOURCE_PIXELS} px ` +
187
+ '(4096x4096) Basis encode ceiling; reduce the texture resolution to <=4096x4096 ' +
188
+ "or set compressionMode:'none' in the sidecar to keep it uncompressed",
189
+ },
190
+ };
191
+ }
192
+ const result = await basisEncode(pixels, {
193
+ mode: params.mode,
194
+ width,
195
+ height,
196
+ srgb: params.srgb,
197
+ perceptual: params.perceptual,
198
+ uastcSupercompression: params.uastcSupercompression,
199
+ mipGen: params.mipGen,
200
+ });
201
+ if (!result.ok) {
202
+ const reason =
203
+ result.error.detail !== undefined && 'reason' in result.error.detail
204
+ ? String((result.error.detail as { reason: unknown }).reason)
205
+ : result.error.code;
206
+ return {
207
+ ok: false,
208
+ error: {
209
+ code:
210
+ result.error.code === 'codec-init-failed' ? 'codec-init-failed' : 'ktx2-encode-failed',
211
+ mode: resolved,
212
+ reason,
213
+ },
214
+ };
215
+ }
216
+ return { ok: true, value: { ktx2: result.value, mode: resolved } };
217
+ }
@@ -0,0 +1,176 @@
1
+ import type { DecodedImage, ImageColorSpace, ImageError } from '@forgeax/engine-types';
2
+ import * as jpeg from 'jpeg-js';
3
+ // Static imports of upng-js + jpeg-js. tsup external keeps both as runtime
4
+ // require() calls so the browser bundle still tree-shakes them out when
5
+ // only image-decoder-browser.ts (createImageBitmap) is reached at runtime.
6
+ // Synchronous decode is required by the parseImage signature contract (see
7
+ // plan-strategy section 3.3).
8
+ import * as UPNG from 'upng-js';
9
+ import { imageError } from './errors.js';
10
+ import { downscaleRgba } from './resize-image.js';
11
+ import type { Result } from './result.js';
12
+ import { err, ok } from './result.js';
13
+
14
+ export interface ParseImageOptions {
15
+ /**
16
+ * Maximum allowed dimension (width or height). Defaults to 16384 -- the
17
+ * conservative WebGPU `maxTextureDimension2D` floor across desktop +
18
+ * high-end mobile (research F-3 / spec). Test fixtures pass smaller
19
+ * values to exercise the bounds-check code path with tiny fixtures.
20
+ */
21
+ readonly maxDimension?: number;
22
+
23
+ /** Optional asset-owned cooked-payload target; source bytes remain unchanged. */
24
+ readonly downscaleMaxDimension?: number;
25
+
26
+ /**
27
+ * Sidecar `colorSpace` carried over to the DecodedImage POD. Defaults to
28
+ * 'srgb' (typical baseColor / albedo path; plan-strategy section 2.5).
29
+ */
30
+ readonly colorSpace?: ImageColorSpace;
31
+
32
+ /**
33
+ * Sidecar `mipmap` flag carried over to DecodedImage. Defaults to true
34
+ * (auto-generate via runtime mipmap-generator; plan-strategy section 2.6).
35
+ */
36
+ readonly mipmap?: boolean;
37
+
38
+ /**
39
+ * Optional source path to embed in error.detail.path (decode-failed /
40
+ * format-unsupported variants). decodeImageFromFile fills this in;
41
+ * in-memory parseImage callers leave it empty.
42
+ */
43
+ readonly path?: string;
44
+ }
45
+
46
+ const DEFAULT_MAX_DIMENSION = 16384;
47
+
48
+ const SUPPORTED_MIMES: readonly string[] = ['image/png', 'image/jpeg'];
49
+
50
+ interface UpngDecoded {
51
+ readonly width: number;
52
+ readonly height: number;
53
+ readonly data: Uint8Array;
54
+ }
55
+
56
+ interface JpegDecoded {
57
+ readonly width: number;
58
+ readonly height: number;
59
+ readonly data: Uint8Array;
60
+ }
61
+
62
+ /**
63
+ * Pure synchronous image decoder. Translates raw byte stream + mime hint
64
+ * into a tight-packed RGBA `DecodedImage` POD. Surfaces all four
65
+ * ImageErrorCode members through the structured `Result<DecodedImage,
66
+ * ImageError>` return (charter P3 explicit failure; AGENTS.md "Return
67
+ * Result, never throw").
68
+ *
69
+ * Decoder selection follows the mime hint:
70
+ * - `image/png` -> upng-js `UPNG.decode` + `UPNG.toRGBA8`
71
+ * - `image/jpeg` -> jpeg-js `decode` with `formatAsRGBA: true`
72
+ * - other -> `image-format-unsupported`
73
+ *
74
+ * Decoded bytes are always tight-packed RGBA (`bytes.length === width *
75
+ * height * 4`). `colorSpace` and `mipmap` are carried from the supplied
76
+ * `ParseImageOptions` (decodeImageFromFile passes the sidecar settings).
77
+ */
78
+ export function parseImage(
79
+ bytes: Uint8Array,
80
+ mime: string,
81
+ opts: ParseImageOptions = {},
82
+ ): Result<DecodedImage, ImageError> {
83
+ if (!SUPPORTED_MIMES.includes(mime)) {
84
+ return err(
85
+ imageError({
86
+ code: 'image-format-unsupported',
87
+ actualMime: mime,
88
+ ...(opts.path !== undefined ? { path: opts.path } : {}),
89
+ }),
90
+ );
91
+ }
92
+
93
+ let width = 0;
94
+ let height = 0;
95
+ let rgba: Uint8Array;
96
+
97
+ try {
98
+ if (mime === 'image/png') {
99
+ const upngMod: {
100
+ decode: (b: Uint8Array | ArrayBuffer) => UpngDecoded;
101
+ toRGBA8: (i: UpngDecoded) => ArrayBuffer[];
102
+ } = ((UPNG as unknown as { default?: typeof UPNG }).default ?? UPNG) as unknown as {
103
+ decode: (b: Uint8Array | ArrayBuffer) => UpngDecoded;
104
+ toRGBA8: (i: UpngDecoded) => ArrayBuffer[];
105
+ };
106
+ const decoded = upngMod.decode(bytes);
107
+ width = decoded.width;
108
+ height = decoded.height;
109
+ const frames = upngMod.toRGBA8(decoded);
110
+ const first = frames[0];
111
+ if (first === undefined) {
112
+ return err(
113
+ imageError({
114
+ code: 'image-decode-failed',
115
+ reason: 'UPNG.toRGBA8 returned no frames',
116
+ ...(opts.path !== undefined ? { path: opts.path } : {}),
117
+ }),
118
+ );
119
+ }
120
+ rgba = new Uint8Array(first);
121
+ } else {
122
+ // image/jpeg
123
+ const jpegMod: {
124
+ decode: (
125
+ b: Uint8Array | ArrayBuffer,
126
+ o?: { useTArray?: boolean; formatAsRGBA?: boolean },
127
+ ) => JpegDecoded;
128
+ } = ((jpeg as unknown as { default?: typeof jpeg }).default ?? jpeg) as unknown as {
129
+ decode: (
130
+ b: Uint8Array | ArrayBuffer,
131
+ o?: { useTArray?: boolean; formatAsRGBA?: boolean },
132
+ ) => JpegDecoded;
133
+ };
134
+ const decoded = jpegMod.decode(bytes, { useTArray: true, formatAsRGBA: true });
135
+ width = decoded.width;
136
+ height = decoded.height;
137
+ rgba = decoded.data;
138
+ }
139
+ } catch (e) {
140
+ return err(
141
+ imageError({
142
+ code: 'image-decode-failed',
143
+ reason: e instanceof Error ? e.message : String(e),
144
+ ...(opts.path !== undefined ? { path: opts.path } : {}),
145
+ }),
146
+ );
147
+ }
148
+
149
+ const downscaleLimit = opts.downscaleMaxDimension;
150
+ if (downscaleLimit !== undefined && Number.isInteger(downscaleLimit) && downscaleLimit > 0) {
151
+ const downscaled = downscaleRgba(rgba, width, height, downscaleLimit);
152
+ rgba = downscaled.bytes;
153
+ width = downscaled.width;
154
+ height = downscaled.height;
155
+ }
156
+
157
+ const limit = opts.maxDimension ?? DEFAULT_MAX_DIMENSION;
158
+ if (width > limit || height > limit) {
159
+ return err(
160
+ imageError({
161
+ code: 'image-dimension-out-of-bounds',
162
+ requested: { width, height },
163
+ limit,
164
+ }),
165
+ );
166
+ }
167
+
168
+ return ok({
169
+ bytes: rgba,
170
+ width,
171
+ height,
172
+ mime: mime as 'image/png' | 'image/jpeg',
173
+ colorSpace: opts.colorSpace ?? 'srgb',
174
+ mipmap: opts.mipmap ?? true,
175
+ });
176
+ }
@@ -0,0 +1,160 @@
1
+ import { AssetGuid } from '@forgeax/engine-pack/guid';
2
+ import type { DecodedImage, ImageColorSpace } from '@forgeax/engine-types';
3
+
4
+ import { deriveImageSourceKey } from './source-key.js';
5
+ import type { SubAssetKey } from './sub-asset-key.js';
6
+ import { subAssetKey, subAssetKeyEqual } from './sub-asset-key.js';
7
+
8
+ /**
9
+ * Existing `*.meta.json` (importer: 'image') sub-asset record (free-form sub-shape; the
10
+ * meta.schema.json $defs/subAsset locks `guid` + `sourceIndex` + `kind` only).
11
+ * Image importer treats the open shape as a key-bearing tuple; the gltf-loader
12
+ * feat works on the same shape.
13
+ */
14
+ export interface ExistingSubAsset {
15
+ readonly guid: string;
16
+ readonly sourceIndex: number;
17
+ readonly kind: string;
18
+ readonly name?: string;
19
+ readonly sourceKey?: string;
20
+ }
21
+
22
+ /**
23
+ * Existing `*.meta.json` (importer: 'image') package record. Mirrors the
24
+ * meta.schema.json $defs/ExternalAssetPackage shape (research F-9). Only
25
+ * the fields consumed by the reimport reuse algorithm are typed here;
26
+ * importSettings stays a free-form open shape.
27
+ */
28
+ export interface ExistingExternalAssetPackage {
29
+ readonly schemaVersion: string;
30
+ readonly kind: 'external-asset-package';
31
+ readonly importer: 'image';
32
+ readonly source: string;
33
+ readonly importSettings: Readonly<Record<string, unknown>>;
34
+ readonly subAssets: readonly ExistingSubAsset[];
35
+ }
36
+
37
+ /**
38
+ * Sub-asset record emitted into the regenerated `*.meta.json` (importer: 'image')
39
+ * `subAssets[]` slot. Shape matches `ExistingSubAsset` so two consecutive
40
+ * reimports of byte-identical bytes produce byte-identical JSON
41
+ * (AC-16 byte-stable reimport guarantee).
42
+ */
43
+ export interface EmittedSubAsset {
44
+ readonly guid: string;
45
+ readonly sourceIndex: number;
46
+ readonly kind: string;
47
+ readonly name?: string;
48
+ readonly sourceKey?: string;
49
+ }
50
+
51
+ /**
52
+ * Two-phase reimport reuse algorithm (plan-strategy section 2.2 D-4
53
+ * mirroring the in-flight gltf-loader algorithm).
54
+ *
55
+ * For each fresh sub-asset key the importer would emit, this function:
56
+ * 1. Searches the `existing.subAssets[]` for a `subAssetKeyEqual` hit.
57
+ * 2. On hit -- reuses the existing GUID byte-for-byte.
58
+ * 3. On miss -- mints a fresh UUIDv7 via `AssetGuid.random()`.
59
+ *
60
+ * Image disk schema currently emits a single `kind='texture'` sub-asset per
61
+ * source (cubemap face / array layer reserved for future feat); the
62
+ * algorithm scales to multi-sub-asset sources without modification (charter
63
+ * P5 consistent abstraction).
64
+ *
65
+ * Returns the regenerated `subAssets[]` array. The caller (toAssetPack /
66
+ * runAssetImport) splices it into the `*.meta.json` (importer: 'image') envelope.
67
+ */
68
+ export function reimportReuseMeta(
69
+ decoded: DecodedImage,
70
+ existing: ExistingExternalAssetPackage | undefined,
71
+ ): readonly EmittedSubAsset[] {
72
+ // Image disk schema is single-sub-asset; the future multi-asset path
73
+ // iterates a producer-side list of sub-asset keys instead of a single one.
74
+ const freshKeys: readonly SubAssetKey[] = [subAssetKey({ kind: 'texture', sourceIndex: 0 })];
75
+
76
+ const out: EmittedSubAsset[] = [];
77
+ const sourceKey = deriveImageSourceKey('texture');
78
+
79
+ for (let i = 0; i < freshKeys.length; i++) {
80
+ const fresh = freshKeys[i];
81
+ if (fresh === undefined) continue;
82
+
83
+ let reuseGuid: string | undefined;
84
+ if (existing !== undefined) {
85
+ for (const candidate of existing.subAssets) {
86
+ if (sourceKey !== undefined && candidate.sourceKey === sourceKey) {
87
+ reuseGuid = candidate.guid;
88
+ break;
89
+ }
90
+ const candidateKey = subAssetKey({
91
+ kind: candidate.kind,
92
+ sourceIndex: candidate.sourceIndex,
93
+ ...(candidate.name !== undefined ? { name: candidate.name } : {}),
94
+ });
95
+ if (subAssetKeyEqual(fresh, candidateKey)) {
96
+ reuseGuid = candidate.guid;
97
+ break;
98
+ }
99
+ }
100
+ }
101
+
102
+ const guid = reuseGuid ?? AssetGuid.format(AssetGuid.random());
103
+ const emit: EmittedSubAsset =
104
+ fresh.name !== undefined
105
+ ? {
106
+ guid,
107
+ sourceIndex: i,
108
+ kind: fresh.kind,
109
+ name: fresh.name,
110
+ ...(sourceKey === undefined ? {} : { sourceKey }),
111
+ }
112
+ : {
113
+ guid,
114
+ sourceIndex: i,
115
+ kind: fresh.kind,
116
+ ...(sourceKey === undefined ? {} : { sourceKey }),
117
+ };
118
+ out.push(emit);
119
+ }
120
+
121
+ // Ensure decoded ref is consumed (decoded.bytes is the producer signal that
122
+ // a sub-asset exists; without bytes there is nothing to emit). The
123
+ // signature keeps `decoded` in the public surface so the future cubemap /
124
+ // array-layer feat can derive its multi-sub-asset list from `decoded`
125
+ // metadata without a breaking change (charter P5 consistent abstraction).
126
+ void decoded;
127
+
128
+ return out;
129
+ }
130
+
131
+ /**
132
+ * HDR colorSpace validator for *.image.meta.json sidecar importSettings
133
+ * (plan-strategy D-8 -- HDR files must use colorSpace=linear).
134
+ *
135
+ * Extension is matched case-insensitively against the HDR set
136
+ * (`.hdr`, `.exr`). When the extension belongs to the HDR set:
137
+ * - `colorSpace='linear'` -> ok, returns `'linear'`
138
+ * - any other colorSpace -> structured rejection with expected/actual
139
+ *
140
+ * Non-HDR extensions pass through the colorSpace unchanged. Downstream
141
+ * callers (decodeImageFromFile, parseImage) invoke this at the
142
+ * sidecar-read entry to fail-fast when an .hdr meta carries srgb.
143
+ */
144
+ export function validateColorSpaceForHdr(
145
+ ext: string,
146
+ colorSpace: ImageColorSpace,
147
+ ): { ok: true; value: ImageColorSpace } | { ok: false; expected: string; actual: string } {
148
+ const lower = ext.toLowerCase();
149
+ if (lower === '.hdr' || lower === '.exr') {
150
+ if (colorSpace !== 'linear') {
151
+ return {
152
+ ok: false,
153
+ expected: 'colorSpace=linear for HDR source files',
154
+ actual: `colorSpace=${colorSpace}`,
155
+ };
156
+ }
157
+ return { ok: true, value: 'linear' };
158
+ }
159
+ return { ok: true, value: colorSpace };
160
+ }
@@ -0,0 +1,39 @@
1
+ /** Deterministically downscale tight-packed RGBA pixels with bilinear sampling. */
2
+ export function downscaleRgba(
3
+ bytes: Uint8Array,
4
+ width: number,
5
+ height: number,
6
+ maxDimension: number,
7
+ ): { readonly bytes: Uint8Array; readonly width: number; readonly height: number } {
8
+ if (width <= maxDimension && height <= maxDimension) {
9
+ return { bytes, width, height };
10
+ }
11
+
12
+ const scale = maxDimension / Math.max(width, height);
13
+ const targetWidth = Math.max(1, Math.round(width * scale));
14
+ const targetHeight = Math.max(1, Math.round(height * scale));
15
+ const output = new Uint8Array(targetWidth * targetHeight * 4);
16
+ const source = (x: number, y: number, channel: number): number =>
17
+ bytes[(y * width + x) * 4 + channel] ?? 0;
18
+
19
+ for (let y = 0; y < targetHeight; y += 1) {
20
+ const sourceY = ((y + 0.5) * height) / targetHeight - 0.5;
21
+ const y0 = Math.max(0, Math.floor(sourceY));
22
+ const y1 = Math.min(height - 1, y0 + 1);
23
+ const yWeight = Math.max(0, Math.min(1, sourceY - y0));
24
+ for (let x = 0; x < targetWidth; x += 1) {
25
+ const sourceX = ((x + 0.5) * width) / targetWidth - 0.5;
26
+ const x0 = Math.max(0, Math.floor(sourceX));
27
+ const x1 = Math.min(width - 1, x0 + 1);
28
+ const xWeight = Math.max(0, Math.min(1, sourceX - x0));
29
+ const outputOffset = (y * targetWidth + x) * 4;
30
+ for (let channel = 0; channel < 4; channel += 1) {
31
+ const top = source(x0, y0, channel) * (1 - xWeight) + source(x1, y0, channel) * xWeight;
32
+ const bottom = source(x0, y1, channel) * (1 - xWeight) + source(x1, y1, channel) * xWeight;
33
+ output[outputOffset + channel] = Math.round(top * (1 - yWeight) + bottom * yWeight);
34
+ }
35
+ }
36
+ }
37
+
38
+ return { bytes: output, width: targetWidth, height: targetHeight };
39
+ }
package/src/result.ts ADDED
@@ -0,0 +1,13 @@
1
+ // Result<T, E> + ok / err live in `@forgeax/engine-types` (tweak-20260612-result-
2
+ // into-types). They were duplicated here as a lite (plain-object, no `unwrap`)
3
+ // variant; consolidated upstream into the same shape used by rhi / ecs / naga.
4
+ // The barrel here re-exports them so existing
5
+ // `import { err, ok, Result, ResultOk, ResultErr } from '@forgeax/engine-image'`
6
+ // consumers (and intra-package `./result.js` imports) stay unchanged.
7
+ export {
8
+ err,
9
+ ok,
10
+ type Result,
11
+ type ResultErr,
12
+ type ResultOk,
13
+ } from '@forgeax/engine-types';