@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/package.json ADDED
@@ -0,0 +1,105 @@
1
+ {
2
+ "name": "@forgeax/engine-image",
3
+ "version": "0.0.0-dev.8d955ade1c79",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "Apache-2.0",
7
+ "sideEffects": false,
8
+ "description": "Disk-to-memory image importer for forgeax-engine: parseImage / decodeImageFromFile / toAssetPack three pure functions + sub-asset-key + reimport-reuse-meta + correlated ImageError closed union.",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs"
13
+ },
14
+ "./parse-image": {
15
+ "types": "./dist/parse-image.d.ts",
16
+ "node": {
17
+ "import": "./dist/parse-image.mjs"
18
+ },
19
+ "default": null
20
+ },
21
+ "./decode-image-from-file": {
22
+ "types": "./dist/decode-image-from-file.d.ts",
23
+ "node": {
24
+ "import": "./dist/decode-image-from-file.mjs"
25
+ },
26
+ "default": null
27
+ },
28
+ "./hdr-decoder": {
29
+ "types": "./dist/hdr-decoder.d.ts",
30
+ "import": "./dist/hdr-decoder.mjs"
31
+ },
32
+ "./image-importer": {
33
+ "types": "./dist/image-importer.d.ts",
34
+ "node": {
35
+ "import": "./dist/image-importer.mjs"
36
+ },
37
+ "default": null
38
+ },
39
+ "./ktx2-encode": {
40
+ "types": "./dist/ktx2-encode.d.ts",
41
+ "node": {
42
+ "import": "./dist/ktx2-encode.mjs"
43
+ },
44
+ "default": null
45
+ },
46
+ "./package.json": "./package.json"
47
+ },
48
+ "main": "./dist/index.mjs",
49
+ "types": "./dist/index.d.ts",
50
+ "files": [
51
+ "dist",
52
+ "src",
53
+ "README.md",
54
+ "LICENSE"
55
+ ],
56
+ "dependencies": {
57
+ "@forgeax/engine-codec": "0.0.0-dev.8d955ade1c79",
58
+ "@forgeax/engine-math": "0.0.0-dev.8d955ade1c79",
59
+ "@forgeax/engine-pack": "0.0.0-dev.8d955ade1c79",
60
+ "@forgeax/engine-types": "0.0.0-dev.8d955ade1c79",
61
+ "jpeg-js": "^0.4.4",
62
+ "pako": "^1.0.11",
63
+ "upng-js": "^2.1.0"
64
+ },
65
+ "devDependencies": {
66
+ "@forgeax/engine-import": "0.0.0-dev.8d955ade1c79",
67
+ "@types/node": "^20.14.0",
68
+ "@webgpu/types": "^0.1.71"
69
+ },
70
+ "forgeax": {
71
+ "metrics": {
72
+ "bundle-size": {
73
+ "enabled": true,
74
+ "path": "dist/index.mjs",
75
+ "compression": "gzip",
76
+ "expected": 2881,
77
+ "baseline": {
78
+ "source": ".forgeax-harness/forgeax-loop/feat-20260515-learn-render-getting-started/plan-strategy.md#5.4",
79
+ "threshold": 307200
80
+ }
81
+ },
82
+ "fps": {
83
+ "enabled": false,
84
+ "reason": "library package, no runtime canvas; fps is reported by hello-* / learn-render apps that consume DecodedImage POD"
85
+ },
86
+ "bench": {
87
+ "enabled": false,
88
+ "reason": "image decode is one-shot importer-side work; bench focus stays on @forgeax/engine-math for perf-critical hot paths"
89
+ },
90
+ "gate": {
91
+ "enabled": false,
92
+ "reason": "no package-level binary gate; the disk-to-memory pipeline isolation is policed by node scripts/check-image-pipeline-isolation.mjs in M2 milestoneCISweep"
93
+ },
94
+ "spike-report": {
95
+ "enabled": false,
96
+ "reason": "not a spike package; this is a production MVP per plan-strategy D-4 / D-12 (image importer + ImageErrorCode independent closed union)"
97
+ }
98
+ }
99
+ },
100
+ "scripts": {
101
+ "build": "tsup",
102
+ "test": "vitest run",
103
+ "typecheck": "tsc -b"
104
+ }
105
+ }
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { basisEncodeParamsFor, resolveEncodeMode } from '../ktx2-encode.js';
3
+
4
+ /**
5
+ * compressionMode sidecar + 'auto' derivation unit tests (M3 w16).
6
+ *
7
+ * Tests the pure image-side derivation the offline encode arm relies on
8
+ * (D-12), independent of the WASM encoder (round-trip bytes are w15's job;
9
+ * import-texture wiring is w20's job; no browser DOM):
10
+ *
11
+ * - the four sidecar values ('auto' / 'etc1s' / 'uastc' / 'none') each
12
+ * resolve to a delivery encoding;
13
+ * - 'auto' derives deterministically from existing sidecar signals (D-12):
14
+ * colorSpace 'srgb' -> ETC1S (albedo / UI), colorSpace 'linear' -> UASTC
15
+ * (normals / ORM / data), HDR source -> UASTC-HDR;
16
+ * - the 'none' path resolves to 'none' and yields NO encoder params (the
17
+ * importer keeps the existing .bin path -- the encoder is not called);
18
+ * - the encoder param mapping (basis tex format / srgb transfer flag / mip
19
+ * gen) is correct per resolved mode.
20
+ *
21
+ * Note (M3 sequencing constraint): 'auto' derivation is implemented + tested
22
+ * here but is NOT the default value. The sidecar default stays 'none' until
23
+ * M5 (w38) flips it -- this milestone must not make existing textures encode
24
+ * to basis ahead of loader support (plan R-9).
25
+ */
26
+
27
+ describe('resolveEncodeMode -- four sidecar values (M3 w16)', () => {
28
+ it("explicit 'etc1s' resolves to etc1s", () => {
29
+ expect(resolveEncodeMode('etc1s', { colorSpace: 'srgb', isHdr: false })).toBe('etc1s');
30
+ expect(resolveEncodeMode('etc1s', { colorSpace: 'linear', isHdr: false })).toBe('etc1s');
31
+ });
32
+
33
+ it("explicit 'uastc' resolves to uastc (LDR) or uastc-hdr for an HDR source", () => {
34
+ expect(resolveEncodeMode('uastc', { colorSpace: 'linear', isHdr: false })).toBe('uastc');
35
+ expect(resolveEncodeMode('uastc', { colorSpace: 'srgb', isHdr: false })).toBe('uastc');
36
+ expect(resolveEncodeMode('uastc', { colorSpace: 'linear', isHdr: true })).toBe('uastc-hdr');
37
+ });
38
+
39
+ it("explicit 'none' resolves to none", () => {
40
+ expect(resolveEncodeMode('none', { colorSpace: 'srgb', isHdr: false })).toBe('none');
41
+ expect(resolveEncodeMode('none', { colorSpace: 'linear', isHdr: true })).toBe('none');
42
+ });
43
+ });
44
+
45
+ describe("resolveEncodeMode -- 'auto' derivation (D-12, M3 w16)", () => {
46
+ it('srgb color source -> etc1s', () => {
47
+ expect(resolveEncodeMode('auto', { colorSpace: 'srgb', isHdr: false })).toBe('etc1s');
48
+ });
49
+
50
+ it('linear (data) source -> uastc', () => {
51
+ expect(resolveEncodeMode('auto', { colorSpace: 'linear', isHdr: false })).toBe('uastc');
52
+ });
53
+
54
+ it('HDR source -> uastc-hdr (regardless of colorSpace)', () => {
55
+ expect(resolveEncodeMode('auto', { colorSpace: 'linear', isHdr: true })).toBe('uastc-hdr');
56
+ expect(resolveEncodeMode('auto', { colorSpace: 'srgb', isHdr: true })).toBe('uastc-hdr');
57
+ });
58
+ });
59
+
60
+ describe('basisEncodeParamsFor -- mode -> encoder params (M3 w16)', () => {
61
+ it("'none' yields no encoder params (encoder is not called)", () => {
62
+ expect(basisEncodeParamsFor('none', { colorSpace: 'srgb', isHdr: false })).toBeNull();
63
+ });
64
+
65
+ it('etc1s maps to the ETC1S tex format with perceptual metrics for srgb color', () => {
66
+ const p = basisEncodeParamsFor('etc1s', { colorSpace: 'srgb', isHdr: false });
67
+ expect(p).not.toBeNull();
68
+ if (p === null) return;
69
+ expect(p.mode).toBe('etc1s');
70
+ expect(p.srgb).toBe(true);
71
+ expect(p.perceptual).toBe(true);
72
+ expect(p.uastcSupercompression).toBe(false);
73
+ expect(p.mipGen).toBe(false);
74
+ });
75
+
76
+ it('etc1s on a linear source drops the srgb transfer + perceptual flags', () => {
77
+ const p = basisEncodeParamsFor('etc1s', { colorSpace: 'linear', isHdr: false });
78
+ expect(p).not.toBeNull();
79
+ if (p === null) return;
80
+ expect(p.mode).toBe('etc1s');
81
+ expect(p.srgb).toBe(false);
82
+ expect(p.perceptual).toBe(false);
83
+ });
84
+
85
+ it('uastc maps to the UASTC-LDR tex format with zstd supercompression', () => {
86
+ const p = basisEncodeParamsFor('uastc', { colorSpace: 'linear', isHdr: false });
87
+ expect(p).not.toBeNull();
88
+ if (p === null) return;
89
+ expect(p.mode).toBe('uastc-ldr');
90
+ expect(p.srgb).toBe(false);
91
+ expect(p.uastcSupercompression).toBe(true);
92
+ });
93
+
94
+ it("'uastc' on an HDR source maps to the UASTC-HDR tex format with no srgb transfer", () => {
95
+ // 'uastc-hdr' is a derived delivery encoding, not a sidecar value; it is
96
+ // reached via the 'uastc' (or 'auto') sidecar mode on an HDR source.
97
+ const p = basisEncodeParamsFor('uastc', { colorSpace: 'linear', isHdr: true });
98
+ expect(p).not.toBeNull();
99
+ if (p === null) return;
100
+ expect(p.mode).toBe('uastc-hdr');
101
+ expect(p.srgb).toBe(false);
102
+ expect(p.uastcSupercompression).toBe(false);
103
+ });
104
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ type EncodeSourceInfo,
4
+ encodeTextureToKtx2,
5
+ MAX_ENCODE_SOURCE_PIXELS,
6
+ } from '../ktx2-encode.js';
7
+
8
+ /**
9
+ * Encoder source-pixel fail-fast gate (CI-red fix, human decision).
10
+ *
11
+ * The wasm32 Basis encoder ceiling is 4096x4096 = 16.78 Mpx (MAX_ENCODE_SOURCE_PIXELS);
12
+ * above it the raw encoder returns 0 bytes silently. `encodeTextureToKtx2` pre-checks
13
+ * width*height and, when it exceeds the ceiling, returns a structured
14
+ * `ktx2-encode-source-too-large` error WITHOUT calling the encoder -- so the failure is
15
+ * actionable ("reduce resolution or set compressionMode:'none'") instead of an opaque
16
+ * 0-byte encode failure. This test asserts both the boundary behavior and that the
17
+ * encoder is never reached for an over-limit source.
18
+ *
19
+ * The over-limit path is exercised with a tiny buffer + explicit dimensions: the
20
+ * pre-check only reads width*height, never the pixel bytes, so no real megapixel
21
+ * allocation is needed and no WASM is loaded.
22
+ */
23
+
24
+ // Spy on the codec encode binding: an over-limit source must never reach it.
25
+ const basisEncodeSpy = vi.fn();
26
+ vi.mock('@forgeax/engine-codec/encode', () => ({
27
+ basisEncode: (...args: unknown[]) => basisEncodeSpy(...args),
28
+ }));
29
+
30
+ const SRGB: EncodeSourceInfo = { colorSpace: 'srgb', isHdr: false };
31
+
32
+ describe('encodeTextureToKtx2 -- source-pixel ceiling fail-fast', () => {
33
+ it('MAX_ENCODE_SOURCE_PIXELS is 4096x4096 (16.78 Mpx)', () => {
34
+ expect(MAX_ENCODE_SOURCE_PIXELS).toBe(4096 * 4096);
35
+ expect(MAX_ENCODE_SOURCE_PIXELS).toBe(16_777_216);
36
+ });
37
+
38
+ it('rejects a >4096^2 source with ktx2-encode-source-too-large, without calling the encoder', async () => {
39
+ basisEncodeSpy.mockClear();
40
+ // 8192x8192 = 67 Mpx, well over the ceiling. A 4-byte stub buffer suffices:
41
+ // the pre-check reads only the dimensions.
42
+ const r = await encodeTextureToKtx2(new Uint8Array(4), 8192, 8192, 'etc1s', SRGB);
43
+ expect(r.ok).toBe(false);
44
+ if (r.ok) return;
45
+ expect(r.error.code).toBe('ktx2-encode-source-too-large');
46
+ expect(r.error.mode).toBe('etc1s');
47
+ expect(r.error.reason).toContain('8192x8192');
48
+ expect(r.error.reason).toContain('4096x4096');
49
+ expect(basisEncodeSpy).not.toHaveBeenCalled();
50
+ });
51
+
52
+ it('rejects the first over-limit size just past the ceiling (4097x4096)', async () => {
53
+ basisEncodeSpy.mockClear();
54
+ const r = await encodeTextureToKtx2(new Uint8Array(4), 4097, 4096, 'etc1s', SRGB);
55
+ expect(r.ok).toBe(false);
56
+ if (r.ok) return;
57
+ expect(r.error.code).toBe('ktx2-encode-source-too-large');
58
+ expect(basisEncodeSpy).not.toHaveBeenCalled();
59
+ });
60
+
61
+ it('an exactly-4096^2 source passes the ceiling and reaches the encoder', async () => {
62
+ basisEncodeSpy.mockClear();
63
+ basisEncodeSpy.mockResolvedValue({ ok: true, value: new Uint8Array([1, 2, 3]) });
64
+ const r = await encodeTextureToKtx2(new Uint8Array(4), 4096, 4096, 'etc1s', SRGB);
65
+ expect(basisEncodeSpy).toHaveBeenCalledTimes(1);
66
+ expect(r.ok).toBe(true);
67
+ if (!r.ok) return;
68
+ expect(r.value.mode).toBe('etc1s');
69
+ });
70
+
71
+ it("the 'none' path still short-circuits before the ceiling check", async () => {
72
+ basisEncodeSpy.mockClear();
73
+ // Over-limit dimensions but mode 'none' -> resolves to null params first.
74
+ const r = await encodeTextureToKtx2(new Uint8Array(4), 8192, 8192, 'none', SRGB);
75
+ expect(r.ok).toBe(false);
76
+ if (r.ok) return;
77
+ expect(r.error.code).toBe('ktx2-encode-failed');
78
+ expect(r.error.mode).toBe('none');
79
+ expect(basisEncodeSpy).not.toHaveBeenCalled();
80
+ });
81
+ });
@@ -0,0 +1,148 @@
1
+ import type {
2
+ DecodedImage,
3
+ ImageError,
4
+ ImageErrorCode,
5
+ ImageErrorDetail,
6
+ ImageErrorDetailFor,
7
+ ImageErrorFor,
8
+ ImageMeta,
9
+ } from '@forgeax/engine-types';
10
+ import { describe, expectTypeOf, it } from 'vitest';
11
+
12
+ // Type-only tests. Verify the 8-member ImageErrorCode closed union compiles
13
+ // an exhaustive switch without a default arm (charter P4 explicit failure).
14
+ // Verifies the discriminated ImageErrorDetail narrowing per .code (charter
15
+ // P3 machine-readable union > prose; AGENTS.md Error model row pattern).
16
+ //
17
+ // feat-20260521-sprite-atlas-animation M1 T-03 update: union grew from 5 to
18
+ // 8 with the atlas-* triplet (atlas-empty-input / atlas-size-exceeded /
19
+ // atlas-region-mismatch). The exact-member assertion + exhaustive switch
20
+ // arms below mirror the SSOT in @forgeax/engine-types ImageErrorCode +
21
+ // ImageErrorDetail so a future minor add still fails this file at compile
22
+ // time until the new arm + .expected + .hint rows are supplied.
23
+
24
+ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () => {
25
+ it('ImageErrorCode is the exact 8-member union literal set', () => {
26
+ expectTypeOf<ImageErrorCode>().toEqualTypeOf<
27
+ | 'image-decode-failed'
28
+ | 'image-format-unsupported'
29
+ | 'image-dimension-out-of-bounds'
30
+ | 'image-meta-missing'
31
+ | 'image-hdr-decode-failed'
32
+ | 'atlas-empty-input'
33
+ | 'atlas-size-exceeded'
34
+ | 'atlas-region-mismatch'
35
+ >();
36
+ });
37
+
38
+ it('exhaustive switch on err.code compiles with no default arm (charter P4)', () => {
39
+ function recover(code: ImageErrorCode): string {
40
+ switch (code) {
41
+ case 'image-decode-failed':
42
+ return 'check file integrity / re-export from DCC tool';
43
+ case 'image-format-unsupported':
44
+ return 'convert to PNG or JPG';
45
+ case 'image-dimension-out-of-bounds':
46
+ return 'downscale source under device caps maxDimension';
47
+ case 'image-meta-missing':
48
+ return 'run forgeax-engine-remote-asset import <path>';
49
+ case 'image-hdr-decode-failed':
50
+ return 'verify Radiance RGBE header and FORMAT=32-bit_rle_rgbe field';
51
+ case 'atlas-empty-input':
52
+ return 'verify forgeax-engine-remote-asset atlas --input glob matches at least 1 PNG';
53
+ case 'atlas-size-exceeded':
54
+ return 'downscale source or split atlas under maxAtlasSize cap';
55
+ case 'atlas-region-mismatch':
56
+ return 'shelfPack regions exceed atlas footprint -- packer safety net';
57
+ }
58
+ }
59
+ expectTypeOf(recover).toBeFunction();
60
+ });
61
+
62
+ it('ImageErrorDetail narrows per .code via discriminated union pattern', () => {
63
+ function describe_(detail: ImageErrorDetail): string {
64
+ switch (detail.code) {
65
+ case 'image-decode-failed':
66
+ return detail.reason;
67
+ case 'image-format-unsupported':
68
+ return detail.actualMime;
69
+ case 'image-dimension-out-of-bounds':
70
+ return `${detail.requested.width}x${detail.requested.height} > ${detail.limit}`;
71
+ case 'image-meta-missing':
72
+ return `${detail.sourcePath} -> ${detail.expectedSidecarPath}`;
73
+ case 'image-hdr-decode-failed':
74
+ return detail.reason;
75
+ case 'atlas-empty-input':
76
+ return `atlas-empty:${detail.receivedCount}`;
77
+ case 'atlas-size-exceeded':
78
+ return `atlas-size:${detail.name}:${detail.width}x${detail.height}>${detail.maxAtlasSize}`;
79
+ case 'atlas-region-mismatch':
80
+ return `atlas-region:${detail.name}:${detail.regionsTotalPixels}>${detail.atlasPixels}`;
81
+ }
82
+ }
83
+ expectTypeOf(describe_).toBeFunction();
84
+ });
85
+
86
+ it('ImageError correlates envelope code and detail shape', () => {
87
+ function describeError(error: ImageError): string {
88
+ switch (error.code) {
89
+ case 'image-decode-failed':
90
+ return error.detail.reason;
91
+ case 'image-format-unsupported':
92
+ return error.detail.actualMime;
93
+ case 'image-dimension-out-of-bounds':
94
+ return `${error.detail.requested.width} > ${error.detail.limit}`;
95
+ case 'image-meta-missing':
96
+ return error.detail.expectedSidecarPath;
97
+ case 'image-hdr-decode-failed':
98
+ return error.detail.reason;
99
+ case 'atlas-empty-input':
100
+ return `${error.detail.receivedCount}`;
101
+ case 'atlas-size-exceeded':
102
+ return `${error.detail.name}:${error.detail.maxAtlasSize}`;
103
+ case 'atlas-region-mismatch':
104
+ return `${error.detail.name}:${error.detail.atlasPixels}`;
105
+ }
106
+ }
107
+
108
+ expectTypeOf(describeError).toBeFunction();
109
+ expectTypeOf<ImageErrorDetailFor<'image-decode-failed'>>().toMatchTypeOf<{
110
+ code: 'image-decode-failed';
111
+ reason: string;
112
+ }>();
113
+ expectTypeOf<ImageErrorFor<'image-format-unsupported'>>().toMatchTypeOf<{
114
+ code: 'image-format-unsupported';
115
+ detail: { actualMime: string };
116
+ }>();
117
+ });
118
+
119
+ it('ImageMeta POD has the 5 free-form fields (guid + 4 importer settings)', () => {
120
+ expectTypeOf<ImageMeta>().toMatchTypeOf<{
121
+ guid: string;
122
+ colorSpace: 'srgb' | 'linear';
123
+ mipmap: 'auto' | 'none';
124
+ addressMode: 'repeat' | 'clamp-to-edge' | 'mirror-repeat';
125
+ filterMode: 'nearest' | 'linear';
126
+ }>();
127
+ });
128
+
129
+ it('DecodedImage POD has the 6 fields (bytes + dims + mime + colorSpace + mipmap)', () => {
130
+ expectTypeOf<DecodedImage>().toMatchTypeOf<{
131
+ bytes: Uint8Array;
132
+ width: number;
133
+ height: number;
134
+ mime: 'image/jpeg' | 'image/png' | 'image/x-tga';
135
+ colorSpace: 'srgb' | 'linear';
136
+ mipmap: boolean;
137
+ }>();
138
+ });
139
+
140
+ it('ImageError 4-field surface (.code / .expected / .hint / .detail) parallels RhiError shape', () => {
141
+ expectTypeOf<ImageError>().toMatchTypeOf<{
142
+ code: ImageErrorCode;
143
+ expected: string;
144
+ hint: string;
145
+ detail: ImageErrorDetail;
146
+ }>();
147
+ });
148
+ });