@forgeax/engine-image 0.1.2

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 (122) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +168 -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__/ktx2-encode-mode-owner.test-d.d.ts +2 -0
  25. package/dist/__tests__/ktx2-encode-mode-owner.test-d.d.ts.map +1 -0
  26. package/dist/__tests__/make-fixture.d.ts +12 -0
  27. package/dist/__tests__/make-fixture.d.ts.map +1 -0
  28. package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
  29. package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
  30. package/dist/__tests__/runtime-decode.unit.test.d.ts +2 -0
  31. package/dist/__tests__/runtime-decode.unit.test.d.ts.map +1 -0
  32. package/dist/__tests__/source-key.unit.test.d.ts +2 -0
  33. package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
  34. package/dist/__tests__/tga.unit.test.d.ts +2 -0
  35. package/dist/__tests__/tga.unit.test.d.ts.map +1 -0
  36. package/dist/decode-image-from-file.d.ts +40 -0
  37. package/dist/decode-image-from-file.d.ts.map +1 -0
  38. package/dist/decode-image-from-file.mjs +590 -0
  39. package/dist/decode-image-from-file.mjs.map +1 -0
  40. package/dist/errors.d.ts +14 -0
  41. package/dist/errors.d.ts.map +1 -0
  42. package/dist/hdr-decoder.d.ts +28 -0
  43. package/dist/hdr-decoder.d.ts.map +1 -0
  44. package/dist/hdr-decoder.mjs +212 -0
  45. package/dist/hdr-decoder.mjs.map +1 -0
  46. package/dist/image-decoder-browser.d.ts +22 -0
  47. package/dist/image-decoder-browser.d.ts.map +1 -0
  48. package/dist/image-decoder-node.d.ts +50 -0
  49. package/dist/image-decoder-node.d.ts.map +1 -0
  50. package/dist/image-importer.d.ts +15 -0
  51. package/dist/image-importer.d.ts.map +1 -0
  52. package/dist/image-importer.mjs +1107 -0
  53. package/dist/image-importer.mjs.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.mjs +503 -0
  57. package/dist/index.mjs.map +1 -0
  58. package/dist/ktx2-encode.d.ts +84 -0
  59. package/dist/ktx2-encode.d.ts.map +1 -0
  60. package/dist/ktx2-encode.mjs +100 -0
  61. package/dist/ktx2-encode.mjs.map +1 -0
  62. package/dist/parse-image.d.ts +48 -0
  63. package/dist/parse-image.d.ts.map +1 -0
  64. package/dist/parse-image.mjs +231 -0
  65. package/dist/parse-image.mjs.map +1 -0
  66. package/dist/reimport-reuse-meta.d.ts +81 -0
  67. package/dist/reimport-reuse-meta.d.ts.map +1 -0
  68. package/dist/resize-image.d.ts +7 -0
  69. package/dist/resize-image.d.ts.map +1 -0
  70. package/dist/result.d.ts +2 -0
  71. package/dist/result.d.ts.map +1 -0
  72. package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts +2 -0
  73. package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts.map +1 -0
  74. package/dist/runtime/asset-decoders.d.ts +4 -0
  75. package/dist/runtime/asset-decoders.d.ts.map +1 -0
  76. package/dist/runtime/decode-image-bytes.d.ts +7 -0
  77. package/dist/runtime/decode-image-bytes.d.ts.map +1 -0
  78. package/dist/runtime/image-error.d.ts +3 -0
  79. package/dist/runtime/image-error.d.ts.map +1 -0
  80. package/dist/source-key.d.ts +21 -0
  81. package/dist/source-key.d.ts.map +1 -0
  82. package/dist/sub-asset-key.d.ts +50 -0
  83. package/dist/sub-asset-key.d.ts.map +1 -0
  84. package/dist/to-asset-pack.d.ts +61 -0
  85. package/dist/to-asset-pack.d.ts.map +1 -0
  86. package/package.json +105 -0
  87. package/src/__tests__/compression-mode.unit.test.ts +104 -0
  88. package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
  89. package/src/__tests__/errors.test-d.ts +148 -0
  90. package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
  91. package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
  92. package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
  93. package/src/__tests__/image-importer.test.ts +12 -0
  94. package/src/__tests__/image-local-artifacts.test.ts +38 -0
  95. package/src/__tests__/image.unit.test.ts +1387 -0
  96. package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
  97. package/src/__tests__/ktx2-encode-mode-owner.test-d.ts +70 -0
  98. package/src/__tests__/make-fixture.ts +100 -0
  99. package/src/__tests__/parse-image-downscale.test.ts +26 -0
  100. package/src/__tests__/runtime-decode.unit.test.ts +22 -0
  101. package/src/__tests__/source-key.unit.test.ts +65 -0
  102. package/src/__tests__/tga.unit.test.ts +59 -0
  103. package/src/decode-image-from-file.ts +298 -0
  104. package/src/errors.ts +60 -0
  105. package/src/hdr-decoder.ts +255 -0
  106. package/src/image-decoder-browser.ts +88 -0
  107. package/src/image-decoder-node.ts +67 -0
  108. package/src/image-decoders.d.ts +23 -0
  109. package/src/image-importer.ts +780 -0
  110. package/src/index.ts +46 -0
  111. package/src/ktx2-encode.ts +221 -0
  112. package/src/parse-image.ts +276 -0
  113. package/src/reimport-reuse-meta.ts +160 -0
  114. package/src/resize-image.ts +39 -0
  115. package/src/result.ts +13 -0
  116. package/src/runtime/__tests__/asset-decoders.unit.test.ts +66 -0
  117. package/src/runtime/asset-decoders.ts +152 -0
  118. package/src/runtime/decode-image-bytes.ts +41 -0
  119. package/src/runtime/image-error.ts +8 -0
  120. package/src/source-key.ts +41 -0
  121. package/src/sub-asset-key.ts +67 -0
  122. package/src/to-asset-pack.ts +95 -0
@@ -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';
@@ -0,0 +1,66 @@
1
+ import { ok } from '@forgeax/engine-types';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+
4
+ vi.mock('@forgeax/engine-codec', () => ({
5
+ parseKtx2: vi.fn(async () => ({ ok: true, value: {} })),
6
+ transcodeBasis: vi.fn(),
7
+ transcodeKtx2: vi.fn(async (_parsed: unknown, target: string) => ({
8
+ ok: true,
9
+ value: {
10
+ format: target,
11
+ width: 2,
12
+ height: 2,
13
+ mips: [{ level: 0, width: 2, height: 2, data: new Uint8Array(16) }],
14
+ },
15
+ })),
16
+ }));
17
+
18
+ import { textureContribution } from '../asset-decoders.js';
19
+
20
+ describe('runtime image asset decoder', () => {
21
+ it('transcodes Basis KTX2 artifacts to the uncompressed TextureAsset baseline', async () => {
22
+ const result = await textureContribution.decoder.decode({
23
+ envelope: {
24
+ guid: '019e3969-1d47-760f-982e-7bad1ffd969c',
25
+ kind: 'texture',
26
+ payload: {
27
+ kind: 'texture',
28
+ width: 4,
29
+ height: 4,
30
+ format: 'rgba8unorm-srgb',
31
+ colorSpace: 'srgb',
32
+ mipmap: true,
33
+ data: new Uint8Array(64),
34
+ },
35
+ refs: [],
36
+ artifacts: {
37
+ body: {
38
+ path: 'body.bin',
39
+ mediaType: 'image/ktx2',
40
+ assetCodec: { name: 'basis', container: 'ktx2', profile: 'etc1s' },
41
+ contentEncoding: 'identity',
42
+ byteLength: 4,
43
+ integrity: { algorithm: 'sha256', digest: `sha256:${'0'.repeat(64)}` },
44
+ },
45
+ },
46
+ },
47
+ artifacts: {
48
+ read: async () => ok(new Uint8Array([1, 2, 3, 4])),
49
+ },
50
+ signal: new AbortController().signal,
51
+ });
52
+
53
+ expect(result).toEqual({
54
+ ok: true,
55
+ value: expect.objectContaining({
56
+ kind: 'texture',
57
+ width: 2,
58
+ height: 2,
59
+ format: 'rgba8unorm-srgb',
60
+ colorSpace: 'srgb',
61
+ mipmap: true,
62
+ data: expect.any(Uint8Array),
63
+ }),
64
+ });
65
+ });
66
+ });
@@ -0,0 +1,152 @@
1
+ import { parseKtx2, transcodeBasis, transcodeKtx2 } from '@forgeax/engine-codec';
2
+ import {
3
+ type AssetDecoderContribution,
4
+ type AssetDecoderInput,
5
+ type AssetDecoderResult,
6
+ type AssetKind,
7
+ type AssetLoadError,
8
+ type EquirectAsset,
9
+ err,
10
+ ok,
11
+ type TextureAsset,
12
+ } from '@forgeax/engine-types';
13
+
14
+ function invalid(guid: string, expected: string, reason: string) {
15
+ return err<AssetLoadError>({
16
+ code: 'asset-package-invalid',
17
+ expected,
18
+ hint: 'recook the image asset and publish its complete device-neutral payload',
19
+ detail: { guid, reason },
20
+ });
21
+ }
22
+
23
+ function validDimensions(width: number, height: number): boolean {
24
+ return Number.isSafeInteger(width) && width > 0 && Number.isSafeInteger(height) && height > 0;
25
+ }
26
+
27
+ function imageBytes(value: unknown): Uint8Array | Uint8ClampedArray | undefined {
28
+ if (value instanceof Uint8Array || value instanceof Uint8ClampedArray) return value;
29
+ if (!Array.isArray(value)) return undefined;
30
+ if (!value.every((item) => Number.isInteger(item) && item >= 0 && item <= 255)) {
31
+ return undefined;
32
+ }
33
+ return Uint8Array.from(value);
34
+ }
35
+
36
+ function compressedImageTarget(colorSpace: TextureAsset['colorSpace']): TextureAsset['format'] {
37
+ return colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm';
38
+ }
39
+
40
+ function validImageSurface(
41
+ value: unknown,
42
+ ): value is Pick<TextureAsset, 'width' | 'height' | 'format' | 'data' | 'colorSpace'> {
43
+ if (value === null || typeof value !== 'object') return false;
44
+ const candidate = value as Partial<TextureAsset>;
45
+ return (
46
+ validDimensions(candidate.width ?? 0, candidate.height ?? 0) &&
47
+ typeof candidate.format === 'string' &&
48
+ imageBytes(candidate.data) !== undefined &&
49
+ (candidate.colorSpace === 'srgb' || candidate.colorSpace === 'linear')
50
+ );
51
+ }
52
+
53
+ async function readImageSurface<P extends TextureAsset | EquirectAsset>(
54
+ input: AssetDecoderInput<P>,
55
+ kind: P['kind'],
56
+ expected: string,
57
+ ): Promise<AssetDecoderResult<P>> {
58
+ const { envelope, artifacts } = input;
59
+ const payload = envelope.payload as unknown;
60
+ if (
61
+ payload === null ||
62
+ typeof payload !== 'object' ||
63
+ (payload as { kind?: unknown }).kind !== kind
64
+ ) {
65
+ return invalid(envelope.guid, expected, `${kind} owner validation failed`);
66
+ }
67
+
68
+ const body = envelope.artifacts.body ?? envelope.artifacts.atlas;
69
+ let data = imageBytes((payload as { data?: unknown }).data);
70
+ if (body !== undefined) {
71
+ const read = await artifacts.read(body);
72
+ if (!read.ok) return err(read.error);
73
+ const bytes = read.value;
74
+ data = bytes;
75
+
76
+ // Pack v2 keeps the authored TextureAsset format device-neutral while the
77
+ // artifact may carry a Basis KTX2/raw-Basis delivery codec. The new
78
+ // AssetDecoder seam has no device-capability input, so its safe baseline is
79
+ // an uncompressed RGBA target; the renderer can upload that POD on every
80
+ // backend without interpreting container bytes as pixels.
81
+ if (body.assetCodec?.name === 'basis' && body.assetCodec.container !== undefined) {
82
+ const candidate = payload as Partial<TextureAsset>;
83
+ if (candidate.colorSpace !== 'srgb' && candidate.colorSpace !== 'linear') {
84
+ return invalid(envelope.guid, expected, `${kind} color space is invalid`);
85
+ }
86
+ const target = compressedImageTarget(candidate.colorSpace);
87
+ const transcoded =
88
+ body.assetCodec.container === 'ktx2'
89
+ ? await parseKtx2(bytes).then((parsed) =>
90
+ parsed.ok ? transcodeKtx2(parsed.value, target) : parsed,
91
+ )
92
+ : await transcodeBasis(bytes, target);
93
+ if (!transcoded.ok) {
94
+ return invalid(envelope.guid, expected, `codec:${transcoded.error.code}`);
95
+ }
96
+ const mip = transcoded.value.mips[0];
97
+ if (mip === undefined) return invalid(envelope.guid, expected, 'codec:base-mip-missing');
98
+ data = mip.data;
99
+ return readDecodedSurface(envelope.guid, expected, {
100
+ ...payload,
101
+ width: mip.width,
102
+ height: mip.height,
103
+ format: target,
104
+ data,
105
+ });
106
+ }
107
+ }
108
+
109
+ return readDecodedSurface(envelope.guid, expected, {
110
+ ...payload,
111
+ ...(data === undefined ? {} : { data }),
112
+ });
113
+ }
114
+
115
+ function readDecodedSurface<P extends TextureAsset | EquirectAsset>(
116
+ guid: string,
117
+ expected: string,
118
+ candidate: unknown,
119
+ ): ReturnType<typeof ok<P>> | ReturnType<typeof err<AssetLoadError>> {
120
+ if (!validImageSurface(candidate)) {
121
+ return invalid(guid, expected, 'image owner validation failed');
122
+ }
123
+ return ok(candidate as P);
124
+ }
125
+
126
+ export const textureContribution: AssetDecoderContribution<TextureAsset, 'texture'> = {
127
+ kind: { kind: 'texture' } as AssetKind<TextureAsset, 'texture'>,
128
+ consumer: 'Image/Render DeviceScope',
129
+ decoder: {
130
+ async decode(input) {
131
+ return readImageSurface(
132
+ input,
133
+ 'texture',
134
+ 'a texture payload with dimensions, format, color space, and bytes',
135
+ );
136
+ },
137
+ },
138
+ };
139
+
140
+ export const equirectContribution: AssetDecoderContribution<EquirectAsset, 'equirect'> = {
141
+ kind: { kind: 'equirect' } as AssetKind<EquirectAsset, 'equirect'>,
142
+ consumer: 'Image/Render DeviceScope',
143
+ decoder: {
144
+ async decode(input) {
145
+ return readImageSurface(
146
+ input,
147
+ 'equirect',
148
+ 'an equirect payload with dimensions, format, color space, and bytes',
149
+ );
150
+ },
151
+ },
152
+ };
@@ -0,0 +1,41 @@
1
+ import type { ImageError, Result, TextureAsset } from '@forgeax/engine-types';
2
+ import { err, ok } from '@forgeax/engine-types';
3
+ import { decodeImageInBrowser } from '../image-decoder-browser';
4
+ import { runtimeImageError } from './image-error';
5
+
6
+ const SUPPORTED_MIMES = ['image/png', 'image/jpeg'] as const;
7
+ type SupportedMime = (typeof SUPPORTED_MIMES)[number];
8
+
9
+ function isSupportedMime(mime: string): mime is SupportedMime {
10
+ return SUPPORTED_MIMES.some((supportedMime) => supportedMime === mime);
11
+ }
12
+
13
+ function mipLevels(width: number, height: number): number {
14
+ return Math.floor(Math.log2(Math.max(width, height))) + 1;
15
+ }
16
+
17
+ /** Image-owned runtime boundary from encoded bytes to a TextureAsset POD. */
18
+ export async function decodeImageBytes(
19
+ bytes: Uint8Array | ArrayBuffer,
20
+ mime: string,
21
+ opts: { readonly colorSpace?: 'srgb' | 'linear'; readonly mipmap?: boolean } = {},
22
+ ): Promise<Result<TextureAsset, ImageError>> {
23
+ if (!isSupportedMime(mime)) {
24
+ return err(runtimeImageError({ code: 'image-format-unsupported', actualMime: mime }));
25
+ }
26
+ const colorSpace = opts.colorSpace ?? 'srgb';
27
+ const mipmap = opts.mipmap ?? true;
28
+ const input = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
29
+ const decoded = await decodeImageInBrowser(input, mime as SupportedMime, { colorSpace, mipmap });
30
+ if (!decoded.ok) return err(decoded.error);
31
+ return ok({
32
+ kind: 'texture',
33
+ width: decoded.value.width,
34
+ height: decoded.value.height,
35
+ format: colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm',
36
+ data: decoded.value.bytes,
37
+ colorSpace,
38
+ mipmap,
39
+ mipLevelCount: mipmap ? mipLevels(decoded.value.width, decoded.value.height) : 1,
40
+ });
41
+ }
@@ -0,0 +1,8 @@
1
+ import type { ImageErrorCode, ImageErrorDetailFor, ImageErrorFor } from '@forgeax/engine-types';
2
+ import { imageError } from '../errors';
3
+
4
+ export function runtimeImageError<C extends ImageErrorCode>(
5
+ detail: ImageErrorDetailFor<C>,
6
+ ): ImageErrorFor<C> {
7
+ return imageError(detail);
8
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Derive the semantic identity for an image producer output.
3
+ *
4
+ * The role is the producer-owned identity. Source path and sourceIndex are
5
+ * locators only and must not be folded into this key.
6
+ */
7
+ export interface ImageSourceKeyLocator {
8
+ readonly sourcePath?: string;
9
+ readonly sourceIndex?: number;
10
+ }
11
+
12
+ export type ImageSourceKeyResult =
13
+ | { readonly ok: true; readonly keys: readonly string[] }
14
+ | {
15
+ readonly ok: false;
16
+ readonly code: 'missing-source-key' | 'duplicate-source-key';
17
+ readonly roles: readonly string[];
18
+ };
19
+
20
+ export function deriveImageSourceKey(
21
+ role: string,
22
+ _locator?: ImageSourceKeyLocator,
23
+ ): string | undefined {
24
+ const normalizedRole = role.trim();
25
+ if (normalizedRole.length === 0) return undefined;
26
+ return `image:${normalizedRole}`;
27
+ }
28
+
29
+ export function deriveImageSourceKeys(roles: readonly string[]): ImageSourceKeyResult {
30
+ const keys = roles.map((role) => deriveImageSourceKey(role));
31
+ const missing = roles.filter((_role, index) => keys[index] === undefined);
32
+ if (missing.length > 0) return { ok: false, code: 'missing-source-key', roles: missing };
33
+
34
+ const seen = new Set<string>();
35
+ for (const key of keys) {
36
+ if (key === undefined) continue;
37
+ if (seen.has(key)) return { ok: false, code: 'duplicate-source-key', roles };
38
+ seen.add(key);
39
+ }
40
+ return { ok: true, keys: keys as string[] };
41
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Sub-asset-key shape mirroring the in-flight gltf-loader feat (plan-strategy
3
+ * section 2.2 D-4 same-shape). Each sub-asset emitted into a `*.meta.json`
4
+ * sidecar (uniformly `<source>.meta.json` across image / gltf arms,
5
+ * dispatched on top-level `importer` field per feat-20260521) carries a
6
+ * `{kind, name?, indexFallback}` triple so the importer can deterministically
7
+ * match an existing sub-asset across reimports (charter P5 producer/consumer
8
+ * split + AC-14 cross-feat alignment).
9
+ *
10
+ * The image disk schema is currently single-sub-asset; this file collapses
11
+ * to `kind='texture'` + `indexFallback='textures/0'` so the same matching
12
+ * algorithm is reusable when the future cubemap / array-layer feat lands
13
+ * (plan-strategy R5 free-form schema).
14
+ */
15
+
16
+ export interface SubAssetKey {
17
+ /** Discriminator literal -- 'texture' for engine-image; 'mesh' / 'material' / 'scene' / 'image' for gltf-loader. */
18
+ readonly kind: string;
19
+ /** Optional human-readable name field; absent when the source has no symbolic identifier. */
20
+ readonly name?: string;
21
+ /** Path-style fallback identifier (e.g. 'images/0' / 'meshes/0'); always non-empty. */
22
+ readonly indexFallback: string;
23
+ }
24
+
25
+ export interface SubAssetKeyInput {
26
+ readonly kind: string;
27
+ readonly sourceIndex: number;
28
+ readonly name?: string;
29
+ }
30
+
31
+ /**
32
+ * Construct a sub-asset key from importer input. The `indexFallback` is
33
+ * synthesised from the kind literal + sourceIndex so two importers
34
+ * (image / gltf-loader) emit identical fallback strings for the same
35
+ * `(kind, sourceIndex)` pair (AC-14).
36
+ */
37
+ export function subAssetKey(input: SubAssetKeyInput): SubAssetKey {
38
+ const indexFallback = `${input.kind}s/${input.sourceIndex}`;
39
+ if (input.name !== undefined) {
40
+ return { kind: input.kind, name: input.name, indexFallback };
41
+ }
42
+ return { kind: input.kind, indexFallback };
43
+ }
44
+
45
+ /**
46
+ * Two-phase equality predicate used by reimportReuseMeta for GUID
47
+ * preservation across reimports:
48
+ *
49
+ * Phase 1 -- (kind + name + idx) full match: identical in all three fields
50
+ * Phase 2 -- (kind + idx) match: same kind + indexFallback, name absent or
51
+ * equal (collapses when both sides omit name)
52
+ * Phase 3 -- otherwise: false (importer mints fresh UUIDv7)
53
+ *
54
+ * The two phases are folded into a single boolean predicate here; the
55
+ * reimportReuseMeta caller iterates the existing subAssets list and picks
56
+ * the first hit (deterministic order; matches AC-16 byte-identical
57
+ * reimport).
58
+ */
59
+ export function subAssetKeyEqual(a: SubAssetKey, b: SubAssetKey): boolean {
60
+ if (a.kind !== b.kind) return false;
61
+ if (a.indexFallback !== b.indexFallback) return false;
62
+ // name comparison: undefined === undefined is fine; if one side has a name
63
+ // and the other does not, the keys are NOT equal (charter P4 explicit
64
+ // failure -- name carries a meaningful identifier when present)
65
+ if (a.name !== b.name) return false;
66
+ return true;
67
+ }
@@ -0,0 +1,95 @@
1
+ import type { AssetCompression, DecodedImage, ImageMeta } from '@forgeax/engine-types';
2
+
3
+ /**
4
+ * External-asset-package envelope shape mirroring the
5
+ * `packages/pack/schema/meta.schema.json` $defs/ExternalAssetPackage.
6
+ * Consumed by `forgeax-engine-remote-asset import` (CLI entry, M3b) +
7
+ * the image importer in M2 build-time path. The internal-text-package
8
+ * variant ($defs/InternalTextPackage) is owned by the gltf-loader feat.
9
+ */
10
+ export interface ExternalAssetPackage {
11
+ readonly schemaVersion: string;
12
+ readonly kind: 'external-asset-package';
13
+ readonly importer: 'image';
14
+ readonly source: string;
15
+ readonly importSettings: ImageImportSettings;
16
+ readonly subAssets: readonly ExternalSubAsset[];
17
+ }
18
+
19
+ /**
20
+ * Free-form image importer settings persisted into `*.meta.json` (importer: 'image')
21
+ * `importSettings`. Matches `ImageMeta` POD field-for-field (charter P5
22
+ * single SSOT) but the open Record shape allows future minor adds (cubemap
23
+ * face / array layer / custom colorSpace) without breaking existing meta
24
+ * files (plan-strategy R5).
25
+ */
26
+ export interface ImageImportSettings extends Readonly<Record<string, unknown>> {
27
+ readonly colorSpace: ImageMeta['colorSpace'];
28
+ readonly mipmap: ImageMeta['mipmap'];
29
+ readonly addressMode: ImageMeta['addressMode'];
30
+ readonly filterMode: ImageMeta['filterMode'];
31
+ readonly downscaleMaxDimension?: number;
32
+ /**
33
+ * Explicit per-asset compression override (AC-01: importSettings carries
34
+ * compression intent). When omitted, the build-time compression strategy
35
+ * table decides by artifact kind (mesh -> zstd, texture -> none). When set,
36
+ * this value wins over the default table for this asset.
37
+ */
38
+ readonly compression?: AssetCompression;
39
+ }
40
+
41
+ export interface ExternalSubAsset {
42
+ readonly guid: string;
43
+ readonly sourceIndex: number;
44
+ readonly kind: string;
45
+ readonly name?: string;
46
+ }
47
+
48
+ /**
49
+ * Pure function that translates a DecodedImage POD + ImageMeta POD into
50
+ * an external-asset-package envelope ready to be JSON-stringified into a
51
+ * `*.meta.json` sidecar with importer: 'image' (plan-strategy section 3.2 sequence A; AC-13
52
+ * disk schema reuse).
53
+ *
54
+ * The single sub-asset emitted carries:
55
+ * - `guid` -- copied verbatim from `meta.guid` (the producer guarantees
56
+ * the GUID is freshly minted or reused via `reimportReuseMeta`)
57
+ * - `sourceIndex: 0` (single-sub-asset shape; cubemap / array reserved
58
+ * for future feat)
59
+ * - `kind: 'texture'` (closed literal for image-importer emit)
60
+ *
61
+ * Two consecutive calls with identical inputs produce JSON.stringify
62
+ * byte-equal output (AC-16 idempotent reimport).
63
+ */
64
+ export function toAssetPack(decoded: DecodedImage, meta: ImageMeta): ExternalAssetPackage {
65
+ // decoded.bytes are not embedded in the meta envelope -- the source bytes
66
+ // live next to the sidecar on disk (the runtime reads them via
67
+ // decodeImageFromFile at app load time). We keep the parameter so future
68
+ // cubemap / array-layer feats can derive multi-sub-asset emit from decoded
69
+ // dimensions without breaking the call surface (charter P5 consistent
70
+ // abstraction).
71
+ void decoded;
72
+
73
+ return {
74
+ schemaVersion: '1.0.0',
75
+ kind: 'external-asset-package',
76
+ importer: 'image',
77
+ source: '',
78
+ importSettings: {
79
+ colorSpace: meta.colorSpace,
80
+ mipmap: meta.mipmap,
81
+ addressMode: meta.addressMode,
82
+ filterMode: meta.filterMode,
83
+ ...(meta.downscaleMaxDimension !== undefined
84
+ ? { downscaleMaxDimension: meta.downscaleMaxDimension }
85
+ : {}),
86
+ },
87
+ subAssets: [
88
+ {
89
+ guid: meta.guid,
90
+ sourceIndex: 0,
91
+ kind: 'texture',
92
+ },
93
+ ],
94
+ };
95
+ }