@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,126 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { basisEncode } from '@forgeax/engine-codec/encode';
4
+ import { ImportError } from '@forgeax/engine-types';
5
+ import { describe, expect, it } from 'vitest';
6
+ import type {
7
+ BasisEncoderModule,
8
+ BasisModuleFactory,
9
+ } from '../../../codec/src/wasm/basis-types.js';
10
+ import { imageImporter } from '../image-importer.js';
11
+
12
+ const TRANSCODER_GLUE = new URL('../../../codec/pkg/basis_transcoder.mjs', import.meta.url);
13
+ const ENCODER_GLUE = new URL('../../../codec/pkg/encode/basis_encoder.mjs', import.meta.url);
14
+ const pkgBuilt =
15
+ existsSync(fileURLToPath(TRANSCODER_GLUE)) && existsSync(fileURLToPath(ENCODER_GLUE));
16
+
17
+ const GUID = '019f0000-0000-7000-8000-000000000301';
18
+
19
+ async function makeKtx2(): Promise<Uint8Array> {
20
+ const result = await basisEncode(new Uint8Array(4 * 4 * 4).fill(127), {
21
+ mode: 'uastc-ldr',
22
+ width: 4,
23
+ height: 4,
24
+ srgb: false,
25
+ perceptual: false,
26
+ uastcSupercompression: true,
27
+ mipGen: false,
28
+ });
29
+ if (!result.ok) throw new Error(result.error.code);
30
+ return result.value;
31
+ }
32
+
33
+ async function loadEncoder(): Promise<BasisEncoderModule> {
34
+ const factory = (
35
+ (await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
36
+ default: BasisModuleFactory<BasisEncoderModule>;
37
+ }
38
+ ).default;
39
+ const mod = await factory({
40
+ locateFile: () => new URL('../../../codec/pkg/encode/basis_encoder.wasm', import.meta.url).href,
41
+ });
42
+ mod.initializeBasis();
43
+ return mod;
44
+ }
45
+
46
+ async function makeBasis(): Promise<Uint8Array> {
47
+ const mod = await loadEncoder();
48
+ const encoder = new mod.BasisEncoder();
49
+ try {
50
+ encoder.setSliceSourceImage(0, new Uint8Array(4 * 4 * 4).fill(127), 4, 4, 0);
51
+ encoder.setCreateKTX2File(false);
52
+ encoder.setFormatMode(mod.basis_tex_format.cUASTC_LDR_4x4.value);
53
+ encoder.setPerceptual(false);
54
+ encoder.setMipGen(false);
55
+ const bytes = new Uint8Array(1 << 20);
56
+ const length = encoder.encode(bytes);
57
+ if (length <= 0) throw new Error('raw Basis encode failed');
58
+ return bytes.slice(0, length);
59
+ } finally {
60
+ encoder.delete();
61
+ }
62
+ }
63
+
64
+ function context(source: string, bytes: Uint8Array) {
65
+ return {
66
+ source,
67
+ readSource: async () => ({ ok: true as const, value: bytes }),
68
+ readSibling: async () => ({
69
+ ok: false as const,
70
+ error: new ImportError({
71
+ code: 'source-read-failed',
72
+ expected: 'no sibling source',
73
+ hint: 'test source has no sibling',
74
+ detail: { source, reason: 'no sibling' },
75
+ }),
76
+ }),
77
+ decodeImage: async () => {
78
+ throw new Error('precompressed source must not call decodeImage');
79
+ },
80
+ subAssets: [{ guid: GUID, sourceIndex: 0, sourceKey: 'main', kind: 'texture' as const }],
81
+ importSettings: { colorSpace: 'linear' as const },
82
+ sourceOverrides: { main: { colorSpace: 'linear' as const } },
83
+ };
84
+ }
85
+
86
+ describe.skipIf(!pkgBuilt)('KTX2/Basis source importer', () => {
87
+ it('rejects an unsupported precompressed extension before image decoding', async () => {
88
+ await expect(imageImporter.import(context('fixture.dds', new Uint8Array()))).rejects.toThrow(
89
+ 'unsupported source extension',
90
+ );
91
+ });
92
+
93
+ it('imports a KTX2 source without decoding it as PNG/JPEG and preserves codec facts', async () => {
94
+ const bytes = await makeKtx2();
95
+ const result = await imageImporter.import(context('fixture.ktx2', bytes));
96
+ expect(result.ok).toBe(true);
97
+ if (!result.ok) return;
98
+ expect(result.value.assets[0]?.payload).toMatchObject({
99
+ kind: 'texture',
100
+ width: 4,
101
+ height: 4,
102
+ format: 'rgba8unorm',
103
+ colorSpace: 'linear',
104
+ mipmap: false,
105
+ });
106
+ expect(result.value.assets[0]?.artifacts.body?.assetCodec).toMatchObject({
107
+ name: 'basis',
108
+ container: 'ktx2',
109
+ profile: 'uastc-ldr',
110
+ });
111
+ expect(result.value.assets[0]?.artifacts.body?.bytes).toEqual(bytes);
112
+ });
113
+
114
+ it('imports raw Basis with authored Meta color-space provenance', async () => {
115
+ const bytes = await makeBasis();
116
+ const result = await imageImporter.import(context('fixture.basis', bytes));
117
+ expect(result.ok).toBe(true);
118
+ if (!result.ok) return;
119
+ expect(result.value.assets[0]?.payload.colorSpace).toBe('linear');
120
+ expect(result.value.assets[0]?.artifacts.body?.assetCodec).toMatchObject({
121
+ name: 'basis',
122
+ container: 'basis',
123
+ profile: 'uastc-ldr',
124
+ });
125
+ });
126
+ });
@@ -0,0 +1,100 @@
1
+ // Synthetic image fixtures generated algorithmically so the engine repo
2
+ // carries zero committed image binaries (and zero inlined asset byte blobs,
3
+ // which would just be a relabelled asset). The PNG path hand-builds the
4
+ // chunk structure (IHDR + IDAT + IEND) and deflates the raw scanlines with
5
+ // node's zlib; the JPEG path uses jpeg-js, already a runtime dep of
6
+ // @forgeax/engine-image. Both produce standard-conformant streams that the
7
+ // production decoder (upng-js / jpeg-js in parse-image.ts) reads back.
8
+ import { deflateSync } from 'node:zlib';
9
+ import * as jpeg from 'jpeg-js';
10
+
11
+ const jpegEncode = ((jpeg as unknown as { encode?: typeof jpeg.encode }).encode ??
12
+ (jpeg as unknown as { default: { encode: typeof jpeg.encode } }).default
13
+ .encode) as typeof jpeg.encode;
14
+
15
+ const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
16
+
17
+ // CRC-32 (PNG polynomial) over a chunk's type+data.
18
+ function crc32(bytes: Uint8Array): number {
19
+ let crc = 0xffffffff;
20
+ for (const byte of bytes) {
21
+ crc ^= byte;
22
+ for (let k = 0; k < 8; k++) {
23
+ crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
24
+ }
25
+ }
26
+ return (crc ^ 0xffffffff) >>> 0;
27
+ }
28
+
29
+ function u32(n: number): number[] {
30
+ return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
31
+ }
32
+
33
+ function chunk(type: string, data: Uint8Array): number[] {
34
+ const typeBytes = [...type].map((c) => c.charCodeAt(0));
35
+ const body = new Uint8Array([...typeBytes, ...data]);
36
+ return [...u32(data.length), ...body, ...u32(crc32(body))];
37
+ }
38
+
39
+ /** Solid-colour 8-bit RGBA PNG, standard zlib/deflate encoding. */
40
+ export function makePng(
41
+ width: number,
42
+ height: number,
43
+ rgba: [number, number, number, number],
44
+ ): Uint8Array {
45
+ // IHDR: width, height, bit-depth=8, colour-type=6 (RGBA), no interlace.
46
+ const ihdr = new Uint8Array([...u32(width), ...u32(height), 8, 6, 0, 0, 0]);
47
+ // Raw image: each scanline prefixed with a filter-type byte (0 = none).
48
+ const raw = new Uint8Array(height * (1 + width * 4));
49
+ let o = 0;
50
+ for (let y = 0; y < height; y++) {
51
+ raw[o++] = 0;
52
+ for (let x = 0; x < width; x++) {
53
+ raw[o++] = rgba[0];
54
+ raw[o++] = rgba[1];
55
+ raw[o++] = rgba[2];
56
+ raw[o++] = rgba[3];
57
+ }
58
+ }
59
+ const idat = new Uint8Array(deflateSync(raw));
60
+ return new Uint8Array([
61
+ ...PNG_MAGIC,
62
+ ...chunk('IHDR', ihdr),
63
+ ...chunk('IDAT', idat),
64
+ ...chunk('IEND', new Uint8Array(0)),
65
+ ]);
66
+ }
67
+
68
+ /** Solid-colour RGBA JPEG of the given dimensions. */
69
+ export function makeJpg(
70
+ width: number,
71
+ height: number,
72
+ rgba: [number, number, number, number],
73
+ ): Uint8Array {
74
+ const data = new Uint8Array(width * height * 4);
75
+ for (let i = 0; i < width * height; i++) {
76
+ data[i * 4 + 0] = rgba[0];
77
+ data[i * 4 + 1] = rgba[1];
78
+ data[i * 4 + 2] = rgba[2];
79
+ data[i * 4 + 3] = rgba[3];
80
+ }
81
+ const encoded = jpegEncode({ data: Buffer.from(data), width, height }, 90);
82
+ return new Uint8Array(encoded.data);
83
+ }
84
+
85
+ /**
86
+ * A valid PNG header + IHDR followed by an IDAT whose deflate stream is
87
+ * garbage, so a real PNG decoder fails (image-decode-failed). Built from the
88
+ * same chunk machinery as makePng, with a corrupted IDAT body — replaces the
89
+ * former committed corrupt.png fixture.
90
+ */
91
+ export function makeCorruptPng(): Uint8Array {
92
+ const ihdr = new Uint8Array([...u32(1), ...u32(1), 8, 6, 0, 0, 0]);
93
+ const garbageIdat = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
94
+ return new Uint8Array([
95
+ ...PNG_MAGIC,
96
+ ...chunk('IHDR', ihdr),
97
+ ...chunk('IDAT', garbageIdat),
98
+ ...chunk('IEND', new Uint8Array(0)),
99
+ ]);
100
+ }
@@ -0,0 +1,26 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseImage } from '../parse-image.js';
3
+ import { makeJpg } from './make-fixture.js';
4
+
5
+ describe('parseImage asset-owned downscale', () => {
6
+ it('emits an aspect-preserving cooked payload under the declared target', async () => {
7
+ const result = parseImage(makeJpg(2, 2, [255, 0, 0, 255]), 'image/jpeg', {
8
+ downscaleMaxDimension: 1,
9
+ maxDimension: 1,
10
+ });
11
+
12
+ expect(result.ok).toBe(true);
13
+ if (!result.ok) return;
14
+ expect(result.value.width).toBe(1);
15
+ expect(result.value.height).toBe(1);
16
+ expect(result.value.bytes).toHaveLength(4);
17
+ });
18
+
19
+ it('keeps current hard-bound behavior when no downscale target is declared', async () => {
20
+ const result = parseImage(makeJpg(2, 2, [255, 0, 0, 255]), 'image/jpeg', { maxDimension: 1 });
21
+
22
+ expect(result.ok).toBe(false);
23
+ if (result.ok) return;
24
+ expect(result.error.code).toBe('image-dimension-out-of-bounds');
25
+ });
26
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { type ExistingExternalAssetPackage, reimportReuseMeta } from '../reimport-reuse-meta.js';
3
+ import { deriveImageSourceKey, deriveImageSourceKeys } from '../source-key.js';
4
+ import { subAssetKey } from '../sub-asset-key.js';
5
+
6
+ const GUID = '01928000-7c00-7000-8000-000000000042';
7
+
8
+ function decoded(): Parameters<typeof reimportReuseMeta>[0] {
9
+ return {
10
+ bytes: new Uint8Array(4),
11
+ width: 1,
12
+ height: 1,
13
+ mime: 'image/png',
14
+ colorSpace: 'srgb',
15
+ mipmap: true,
16
+ };
17
+ }
18
+
19
+ function existing(): ExistingExternalAssetPackage {
20
+ return {
21
+ schemaVersion: '1.0.0',
22
+ kind: 'external-asset-package',
23
+ importer: 'image',
24
+ source: 'renamed.png',
25
+ importSettings: {},
26
+ subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'texture', sourceKey: 'image:texture' }],
27
+ };
28
+ }
29
+
30
+ describe('image producer sourceKey', () => {
31
+ it('uses a role key independent of path and sourceIndex', () => {
32
+ expect(deriveImageSourceKey('texture')).toBe('image:texture');
33
+ expect(deriveImageSourceKey('texture', { sourcePath: 'moved/wood.png', sourceIndex: 8 })).toBe(
34
+ 'image:texture',
35
+ );
36
+ });
37
+
38
+ it('rejects an empty role instead of manufacturing an index key', () => {
39
+ expect(deriveImageSourceKey('')).toBeUndefined();
40
+ });
41
+
42
+ it('requires unique semantic roles for multi-output images', () => {
43
+ expect(deriveImageSourceKeys(['texture', 'equirect'])).toEqual({
44
+ ok: true,
45
+ keys: ['image:texture', 'image:equirect'],
46
+ });
47
+ expect(deriveImageSourceKeys(['texture', 'texture'])).toMatchObject({
48
+ ok: false,
49
+ code: 'duplicate-source-key',
50
+ });
51
+ });
52
+
53
+ it('keeps legacy indexFallback separate from producer sourceKey', () => {
54
+ expect(subAssetKey({ kind: 'texture', sourceIndex: 0 })).toEqual({
55
+ kind: 'texture',
56
+ indexFallback: 'textures/0',
57
+ });
58
+ expect(deriveImageSourceKey('texture')).not.toContain('0');
59
+ });
60
+
61
+ it('reuses identity after source relocation while emitting the role key', () => {
62
+ const result = reimportReuseMeta(decoded(), existing());
63
+ expect(result[0]).toMatchObject({ guid: GUID, sourceIndex: 0, sourceKey: 'image:texture' });
64
+ });
65
+ });
@@ -0,0 +1,233 @@
1
+ import { readFile, stat } from 'node:fs/promises';
2
+ import { dirname, extname, join } from 'node:path';
3
+
4
+ import type { DecodedImage, ImageError, ImageMeta } from '@forgeax/engine-types';
5
+ import { imageError } from './errors.js';
6
+ import { decodeHdr, type HdrDecoded } from './hdr-decoder.js';
7
+ import { parseImage } from './parse-image.js';
8
+ import type { Result } from './result.js';
9
+ import { err, ok } from './result.js';
10
+
11
+ /**
12
+ * Sidecar JSON shape consumed by decodeImageFromFile. The schema is the
13
+ * external-asset-package $defs from `packages/pack/schema/meta.schema.json`
14
+ * (research F-9). Only the fields required by the Node-side decoder are
15
+ * enumerated here; importSettings remains free-form per plan-strategy R5.
16
+ */
17
+ interface SidecarMeta {
18
+ readonly schemaVersion: string;
19
+ readonly kind: 'external-asset-package';
20
+ readonly importer: 'image';
21
+ readonly source: string;
22
+ readonly importSettings: Partial<{
23
+ colorSpace: ImageMeta['colorSpace'];
24
+ mipmap: ImageMeta['mipmap'];
25
+ addressMode: ImageMeta['addressMode'];
26
+ filterMode: ImageMeta['filterMode'];
27
+ }>;
28
+ readonly subAssets: ReadonlyArray<{
29
+ readonly guid: string;
30
+ readonly sourceIndex: number;
31
+ readonly kind: string;
32
+ }>;
33
+ }
34
+
35
+ /**
36
+ * Result envelope returned by decodeImageFromFile for PNG/JPG sources.
37
+ * Carries both the raw DecodedImage POD (for direct uploadTexture consumption)
38
+ * and the parsed ImageMeta POD.
39
+ */
40
+ export interface DecodedImageWithMeta {
41
+ readonly decoded: DecodedImage;
42
+ readonly meta: ImageMeta;
43
+ }
44
+
45
+ /**
46
+ * Result envelope returned by decodeImageFromFile for .hdr sources.
47
+ * Carries the HDR-decoded float data ready for cubemap upload.
48
+ */
49
+ export interface DecodedHdrWithMeta {
50
+ readonly hdr: HdrDecoded;
51
+ readonly meta: ImageMeta;
52
+ }
53
+
54
+ const EXT_TO_MIME: Readonly<Record<string, 'image/png' | 'image/jpeg' | 'image/vnd.radiance'>> = {
55
+ '.png': 'image/png',
56
+ '.jpg': 'image/jpeg',
57
+ '.jpeg': 'image/jpeg',
58
+ '.hdr': 'image/vnd.radiance',
59
+ };
60
+
61
+ function deriveSidecarPath(sourcePath: string): string {
62
+ const dir = dirname(sourcePath);
63
+ // sidecar lives next to source as <source-with-ext>.meta.json
64
+ // (feat-20260521 unify-sidecar-meta-dispatch-by-content; the importer field
65
+ // in the JSON drives importer dispatch instead of filename suffix).
66
+ const base = sourcePath.slice(dir.length + 1);
67
+ return join(dir, `${base}.meta.json`);
68
+ }
69
+
70
+ /**
71
+ * Async file-system entry to the image importer (plan-strategy section 3.2
72
+ * sequence A; AC-17 path (a) sidecar three-way fallback).
73
+ *
74
+ * Behaviour (left-to-right, fail-fast on first surfaced ImageError):
75
+ * 1. Sniff extension -- not in `.png / .jpg / .jpeg` -> image-format-unsupported
76
+ * 2. Stat source -- absent -> image-decode-failed with the source path
77
+ * 3. Stat sibling `<source>.meta.json` (importer: 'image') -- absent -> image-meta-missing
78
+ * 4. Read source bytes + parse sidecar JSON
79
+ * 5. Hand off to `parseImage(bytes, mime, opts)` -- surfaces image-decode-failed
80
+ * / image-format-unsupported / image-dimension-out-of-bounds
81
+ * 6. Compose DecodedImage POD + ImageMeta POD return envelope.
82
+ *
83
+ * AC-17 path (a) lock: when the sidecar is absent, the returned error
84
+ * carries `detail.sourcePath` + `detail.expectedSidecarPath` so AI users
85
+ * read .hint and run `forgeax-engine-remote-asset import <path>` to
86
+ * self-recover (charter P3 explicit failure + IDE jump-to-source).
87
+ */
88
+ export async function decodeImageFromFile(
89
+ sourcePath: string,
90
+ ): Promise<Result<DecodedImageWithMeta, ImageError>> {
91
+ const ext = extname(sourcePath).toLowerCase();
92
+ const mime = EXT_TO_MIME[ext];
93
+ if (mime === undefined) {
94
+ return err(
95
+ imageError({
96
+ code: 'image-format-unsupported',
97
+ actualMime: `extension '${ext || '<none>'}'`,
98
+ path: sourcePath,
99
+ }),
100
+ );
101
+ }
102
+
103
+ const sidecarPath = deriveSidecarPath(sourcePath);
104
+
105
+ try {
106
+ await stat(sourcePath);
107
+ } catch (e) {
108
+ return err(
109
+ imageError({
110
+ code: 'image-decode-failed',
111
+ reason: `failed to read source: ${e instanceof Error ? e.message : String(e)}`,
112
+ path: sourcePath,
113
+ }),
114
+ );
115
+ }
116
+
117
+ try {
118
+ await stat(sidecarPath);
119
+ } catch {
120
+ return err(
121
+ imageError({
122
+ code: 'image-meta-missing',
123
+ sourcePath,
124
+ expectedSidecarPath: sidecarPath,
125
+ }),
126
+ );
127
+ }
128
+
129
+ let sidecarText: string;
130
+ try {
131
+ sidecarText = (await readFile(sidecarPath)).toString('utf8');
132
+ } catch (e) {
133
+ return err(
134
+ imageError({
135
+ code: 'image-decode-failed',
136
+ reason: `failed to read sidecar: ${e instanceof Error ? e.message : String(e)}`,
137
+ path: sidecarPath,
138
+ }),
139
+ );
140
+ }
141
+
142
+ let sidecar: SidecarMeta;
143
+ try {
144
+ sidecar = JSON.parse(sidecarText) as SidecarMeta;
145
+ } catch (e) {
146
+ return err(
147
+ imageError({
148
+ code: 'image-decode-failed',
149
+ reason: `sidecar JSON parse failed: ${e instanceof Error ? e.message : String(e)}`,
150
+ path: sidecarPath,
151
+ }),
152
+ );
153
+ }
154
+
155
+ const settings = sidecar.importSettings ?? {};
156
+ const colorSpace: ImageMeta['colorSpace'] = settings.colorSpace ?? 'srgb';
157
+ const mipmap: ImageMeta['mipmap'] = settings.mipmap ?? 'auto';
158
+ const addressMode: ImageMeta['addressMode'] = settings.addressMode ?? 'repeat';
159
+ const filterMode: ImageMeta['filterMode'] = settings.filterMode ?? 'linear';
160
+ const guid = sidecar.subAssets[0]?.guid ?? '';
161
+
162
+ const meta: ImageMeta = {
163
+ guid,
164
+ colorSpace,
165
+ mipmap,
166
+ addressMode,
167
+ filterMode,
168
+ };
169
+
170
+ let bytes: Uint8Array;
171
+ try {
172
+ const buf = await readFile(sourcePath);
173
+ bytes = new Uint8Array(buf);
174
+ } catch (e) {
175
+ return err(
176
+ imageError({
177
+ code: 'image-decode-failed',
178
+ reason: `failed to read source: ${e instanceof Error ? e.message : String(e)}`,
179
+ path: sourcePath,
180
+ }),
181
+ );
182
+ }
183
+
184
+ // .hdr path: delegate to HDR decoder, enforce linear color space
185
+ if (mime === 'image/vnd.radiance') {
186
+ if (colorSpace !== 'linear') {
187
+ return err(
188
+ imageError({
189
+ code: 'image-format-unsupported',
190
+ actualMime: `colorSpace conflict: HDR requires linear, got ${colorSpace}`,
191
+ path: sourcePath,
192
+ }),
193
+ );
194
+ }
195
+ const hdrRes = decodeHdr(bytes);
196
+ if (!hdrRes.ok) return err(hdrRes.error);
197
+ // DecodedImageWithMeta expects DecodedImage; for HDR we return the HDR
198
+ // variant through a separate export path. Downstream consumers
199
+ // distinguish by file extension.
200
+ // For the DecodedImageWithMeta return type compatibility, we synthesise
201
+ // a minimal DecodedImage POD carrying the byte representation.
202
+ // This keeps the return type stable for existing PNG/JPG callers.
203
+ // The HDR data is available through the separate decodeHdr export.
204
+ const rgba8 = new Uint8Array(hdrRes.value.width * hdrRes.value.height * 4);
205
+ // convert float to quantised 8-bit for the POD shape (lossy; consumers
206
+ // should use decodeHdr directly for float precision)
207
+ for (let i = 0; i < hdrRes.value.data.length; i++) {
208
+ const v = hdrRes.value.data[i];
209
+ const clamped = v !== undefined ? Math.max(0, Math.min(1, v as number)) : 0;
210
+ rgba8[i] = Math.round(clamped * 255);
211
+ }
212
+ const decoded: DecodedImage = {
213
+ bytes: rgba8,
214
+ width: hdrRes.value.width,
215
+ height: hdrRes.value.height,
216
+ mime: 'image/jpeg',
217
+ colorSpace: 'linear',
218
+ mipmap: false,
219
+ };
220
+ return ok({ decoded, meta });
221
+ }
222
+
223
+ const r = parseImage(bytes, mime, {
224
+ colorSpace,
225
+ mipmap: mipmap === 'auto',
226
+ path: sourcePath,
227
+ });
228
+ if (!r.ok) {
229
+ return err(r.error);
230
+ }
231
+
232
+ return ok({ decoded: r.value, meta });
233
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,60 @@
1
+ import type { ImageErrorCode, ImageErrorDetailFor, ImageErrorFor } from '@forgeax/engine-types';
2
+ import { IMAGE_ERROR_HINTS } from '@forgeax/engine-types';
3
+
4
+ // Per-code .expected string literals SSOT.
5
+ // Mirrors ASSET_ERROR_HINTS / IMAGE_ERROR_HINTS shape (charter P5 consistent
6
+ // abstraction). AI users surface .expected when a structured error needs to
7
+ // describe the precondition that was violated.
8
+ const IMAGE_ERROR_EXPECTED: Readonly<Record<ImageErrorCode, string>> = {
9
+ 'image-decode-failed': 'PNG / JPG byte stream decodes successfully',
10
+ 'image-format-unsupported':
11
+ "mime is one of ['image/png', 'image/jpeg']; uploadTexture format <-> colorSpace family agrees",
12
+ 'image-dimension-out-of-bounds':
13
+ 'width and height fall under device caps maxTextureDimension2D (or 16384 hard cap)',
14
+ 'image-meta-missing':
15
+ "<source>.meta.json sidecar (importer: 'image') exists in the same directory",
16
+ 'image-hdr-decode-failed': 'Radiance RGBE header is valid and pixel data decodes successfully',
17
+ // feat-20260521-sprite-atlas-animation M1 T-03 — vite-plugin-image atlas
18
+ // hook .expected literals (plan-strategy section 2 D-2 + AC-10 a/b/c).
19
+ // ImageErrorImpl construction path is unchanged: the new atlas-* errors
20
+ // flow through `new ImageErrorImpl({ code: 'atlas-...', ...detail })` and
21
+ // pick the .expected string up from this Record at construction time
22
+ // (charter P3 explicit failure SSOT — AI users surface .expected next to
23
+ // .hint after switch (err.code) without parsing the message).
24
+ 'atlas-empty-input': 'images.length >= 1',
25
+ 'atlas-size-exceeded':
26
+ 'image width x height <= maxAtlasSize^2 and each image fits in the atlas footprint',
27
+ 'atlas-region-mismatch': 'sum(regions[i].w x regions[i].h) <= atlasWidth x atlasHeight',
28
+ };
29
+
30
+ /** Runtime implementation of the correlated `ImageErrorFor<C>` envelope. */
31
+ export class ImageErrorImpl<C extends ImageErrorCode = ImageErrorCode>
32
+ extends Error
33
+ implements ImageErrorFor<C>
34
+ {
35
+ readonly code: C;
36
+ readonly expected: string;
37
+ readonly hint: string;
38
+ readonly detail: ImageErrorDetailFor<C>;
39
+
40
+ constructor(detail: ImageErrorDetailFor<C>) {
41
+ const code = detail.code;
42
+ const expected = IMAGE_ERROR_EXPECTED[code];
43
+ const hint = IMAGE_ERROR_HINTS[code];
44
+ super(`[ImageError ${code}] expected: ${expected}; hint: ${hint}`);
45
+ this.name = 'ImageError';
46
+ this.code = code;
47
+ this.expected = expected;
48
+ this.hint = hint;
49
+ this.detail = detail;
50
+ }
51
+ }
52
+
53
+ /** Construct a structured image error while preserving its code/detail pair. */
54
+ export function imageError<C extends ImageErrorCode>(
55
+ detail: ImageErrorDetailFor<C>,
56
+ ): ImageErrorFor<C> {
57
+ return new ImageErrorImpl<C>(detail);
58
+ }
59
+
60
+ export { IMAGE_ERROR_EXPECTED };