@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.
- package/LICENSE +202 -0
- package/README.md +168 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts +2 -0
- package/dist/__tests__/compression-mode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts +2 -0
- package/dist/__tests__/encode-source-too-large.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.test-d.d.ts +2 -0
- package/dist/__tests__/errors.test-d.d.ts.map +1 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-conversion-failure.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts +2 -0
- package/dist/__tests__/image-importer-hdr-equirect.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts +2 -0
- package/dist/__tests__/image-importer-topology.unit.test.d.ts.map +1 -0
- package/dist/__tests__/image-importer.test.d.ts +2 -0
- package/dist/__tests__/image-importer.test.d.ts.map +1 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts +2 -0
- package/dist/__tests__/image-local-artifacts.test.d.ts.map +1 -0
- package/dist/__tests__/image.unit.test.d.ts +2 -0
- package/dist/__tests__/image.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts +2 -0
- package/dist/__tests__/ktx2-basis-importer.unit.test.d.ts.map +1 -0
- package/dist/__tests__/ktx2-encode-mode-owner.test-d.d.ts +2 -0
- package/dist/__tests__/ktx2-encode-mode-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/make-fixture.d.ts +12 -0
- package/dist/__tests__/make-fixture.d.ts.map +1 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts +2 -0
- package/dist/__tests__/parse-image-downscale.test.d.ts.map +1 -0
- package/dist/__tests__/runtime-decode.unit.test.d.ts +2 -0
- package/dist/__tests__/runtime-decode.unit.test.d.ts.map +1 -0
- package/dist/__tests__/source-key.unit.test.d.ts +2 -0
- package/dist/__tests__/source-key.unit.test.d.ts.map +1 -0
- package/dist/__tests__/tga.unit.test.d.ts +2 -0
- package/dist/__tests__/tga.unit.test.d.ts.map +1 -0
- package/dist/decode-image-from-file.d.ts +40 -0
- package/dist/decode-image-from-file.d.ts.map +1 -0
- package/dist/decode-image-from-file.mjs +590 -0
- package/dist/decode-image-from-file.mjs.map +1 -0
- package/dist/errors.d.ts +14 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/hdr-decoder.d.ts +28 -0
- package/dist/hdr-decoder.d.ts.map +1 -0
- package/dist/hdr-decoder.mjs +212 -0
- package/dist/hdr-decoder.mjs.map +1 -0
- package/dist/image-decoder-browser.d.ts +22 -0
- package/dist/image-decoder-browser.d.ts.map +1 -0
- package/dist/image-decoder-node.d.ts +50 -0
- package/dist/image-decoder-node.d.ts.map +1 -0
- package/dist/image-importer.d.ts +15 -0
- package/dist/image-importer.d.ts.map +1 -0
- package/dist/image-importer.mjs +1107 -0
- package/dist/image-importer.mjs.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +503 -0
- package/dist/index.mjs.map +1 -0
- package/dist/ktx2-encode.d.ts +84 -0
- package/dist/ktx2-encode.d.ts.map +1 -0
- package/dist/ktx2-encode.mjs +100 -0
- package/dist/ktx2-encode.mjs.map +1 -0
- package/dist/parse-image.d.ts +48 -0
- package/dist/parse-image.d.ts.map +1 -0
- package/dist/parse-image.mjs +231 -0
- package/dist/parse-image.mjs.map +1 -0
- package/dist/reimport-reuse-meta.d.ts +81 -0
- package/dist/reimport-reuse-meta.d.ts.map +1 -0
- package/dist/resize-image.d.ts +7 -0
- package/dist/resize-image.d.ts.map +1 -0
- package/dist/result.d.ts +2 -0
- package/dist/result.d.ts.map +1 -0
- package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts +2 -0
- package/dist/runtime/__tests__/asset-decoders.unit.test.d.ts.map +1 -0
- package/dist/runtime/asset-decoders.d.ts +4 -0
- package/dist/runtime/asset-decoders.d.ts.map +1 -0
- package/dist/runtime/decode-image-bytes.d.ts +7 -0
- package/dist/runtime/decode-image-bytes.d.ts.map +1 -0
- package/dist/runtime/image-error.d.ts +3 -0
- package/dist/runtime/image-error.d.ts.map +1 -0
- package/dist/source-key.d.ts +21 -0
- package/dist/source-key.d.ts.map +1 -0
- package/dist/sub-asset-key.d.ts +50 -0
- package/dist/sub-asset-key.d.ts.map +1 -0
- package/dist/to-asset-pack.d.ts +61 -0
- package/dist/to-asset-pack.d.ts.map +1 -0
- package/package.json +105 -0
- package/src/__tests__/compression-mode.unit.test.ts +104 -0
- package/src/__tests__/encode-source-too-large.unit.test.ts +81 -0
- package/src/__tests__/errors.test-d.ts +148 -0
- package/src/__tests__/image-importer-conversion-failure.unit.test.ts +297 -0
- package/src/__tests__/image-importer-hdr-equirect.test.ts +218 -0
- package/src/__tests__/image-importer-topology.unit.test.ts +209 -0
- package/src/__tests__/image-importer.test.ts +12 -0
- package/src/__tests__/image-local-artifacts.test.ts +38 -0
- package/src/__tests__/image.unit.test.ts +1387 -0
- package/src/__tests__/ktx2-basis-importer.unit.test.ts +126 -0
- package/src/__tests__/ktx2-encode-mode-owner.test-d.ts +70 -0
- package/src/__tests__/make-fixture.ts +100 -0
- package/src/__tests__/parse-image-downscale.test.ts +26 -0
- package/src/__tests__/runtime-decode.unit.test.ts +22 -0
- package/src/__tests__/source-key.unit.test.ts +65 -0
- package/src/__tests__/tga.unit.test.ts +59 -0
- package/src/decode-image-from-file.ts +298 -0
- package/src/errors.ts +60 -0
- package/src/hdr-decoder.ts +255 -0
- package/src/image-decoder-browser.ts +88 -0
- package/src/image-decoder-node.ts +67 -0
- package/src/image-decoders.d.ts +23 -0
- package/src/image-importer.ts +780 -0
- package/src/index.ts +46 -0
- package/src/ktx2-encode.ts +221 -0
- package/src/parse-image.ts +276 -0
- package/src/reimport-reuse-meta.ts +160 -0
- package/src/resize-image.ts +39 -0
- package/src/result.ts +13 -0
- package/src/runtime/__tests__/asset-decoders.unit.test.ts +66 -0
- package/src/runtime/asset-decoders.ts +152 -0
- package/src/runtime/decode-image-bytes.ts +41 -0
- package/src/runtime/image-error.ts +8 -0
- package/src/source-key.ts +41 -0
- package/src/sub-asset-key.ts +67 -0
- package/src/to-asset-pack.ts +95 -0
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
// Consolidated by feat-20260609-test-pool-startup-reduction-merge-tiny-test-files
|
|
2
|
+
// biome-ignore-all lint/complexity/noUselessLoneBlockStatements: scope isolation between merged source files
|
|
3
|
+
//
|
|
4
|
+
// Source files (N=9):
|
|
5
|
+
// - packages/image/src/__tests__/decode-image-from-file.test.ts
|
|
6
|
+
// - packages/image/src/__tests__/hdr-decoder.test.ts
|
|
7
|
+
// - packages/image/src/__tests__/image-importer-bins.test.ts
|
|
8
|
+
// - packages/image/src/__tests__/image-importer-hdr.test.ts
|
|
9
|
+
// - packages/image/src/__tests__/image-importer.test.ts
|
|
10
|
+
// - packages/image/src/__tests__/parse-image.test.ts
|
|
11
|
+
// - packages/image/src/__tests__/reimport-reuse-meta.test.ts
|
|
12
|
+
// - packages/image/src/__tests__/sub-asset-key.test.ts
|
|
13
|
+
// - packages/image/src/__tests__/to-asset-pack.test.ts
|
|
14
|
+
//
|
|
15
|
+
// Paradigm: each block-scoped describe('<source-filename>.test.ts', ...) preserves
|
|
16
|
+
// source as ancestorTitles[0]. Top-level imports merged + deduped.
|
|
17
|
+
|
|
18
|
+
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { ImporterRegistry } from '@forgeax/engine-import';
|
|
22
|
+
import type {
|
|
23
|
+
DecodedImage,
|
|
24
|
+
EquirectAsset,
|
|
25
|
+
ImageMeta,
|
|
26
|
+
ImportContext,
|
|
27
|
+
ImportedAsset,
|
|
28
|
+
ImportSubAsset,
|
|
29
|
+
TextureAsset,
|
|
30
|
+
} from '@forgeax/engine-types';
|
|
31
|
+
|
|
32
|
+
function unwrap(result: {
|
|
33
|
+
readonly ok: boolean;
|
|
34
|
+
readonly value?: { readonly assets: readonly ImportedAsset[] };
|
|
35
|
+
}): readonly ImportedAsset[] {
|
|
36
|
+
return result.ok && result.value !== undefined ? result.value.assets : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
import { describe, expect, it } from 'vitest';
|
|
40
|
+
import { decodeImageFromFile } from '../decode-image-from-file.js';
|
|
41
|
+
import { decodeHdr } from '../hdr-decoder.js';
|
|
42
|
+
import { imageImporter } from '../image-importer.js';
|
|
43
|
+
import { parseImage } from '../parse-image.js';
|
|
44
|
+
import {
|
|
45
|
+
type ExistingExternalAssetPackage,
|
|
46
|
+
reimportReuseMeta,
|
|
47
|
+
validateColorSpaceForHdr,
|
|
48
|
+
} from '../reimport-reuse-meta.js';
|
|
49
|
+
import { subAssetKey, subAssetKeyEqual } from '../sub-asset-key.js';
|
|
50
|
+
import { toAssetPack } from '../to-asset-pack.js';
|
|
51
|
+
import { makeCorruptPng, makeJpg, makePng } from './make-fixture.js';
|
|
52
|
+
|
|
53
|
+
function makeMinimalHdr(): Uint8Array {
|
|
54
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 8\n';
|
|
55
|
+
const encoder = new TextEncoder();
|
|
56
|
+
const headerBytes = encoder.encode(header);
|
|
57
|
+
const width = 8;
|
|
58
|
+
const hi = (width >> 8) & 0xff;
|
|
59
|
+
const lo = width & 0xff;
|
|
60
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
61
|
+
const chRun = new Uint8Array([128 + 8, 128]);
|
|
62
|
+
const pixelBytes = new Uint8Array(4 + 4 * 2);
|
|
63
|
+
let off = 0;
|
|
64
|
+
pixelBytes.set(prefix, off);
|
|
65
|
+
off += 4;
|
|
66
|
+
pixelBytes.set(chRun, off);
|
|
67
|
+
off += 2;
|
|
68
|
+
pixelBytes.set(chRun, off);
|
|
69
|
+
off += 2;
|
|
70
|
+
pixelBytes.set(chRun, off);
|
|
71
|
+
off += 2;
|
|
72
|
+
pixelBytes.set(chRun, off);
|
|
73
|
+
const total = new Uint8Array(headerBytes.length + pixelBytes.length);
|
|
74
|
+
total.set(headerBytes);
|
|
75
|
+
total.set(pixelBytes, headerBytes.length);
|
|
76
|
+
return total;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
{
|
|
80
|
+
// ─── from decode-image-from-file.test.ts ───
|
|
81
|
+
|
|
82
|
+
function mkTmpDir(prefix: string): string {
|
|
83
|
+
const dir = mkdtempSync(join(tmpdir(), `engine-image-${prefix}-`));
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function writeFixture(dir: string, name: string, bytes: Uint8Array): string {
|
|
88
|
+
const p = join(dir, name);
|
|
89
|
+
writeFileSync(p, bytes);
|
|
90
|
+
return p;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function readBinDF(name: string): Uint8Array {
|
|
94
|
+
switch (name) {
|
|
95
|
+
case 'wood-1x1.png':
|
|
96
|
+
return makePng(1, 1, [137, 96, 71, 255]);
|
|
97
|
+
case 'jpg-2x2.jpg':
|
|
98
|
+
return makeJpg(2, 2, [120, 80, 60, 255]);
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(`unknown fixture: ${name}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('decode-image-from-file.test.ts', () => {
|
|
105
|
+
describe('decodeImageFromFile -- async file-system entry; sidecar stat is path (a) of three-way fallback (AC-17 a)', () => {
|
|
106
|
+
it('returns image-meta-missing when sidecar absent (path a fallback)', async () => {
|
|
107
|
+
const dir = mkTmpDir('meta-missing');
|
|
108
|
+
try {
|
|
109
|
+
const png = readBinDF('wood-1x1.png');
|
|
110
|
+
const sourcePath = writeFixture(dir, 'wood.png', png);
|
|
111
|
+
const r = await decodeImageFromFile(sourcePath);
|
|
112
|
+
expect(r.ok).toBe(false);
|
|
113
|
+
if (r.ok) return;
|
|
114
|
+
expect(r.error.code).toBe('image-meta-missing');
|
|
115
|
+
if (r.error.detail.code !== 'image-meta-missing') return;
|
|
116
|
+
expect(r.error.detail.sourcePath).toBe(sourcePath);
|
|
117
|
+
expect(r.error.detail.expectedSidecarPath).toBe(join(dir, 'wood.png.meta.json'));
|
|
118
|
+
expect(r.error.hint).toContain('forgeax-engine-remote-asset');
|
|
119
|
+
} finally {
|
|
120
|
+
rmSync(dir, { recursive: true, force: true });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('distinguishes a missing source from a missing sidecar and recovers on the same path', async () => {
|
|
125
|
+
const dir = mkTmpDir('staged-recovery');
|
|
126
|
+
const sourcePath = join(dir, 'staged.png');
|
|
127
|
+
const sidecarPath = `${sourcePath}.meta.json`;
|
|
128
|
+
const png = readBinDF('wood-1x1.png');
|
|
129
|
+
const meta = {
|
|
130
|
+
schemaVersion: '1.0.0',
|
|
131
|
+
kind: 'external-asset-package',
|
|
132
|
+
importer: 'image',
|
|
133
|
+
source: 'staged.png',
|
|
134
|
+
importSettings: {
|
|
135
|
+
colorSpace: 'linear',
|
|
136
|
+
mipmap: 'none',
|
|
137
|
+
addressMode: 'mirror-repeat',
|
|
138
|
+
filterMode: 'nearest',
|
|
139
|
+
},
|
|
140
|
+
subAssets: [
|
|
141
|
+
{
|
|
142
|
+
guid: '01928000-7c00-7000-8000-000000000004',
|
|
143
|
+
sourceIndex: 0,
|
|
144
|
+
kind: 'texture',
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
};
|
|
148
|
+
const sidecar = new TextEncoder().encode(JSON.stringify(meta));
|
|
149
|
+
try {
|
|
150
|
+
const bothMissing = await decodeImageFromFile(sourcePath);
|
|
151
|
+
expect(bothMissing.ok).toBe(false);
|
|
152
|
+
if (bothMissing.ok) return;
|
|
153
|
+
expect(bothMissing.error.code).toBe('image-decode-failed');
|
|
154
|
+
if (bothMissing.error.detail.code !== 'image-decode-failed') return;
|
|
155
|
+
expect(bothMissing.error.detail.path).toBe(sourcePath);
|
|
156
|
+
expect(bothMissing.error.detail.reason).toContain('failed to read source');
|
|
157
|
+
|
|
158
|
+
writeFileSync(sourcePath, png);
|
|
159
|
+
const sidecarMissing = await decodeImageFromFile(sourcePath);
|
|
160
|
+
expect(sidecarMissing.ok).toBe(false);
|
|
161
|
+
if (sidecarMissing.ok) return;
|
|
162
|
+
expect(sidecarMissing.error.code).toBe('image-meta-missing');
|
|
163
|
+
if (sidecarMissing.error.detail.code !== 'image-meta-missing') return;
|
|
164
|
+
expect(sidecarMissing.error.detail.sourcePath).toBe(sourcePath);
|
|
165
|
+
expect(sidecarMissing.error.detail.expectedSidecarPath).toBe(sidecarPath);
|
|
166
|
+
|
|
167
|
+
writeFileSync(sidecarPath, sidecar);
|
|
168
|
+
const recovered = await decodeImageFromFile(sourcePath);
|
|
169
|
+
expect(recovered.ok).toBe(true);
|
|
170
|
+
if (!recovered.ok) return;
|
|
171
|
+
expect(recovered.value.decoded.width).toBe(1);
|
|
172
|
+
expect(recovered.value.decoded.height).toBe(1);
|
|
173
|
+
expect(recovered.value.decoded.mime).toBe('image/png');
|
|
174
|
+
expect(recovered.value.decoded.colorSpace).toBe('linear');
|
|
175
|
+
expect(recovered.value.decoded.mipmap).toBe(false);
|
|
176
|
+
expect(recovered.value.meta).toEqual({
|
|
177
|
+
guid: '01928000-7c00-7000-8000-000000000004',
|
|
178
|
+
colorSpace: 'linear',
|
|
179
|
+
mipmap: 'none',
|
|
180
|
+
addressMode: 'mirror-repeat',
|
|
181
|
+
filterMode: 'nearest',
|
|
182
|
+
});
|
|
183
|
+
expect(toAssetPack(recovered.value.decoded, recovered.value.meta)).toEqual({
|
|
184
|
+
schemaVersion: '1.0.0',
|
|
185
|
+
kind: 'external-asset-package',
|
|
186
|
+
importer: 'image',
|
|
187
|
+
source: '',
|
|
188
|
+
importSettings: {
|
|
189
|
+
colorSpace: 'linear',
|
|
190
|
+
mipmap: 'none',
|
|
191
|
+
addressMode: 'mirror-repeat',
|
|
192
|
+
filterMode: 'nearest',
|
|
193
|
+
},
|
|
194
|
+
subAssets: [
|
|
195
|
+
{
|
|
196
|
+
guid: '01928000-7c00-7000-8000-000000000004',
|
|
197
|
+
sourceIndex: 0,
|
|
198
|
+
kind: 'texture',
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const repeat = await decodeImageFromFile(sourcePath);
|
|
204
|
+
expect(repeat).toEqual(recovered);
|
|
205
|
+
expect(readFileSync(sourcePath)).toEqual(Buffer.from(png));
|
|
206
|
+
expect(readFileSync(sidecarPath)).toEqual(Buffer.from(sidecar));
|
|
207
|
+
} finally {
|
|
208
|
+
rmSync(dir, { recursive: true, force: true });
|
|
209
|
+
rmSync(dir, { recursive: true, force: true });
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('rejects a parseable schema-invalid sidecar before decode and retries on the same path', async () => {
|
|
214
|
+
const dir = mkTmpDir('malformed-sidecar-retry');
|
|
215
|
+
const sourcePath = writeFixture(dir, 'staged.png', readBinDF('wood-1x1.png'));
|
|
216
|
+
const sidecarPath = `${sourcePath}.meta.json`;
|
|
217
|
+
const invalidSidecar = new TextEncoder().encode('{}');
|
|
218
|
+
const validSidecar = {
|
|
219
|
+
schemaVersion: '1.0.0',
|
|
220
|
+
kind: 'external-asset-package',
|
|
221
|
+
importer: 'image',
|
|
222
|
+
source: 'staged.png',
|
|
223
|
+
importSettings: {
|
|
224
|
+
colorSpace: 'linear',
|
|
225
|
+
mipmap: 'none',
|
|
226
|
+
addressMode: 'mirror-repeat',
|
|
227
|
+
filterMode: 'nearest',
|
|
228
|
+
},
|
|
229
|
+
subAssets: [
|
|
230
|
+
{
|
|
231
|
+
guid: '01928000-7c00-7000-8000-000000000095',
|
|
232
|
+
sourceIndex: 0,
|
|
233
|
+
kind: 'texture',
|
|
234
|
+
},
|
|
235
|
+
],
|
|
236
|
+
};
|
|
237
|
+
const validSidecarBytes = new TextEncoder().encode(JSON.stringify(validSidecar));
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
writeFileSync(sidecarPath, invalidSidecar);
|
|
241
|
+
const rejected = await decodeImageFromFile(sourcePath);
|
|
242
|
+
expect(rejected.ok).toBe(false);
|
|
243
|
+
if (rejected.ok) return;
|
|
244
|
+
expect(rejected.error.code).toBe('image-decode-failed');
|
|
245
|
+
if (rejected.error.detail.code !== 'image-decode-failed') return;
|
|
246
|
+
expect(rejected.error.detail.path).toBe(sidecarPath);
|
|
247
|
+
expect(rejected.error.detail.reason).toMatch(/^sidecar schema validation failed:/);
|
|
248
|
+
|
|
249
|
+
writeFileSync(sidecarPath, validSidecarBytes);
|
|
250
|
+
const recovered = await decodeImageFromFile(sourcePath);
|
|
251
|
+
expect(recovered.ok).toBe(true);
|
|
252
|
+
if (!recovered.ok) return;
|
|
253
|
+
expect(recovered.value.decoded).toMatchObject({
|
|
254
|
+
width: 1,
|
|
255
|
+
height: 1,
|
|
256
|
+
mime: 'image/png',
|
|
257
|
+
colorSpace: 'linear',
|
|
258
|
+
mipmap: false,
|
|
259
|
+
});
|
|
260
|
+
expect(recovered.value.meta).toEqual({
|
|
261
|
+
guid: '01928000-7c00-7000-8000-000000000095',
|
|
262
|
+
colorSpace: 'linear',
|
|
263
|
+
mipmap: 'none',
|
|
264
|
+
addressMode: 'mirror-repeat',
|
|
265
|
+
filterMode: 'nearest',
|
|
266
|
+
});
|
|
267
|
+
expect(toAssetPack(recovered.value.decoded, recovered.value.meta)).toEqual({
|
|
268
|
+
schemaVersion: '1.0.0',
|
|
269
|
+
kind: 'external-asset-package',
|
|
270
|
+
importer: 'image',
|
|
271
|
+
source: '',
|
|
272
|
+
importSettings: {
|
|
273
|
+
colorSpace: 'linear',
|
|
274
|
+
mipmap: 'none',
|
|
275
|
+
addressMode: 'mirror-repeat',
|
|
276
|
+
filterMode: 'nearest',
|
|
277
|
+
},
|
|
278
|
+
subAssets: [
|
|
279
|
+
{
|
|
280
|
+
guid: '01928000-7c00-7000-8000-000000000095',
|
|
281
|
+
sourceIndex: 0,
|
|
282
|
+
kind: 'texture',
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const repeated = await decodeImageFromFile(sourcePath);
|
|
288
|
+
expect(repeated).toEqual(recovered);
|
|
289
|
+
expect(readFileSync(sourcePath)).toEqual(Buffer.from(readBinDF('wood-1x1.png')));
|
|
290
|
+
expect(readFileSync(sidecarPath)).toEqual(Buffer.from(validSidecarBytes));
|
|
291
|
+
} finally {
|
|
292
|
+
rmSync(dir, { recursive: true, force: true });
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it('decodes a valid 1x1 PNG file when sidecar is present, returns DecodedImage + ImageMeta', async () => {
|
|
297
|
+
const dir = mkTmpDir('happy-png');
|
|
298
|
+
try {
|
|
299
|
+
const png = readBinDF('wood-1x1.png');
|
|
300
|
+
const sourcePath = writeFixture(dir, 'wood.png', png);
|
|
301
|
+
const meta = {
|
|
302
|
+
schemaVersion: '1.0.0',
|
|
303
|
+
kind: 'external-asset-package',
|
|
304
|
+
importer: 'image',
|
|
305
|
+
source: 'wood.png',
|
|
306
|
+
importSettings: {
|
|
307
|
+
colorSpace: 'srgb',
|
|
308
|
+
mipmap: 'auto',
|
|
309
|
+
addressMode: 'repeat',
|
|
310
|
+
filterMode: 'linear',
|
|
311
|
+
},
|
|
312
|
+
subAssets: [
|
|
313
|
+
{
|
|
314
|
+
guid: '01928000-7c00-7000-8000-000000000001',
|
|
315
|
+
sourceIndex: 0,
|
|
316
|
+
kind: 'texture',
|
|
317
|
+
},
|
|
318
|
+
],
|
|
319
|
+
};
|
|
320
|
+
writeFixture(dir, 'wood.png.meta.json', new TextEncoder().encode(JSON.stringify(meta)));
|
|
321
|
+
const r = await decodeImageFromFile(sourcePath);
|
|
322
|
+
expect(r.ok).toBe(true);
|
|
323
|
+
if (!r.ok) return;
|
|
324
|
+
expect(r.value.decoded.width).toBe(1);
|
|
325
|
+
expect(r.value.decoded.height).toBe(1);
|
|
326
|
+
expect(r.value.decoded.mime).toBe('image/png');
|
|
327
|
+
expect(r.value.meta.colorSpace).toBe('srgb');
|
|
328
|
+
expect(r.value.meta.mipmap).toBe('auto');
|
|
329
|
+
expect(r.value.meta.addressMode).toBe('repeat');
|
|
330
|
+
expect(r.value.meta.filterMode).toBe('linear');
|
|
331
|
+
} finally {
|
|
332
|
+
rmSync(dir, { recursive: true, force: true });
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('decodes a valid 2x2 JPG file with sidecar', async () => {
|
|
337
|
+
const dir = mkTmpDir('happy-jpg');
|
|
338
|
+
try {
|
|
339
|
+
const jpg = readBinDF('jpg-2x2.jpg');
|
|
340
|
+
const sourcePath = writeFixture(dir, 'small.jpg', jpg);
|
|
341
|
+
const meta = {
|
|
342
|
+
schemaVersion: '1.0.0',
|
|
343
|
+
kind: 'external-asset-package',
|
|
344
|
+
importer: 'image',
|
|
345
|
+
source: 'small.jpg',
|
|
346
|
+
importSettings: {
|
|
347
|
+
colorSpace: 'linear',
|
|
348
|
+
mipmap: 'none',
|
|
349
|
+
addressMode: 'clamp-to-edge',
|
|
350
|
+
filterMode: 'nearest',
|
|
351
|
+
},
|
|
352
|
+
subAssets: [
|
|
353
|
+
{
|
|
354
|
+
guid: '01928000-7c00-7000-8000-000000000002',
|
|
355
|
+
sourceIndex: 0,
|
|
356
|
+
kind: 'texture',
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
writeFixture(dir, 'small.jpg.meta.json', new TextEncoder().encode(JSON.stringify(meta)));
|
|
361
|
+
const r = await decodeImageFromFile(sourcePath);
|
|
362
|
+
expect(r.ok).toBe(true);
|
|
363
|
+
if (!r.ok) return;
|
|
364
|
+
expect(r.value.decoded.width).toBe(2);
|
|
365
|
+
expect(r.value.decoded.height).toBe(2);
|
|
366
|
+
expect(r.value.decoded.mime).toBe('image/jpeg');
|
|
367
|
+
expect(r.value.meta.colorSpace).toBe('linear');
|
|
368
|
+
} finally {
|
|
369
|
+
rmSync(dir, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('keeps HDR equirect sidecars accepted by the file decoder', async () => {
|
|
374
|
+
const dir = mkTmpDir('happy-hdr');
|
|
375
|
+
const sourcePath = writeFixture(dir, 'environment.hdr', makeMinimalHdr());
|
|
376
|
+
const sidecar = {
|
|
377
|
+
schemaVersion: '1.0.0',
|
|
378
|
+
kind: 'external-asset-package',
|
|
379
|
+
importer: 'image',
|
|
380
|
+
source: 'environment.hdr',
|
|
381
|
+
importSettings: {
|
|
382
|
+
colorSpace: 'linear',
|
|
383
|
+
mipmap: 'none',
|
|
384
|
+
addressMode: 'repeat',
|
|
385
|
+
filterMode: 'linear',
|
|
386
|
+
},
|
|
387
|
+
subAssets: [
|
|
388
|
+
{
|
|
389
|
+
guid: '019e3969-1d43-7610-8810-e80dbd491d91',
|
|
390
|
+
sourceIndex: 0,
|
|
391
|
+
kind: 'equirect',
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
};
|
|
395
|
+
try {
|
|
396
|
+
writeFixture(
|
|
397
|
+
dir,
|
|
398
|
+
'environment.hdr.meta.json',
|
|
399
|
+
new TextEncoder().encode(JSON.stringify(sidecar)),
|
|
400
|
+
);
|
|
401
|
+
const r = await decodeImageFromFile(sourcePath);
|
|
402
|
+
expect(r.ok).toBe(true);
|
|
403
|
+
if (!r.ok) return;
|
|
404
|
+
expect(r.value.decoded.width).toBe(8);
|
|
405
|
+
expect(r.value.decoded.height).toBe(1);
|
|
406
|
+
expect(r.value.meta).toEqual({
|
|
407
|
+
guid: '019e3969-1d43-7610-8810-e80dbd491d91',
|
|
408
|
+
colorSpace: 'linear',
|
|
409
|
+
mipmap: 'none',
|
|
410
|
+
addressMode: 'repeat',
|
|
411
|
+
filterMode: 'linear',
|
|
412
|
+
});
|
|
413
|
+
} finally {
|
|
414
|
+
rmSync(dir, { recursive: true, force: true });
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it('returns image-format-unsupported when extension is not .jpg/.jpeg/.png', async () => {
|
|
419
|
+
const dir = mkTmpDir('unsupported-ext');
|
|
420
|
+
try {
|
|
421
|
+
const png = readBinDF('wood-1x1.png');
|
|
422
|
+
const sourcePath = writeFixture(dir, 'wood.webp', png);
|
|
423
|
+
const meta = {
|
|
424
|
+
schemaVersion: '1.0.0',
|
|
425
|
+
kind: 'external-asset-package',
|
|
426
|
+
importer: 'image',
|
|
427
|
+
source: 'wood.webp',
|
|
428
|
+
importSettings: {
|
|
429
|
+
colorSpace: 'srgb',
|
|
430
|
+
mipmap: 'auto',
|
|
431
|
+
addressMode: 'repeat',
|
|
432
|
+
filterMode: 'linear',
|
|
433
|
+
},
|
|
434
|
+
subAssets: [
|
|
435
|
+
{
|
|
436
|
+
guid: '01928000-7c00-7000-8000-000000000003',
|
|
437
|
+
sourceIndex: 0,
|
|
438
|
+
kind: 'texture',
|
|
439
|
+
},
|
|
440
|
+
],
|
|
441
|
+
};
|
|
442
|
+
writeFixture(dir, 'wood.png.meta.json', new TextEncoder().encode(JSON.stringify(meta)));
|
|
443
|
+
const r = await decodeImageFromFile(sourcePath);
|
|
444
|
+
expect(r.ok).toBe(false);
|
|
445
|
+
if (r.ok) return;
|
|
446
|
+
expect(r.error.code).toBe('image-format-unsupported');
|
|
447
|
+
} finally {
|
|
448
|
+
rmSync(dir, { recursive: true, force: true });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
{
|
|
456
|
+
// ─── from hdr-decoder.test.ts ───
|
|
457
|
+
|
|
458
|
+
function makeBlackHdr(): Uint8Array {
|
|
459
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 8\n';
|
|
460
|
+
const encoder = new TextEncoder();
|
|
461
|
+
const headerBytes = encoder.encode(header);
|
|
462
|
+
const width = 8;
|
|
463
|
+
const hi = (width >> 8) & 0xff;
|
|
464
|
+
const lo = width & 0xff;
|
|
465
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
466
|
+
const chRun = new Uint8Array([128 + 8, 0]);
|
|
467
|
+
const pixelBytes = new Uint8Array(4 + 4 * 2);
|
|
468
|
+
let off = 0;
|
|
469
|
+
pixelBytes.set(prefix, off);
|
|
470
|
+
off += 4;
|
|
471
|
+
pixelBytes.set(chRun, off);
|
|
472
|
+
off += 2;
|
|
473
|
+
pixelBytes.set(chRun, off);
|
|
474
|
+
off += 2;
|
|
475
|
+
pixelBytes.set(chRun, off);
|
|
476
|
+
off += 2;
|
|
477
|
+
pixelBytes.set(chRun, off);
|
|
478
|
+
const total = new Uint8Array(headerBytes.length + pixelBytes.length);
|
|
479
|
+
total.set(headerBytes);
|
|
480
|
+
total.set(pixelBytes, headerBytes.length);
|
|
481
|
+
return total;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function makeOldRleHdr(): Uint8Array {
|
|
485
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 2\n';
|
|
486
|
+
const encoder = new TextEncoder();
|
|
487
|
+
const headerBytes = encoder.encode(header);
|
|
488
|
+
const scanline = new Uint8Array([
|
|
489
|
+
0x02, 0x02, 0x00, 0x02, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80,
|
|
490
|
+
]);
|
|
491
|
+
const total = new Uint8Array(headerBytes.length + scanline.length);
|
|
492
|
+
total.set(headerBytes);
|
|
493
|
+
total.set(scanline, headerBytes.length);
|
|
494
|
+
return total;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function makeMultiScanlineHdr(): Uint8Array {
|
|
498
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 2 +X 8\n';
|
|
499
|
+
const encoder = new TextEncoder();
|
|
500
|
+
const headerBytes = encoder.encode(header);
|
|
501
|
+
const width = 8;
|
|
502
|
+
const hi = (width >> 8) & 0xff;
|
|
503
|
+
const lo = width & 0xff;
|
|
504
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
505
|
+
const chRun = new Uint8Array([128 + 8, 64]);
|
|
506
|
+
const chExp = new Uint8Array([128 + 8, 128]);
|
|
507
|
+
const sl = new Uint8Array(4 + 4 * 2);
|
|
508
|
+
let off = 0;
|
|
509
|
+
sl.set(prefix, off);
|
|
510
|
+
off += 4;
|
|
511
|
+
sl.set(chRun, off);
|
|
512
|
+
off += 2;
|
|
513
|
+
sl.set(chRun, off);
|
|
514
|
+
off += 2;
|
|
515
|
+
sl.set(chRun, off);
|
|
516
|
+
off += 2;
|
|
517
|
+
sl.set(chExp, off);
|
|
518
|
+
const sl2 = new Uint8Array(sl);
|
|
519
|
+
const total = new Uint8Array(headerBytes.length + sl.length + sl2.length);
|
|
520
|
+
total.set(headerBytes);
|
|
521
|
+
total.set(sl, headerBytes.length);
|
|
522
|
+
total.set(sl2, headerBytes.length + sl.length);
|
|
523
|
+
return total;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function makeCorruptRleHdr(): Uint8Array {
|
|
527
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 8\n';
|
|
528
|
+
const encoder = new TextEncoder();
|
|
529
|
+
const headerBytes = encoder.encode(header);
|
|
530
|
+
const prefix = new Uint8Array([0x02, 0x02, 0, 4]);
|
|
531
|
+
const chRun = new Uint8Array([128 + 4, 128]);
|
|
532
|
+
const scanline = new Uint8Array(4 + 4 * 2);
|
|
533
|
+
let off = 0;
|
|
534
|
+
scanline.set(prefix, off);
|
|
535
|
+
off += 4;
|
|
536
|
+
scanline.set(chRun, off);
|
|
537
|
+
off += 2;
|
|
538
|
+
scanline.set(chRun, off);
|
|
539
|
+
off += 2;
|
|
540
|
+
scanline.set(chRun, off);
|
|
541
|
+
off += 2;
|
|
542
|
+
scanline.set(chRun, off);
|
|
543
|
+
const total = new Uint8Array(headerBytes.length + scanline.length);
|
|
544
|
+
total.set(headerBytes);
|
|
545
|
+
total.set(scanline, headerBytes.length);
|
|
546
|
+
return total;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
describe('hdr-decoder.test.ts', () => {
|
|
550
|
+
describe('t8/t9 - Radiance RGBE (.hdr) decoder', () => {
|
|
551
|
+
it('(a) valid .hdr header parses magic, resolution, and FORMAT', () => {
|
|
552
|
+
const r = decodeHdr(makeMinimalHdr());
|
|
553
|
+
expect(r.ok).toBe(true);
|
|
554
|
+
if (r.ok) {
|
|
555
|
+
expect(r.value.width).toBe(8);
|
|
556
|
+
expect(r.value.height).toBe(1);
|
|
557
|
+
expect(r.value.data.length).toBe(8 * 4);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
it('(b) new-RLE decode round-trip for single scanline', () => {
|
|
562
|
+
const r = decodeHdr(makeMinimalHdr());
|
|
563
|
+
expect(r.ok).toBe(true);
|
|
564
|
+
if (r.ok) {
|
|
565
|
+
const d = r.value.data;
|
|
566
|
+
for (let i = 0; i < 8; i++) {
|
|
567
|
+
expect(d[i * 4]).toBeCloseTo(128.5 / 256, 4);
|
|
568
|
+
expect(d[i * 4 + 1]).toBeCloseTo(128.5 / 256, 4);
|
|
569
|
+
expect(d[i * 4 + 2]).toBeCloseTo(128.5 / 256, 4);
|
|
570
|
+
expect(d[i * 4 + 3]).toBe(1.0);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('(c) RGBE-to-float decode math: E=0 edge case produces black', () => {
|
|
576
|
+
const r = decodeHdr(makeBlackHdr());
|
|
577
|
+
expect(r.ok).toBe(true);
|
|
578
|
+
if (r.ok) {
|
|
579
|
+
const d = r.value.data;
|
|
580
|
+
for (let i = 0; i < 8; i++) {
|
|
581
|
+
expect(d[i * 4]).toBe(0);
|
|
582
|
+
expect(d[i * 4 + 1]).toBe(0);
|
|
583
|
+
expect(d[i * 4 + 2]).toBe(0);
|
|
584
|
+
expect(d[i * 4 + 3]).toBe(1.0);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
it('(d) old-RLE scanline returns error (image-hdr-decode-failed)', () => {
|
|
590
|
+
const r = decodeHdr(makeOldRleHdr());
|
|
591
|
+
expect(r.ok).toBe(false);
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
expect(r.error.code).toBe('image-hdr-decode-failed');
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('(e) multi-scanline file decodes all rows to rgba float buffer', () => {
|
|
598
|
+
const r = decodeHdr(makeMultiScanlineHdr());
|
|
599
|
+
expect(r.ok).toBe(true);
|
|
600
|
+
if (r.ok) {
|
|
601
|
+
expect(r.value.width).toBe(8);
|
|
602
|
+
expect(r.value.height).toBe(2);
|
|
603
|
+
expect(r.value.data.length).toBe(8 * 2 * 4);
|
|
604
|
+
expect(r.value.data[0]).toBeCloseTo(64.5 / 256, 4);
|
|
605
|
+
expect(r.value.data[3]).toBe(1.0);
|
|
606
|
+
expect(r.value.data[32]).toBeCloseTo(64.5 / 256, 4);
|
|
607
|
+
expect(r.value.data[35]).toBe(1.0);
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it('(f) corrupted RLE (prefix width mismatch) returns error', () => {
|
|
612
|
+
const r = decodeHdr(makeCorruptRleHdr());
|
|
613
|
+
expect(r.ok).toBe(false);
|
|
614
|
+
if (!r.ok) {
|
|
615
|
+
expect(r.error.code).toBe('image-hdr-decode-failed');
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
it('(g) missing FORMAT line returns error', () => {
|
|
620
|
+
const noFmt = new TextEncoder().encode('#?RADIANCE\n\n-Y 1 +X 8\n');
|
|
621
|
+
const r = decodeHdr(noFmt);
|
|
622
|
+
expect(r.ok).toBe(false);
|
|
623
|
+
if (!r.ok) expect(r.error.code).toBe('image-hdr-decode-failed');
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it('(h) non-RADIANCE magic returns error', () => {
|
|
627
|
+
const badMagic = new TextEncoder().encode('PNG\n\n-Y 1 +X 8\n');
|
|
628
|
+
const r = decodeHdr(badMagic);
|
|
629
|
+
expect(r.ok).toBe(false);
|
|
630
|
+
if (!r.ok) expect(r.error.code).toBe('image-hdr-decode-failed');
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('(i) literal RLE run (non-repeating) decodes correctly', () => {
|
|
634
|
+
const header = '#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y 1 +X 8\n';
|
|
635
|
+
const encoder = new TextEncoder();
|
|
636
|
+
const headerBytes = encoder.encode(header);
|
|
637
|
+
const width = 8;
|
|
638
|
+
const hi = (width >> 8) & 0xff;
|
|
639
|
+
const lo = width & 0xff;
|
|
640
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
641
|
+
|
|
642
|
+
const rCh = new Uint8Array([8, 200, 100, 50, 25, 150, 80, 10, 5]);
|
|
643
|
+
const gCh = new Uint8Array([128 + 8, 128]);
|
|
644
|
+
const bCh = new Uint8Array([128 + 8, 128]);
|
|
645
|
+
const eCh = new Uint8Array([128 + 8, 128]);
|
|
646
|
+
|
|
647
|
+
const pixelBytes = new Uint8Array(4 + 9 + 2 + 2 + 2);
|
|
648
|
+
let off = 0;
|
|
649
|
+
pixelBytes.set(prefix, off);
|
|
650
|
+
off += 4;
|
|
651
|
+
pixelBytes.set(rCh, off);
|
|
652
|
+
off += 9;
|
|
653
|
+
pixelBytes.set(gCh, off);
|
|
654
|
+
off += 2;
|
|
655
|
+
pixelBytes.set(bCh, off);
|
|
656
|
+
off += 2;
|
|
657
|
+
pixelBytes.set(eCh, off);
|
|
658
|
+
|
|
659
|
+
const total = new Uint8Array(headerBytes.length + pixelBytes.length);
|
|
660
|
+
total.set(headerBytes);
|
|
661
|
+
total.set(pixelBytes, headerBytes.length);
|
|
662
|
+
|
|
663
|
+
const r = decodeHdr(total);
|
|
664
|
+
expect(r.ok).toBe(true);
|
|
665
|
+
if (r.ok) {
|
|
666
|
+
const d = r.value.data;
|
|
667
|
+
expect(d[0]).toBeCloseTo(200.5 / 256, 4);
|
|
668
|
+
expect(d[4]).toBeCloseTo(100.5 / 256, 4);
|
|
669
|
+
expect(d[8]).toBeCloseTo(50.5 / 256, 4);
|
|
670
|
+
expect(d[12]).toBeCloseTo(25.5 / 256, 4);
|
|
671
|
+
expect(d[16]).toBeCloseTo(150.5 / 256, 4);
|
|
672
|
+
expect(d[20]).toBeCloseTo(80.5 / 256, 4);
|
|
673
|
+
expect(d[24]).toBeCloseTo(10.5 / 256, 4);
|
|
674
|
+
expect(d[28]).toBeCloseTo(5.5 / 256, 4);
|
|
675
|
+
for (let i = 0; i < 8; i++) {
|
|
676
|
+
expect(d[i * 4 + 1]).toBeCloseTo(128.5 / 256, 4);
|
|
677
|
+
expect(d[i * 4 + 2]).toBeCloseTo(128.5 / 256, 4);
|
|
678
|
+
expect(d[i * 4 + 3]).toBe(1.0);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
});
|
|
682
|
+
});
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
{
|
|
687
|
+
// ─── from image-importer-bins.test.ts ───
|
|
688
|
+
|
|
689
|
+
const BINS_GUID = '019e3969-1d43-7610-8810-e80dbd491d90';
|
|
690
|
+
|
|
691
|
+
function makeBinsCtx(
|
|
692
|
+
source: string,
|
|
693
|
+
bytes: Uint8Array,
|
|
694
|
+
subAssets: readonly ImportSubAsset[],
|
|
695
|
+
importSettings: Readonly<Record<string, unknown>> = {},
|
|
696
|
+
): ImportContext {
|
|
697
|
+
return {
|
|
698
|
+
source,
|
|
699
|
+
readSource: async () => ({ ok: true, value: bytes }),
|
|
700
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
701
|
+
decodeImage: async () => {
|
|
702
|
+
throw new Error('decodeImage not used by imageImporter');
|
|
703
|
+
},
|
|
704
|
+
subAssets,
|
|
705
|
+
importSettings,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
describe('image-importer-bins.test.ts', () => {
|
|
710
|
+
describe('imageImporter bins migration (w7)', () => {
|
|
711
|
+
it('(a) imageImporter still produces ImportedAsset with payload shape (width/height/format/colorSpace)', async () => {
|
|
712
|
+
const png = makePng(8, 4, [200, 100, 50, 255]);
|
|
713
|
+
const ctx = makeBinsCtx(
|
|
714
|
+
'tile.png',
|
|
715
|
+
png,
|
|
716
|
+
[{ guid: BINS_GUID, sourceIndex: 0, kind: 'texture' }],
|
|
717
|
+
{
|
|
718
|
+
colorSpace: 'srgb',
|
|
719
|
+
mipmap: 'none',
|
|
720
|
+
},
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
724
|
+
expect(produced.length).toBe(1);
|
|
725
|
+
const asset = produced[0];
|
|
726
|
+
expect(asset?.guid).toBe(BINS_GUID);
|
|
727
|
+
expect(asset?.kind).toBe('texture');
|
|
728
|
+
expect(asset?.refs).toEqual([]);
|
|
729
|
+
|
|
730
|
+
const payload = asset?.payload as unknown as Record<string, unknown>;
|
|
731
|
+
expect(payload.kind).toBe('texture');
|
|
732
|
+
expect(payload.width).toBe(8);
|
|
733
|
+
expect(payload.height).toBe(4);
|
|
734
|
+
expect(payload.format).toBe('rgba8unorm-srgb');
|
|
735
|
+
expect(payload.colorSpace).toBe('srgb');
|
|
736
|
+
expect(payload.data).toBeInstanceOf(Uint8Array);
|
|
737
|
+
expect((payload.data as Uint8Array).length).toBe(8 * 4 * 4);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it('(a) the runner keeps texture bytes in the asset-local body', async () => {
|
|
741
|
+
const png = makePng(8, 4, [200, 100, 50, 255]);
|
|
742
|
+
const registry = new ImporterRegistry();
|
|
743
|
+
registry.register(imageImporter);
|
|
744
|
+
|
|
745
|
+
const { runImport } = await import('@forgeax/engine-import');
|
|
746
|
+
|
|
747
|
+
const result = await runImport(
|
|
748
|
+
{
|
|
749
|
+
importer: 'image',
|
|
750
|
+
source: 'tile.png',
|
|
751
|
+
subAssets: [{ guid: BINS_GUID, sourceIndex: 0, kind: 'texture' }],
|
|
752
|
+
},
|
|
753
|
+
registry,
|
|
754
|
+
{
|
|
755
|
+
readSource: async () => ({ ok: true, value: png }),
|
|
756
|
+
},
|
|
757
|
+
);
|
|
758
|
+
|
|
759
|
+
expect(result.ok).toBe(true);
|
|
760
|
+
if (!result.ok || 'skipped' in result.value) {
|
|
761
|
+
throw new Error('expected ok RunImportOk');
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const { pack } = result.value;
|
|
765
|
+
|
|
766
|
+
const packAsset = pack.assets.find((a) => a.guid === BINS_GUID);
|
|
767
|
+
expect(packAsset).toBeDefined();
|
|
768
|
+
const body = packAsset?.artifacts.body;
|
|
769
|
+
expect(body).toBeDefined();
|
|
770
|
+
expect(body?.mediaType).toBe('application/x-forgeax-rgba8');
|
|
771
|
+
expect(body?.bytes).toBeInstanceOf(Uint8Array);
|
|
772
|
+
expect((body?.bytes as Uint8Array).length).toBe(8 * 4 * 4);
|
|
773
|
+
const packPayload = packAsset?.payload as unknown as Record<string, unknown>;
|
|
774
|
+
expect(packPayload.data).toEqual(expect.any(Array));
|
|
775
|
+
});
|
|
776
|
+
});
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
{
|
|
781
|
+
// ─── from image-importer-hdr.test.ts ───
|
|
782
|
+
|
|
783
|
+
const HDR_GUID = '019e3969-1d43-7610-8810-e80dbd491d91';
|
|
784
|
+
|
|
785
|
+
function makeHdrFixture(width: number, height: number): Uint8Array {
|
|
786
|
+
const header = `#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n\n-Y ${height} +X ${width}\n`;
|
|
787
|
+
const encoder = new TextEncoder();
|
|
788
|
+
const headerBytes = encoder.encode(header);
|
|
789
|
+
|
|
790
|
+
const hi = (width >> 8) & 0xff;
|
|
791
|
+
const lo = width & 0xff;
|
|
792
|
+
const prefix = new Uint8Array([0x02, 0x02, hi, lo]);
|
|
793
|
+
|
|
794
|
+
const chRun = new Uint8Array([128 + width, 128]);
|
|
795
|
+
const scanlineBytes = 4 + 4 * 2;
|
|
796
|
+
const pixelBytes = new Uint8Array(height * scanlineBytes);
|
|
797
|
+
|
|
798
|
+
for (let y = 0; y < height; y++) {
|
|
799
|
+
let off = y * scanlineBytes;
|
|
800
|
+
pixelBytes.set(prefix, off);
|
|
801
|
+
off += 4;
|
|
802
|
+
pixelBytes.set(chRun, off);
|
|
803
|
+
off += 2;
|
|
804
|
+
pixelBytes.set(chRun, off);
|
|
805
|
+
off += 2;
|
|
806
|
+
pixelBytes.set(chRun, off);
|
|
807
|
+
off += 2;
|
|
808
|
+
pixelBytes.set(chRun, off);
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const total = new Uint8Array(headerBytes.length + pixelBytes.length);
|
|
812
|
+
total.set(headerBytes);
|
|
813
|
+
total.set(pixelBytes, headerBytes.length);
|
|
814
|
+
return total;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function makeHdrCtx(
|
|
818
|
+
source: string,
|
|
819
|
+
bytes: Uint8Array,
|
|
820
|
+
subAssets: readonly ImportSubAsset[],
|
|
821
|
+
importSettings: Readonly<Record<string, unknown>> = {},
|
|
822
|
+
): ImportContext {
|
|
823
|
+
return {
|
|
824
|
+
source,
|
|
825
|
+
readSource: async () => ({ ok: true, value: bytes }),
|
|
826
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
827
|
+
decodeImage: async () => {
|
|
828
|
+
throw new Error('decodeImage not used by imageImporter');
|
|
829
|
+
},
|
|
830
|
+
subAssets,
|
|
831
|
+
importSettings,
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
describe('image-importer-hdr.test.ts', () => {
|
|
836
|
+
describe('imageImporter HDR arm', () => {
|
|
837
|
+
it('imports a .hdr source and produces an EquirectAsset with rgba16float format', async () => {
|
|
838
|
+
const width = 8;
|
|
839
|
+
const height = 4;
|
|
840
|
+
const hdr = makeHdrFixture(width, height);
|
|
841
|
+
const ctx = makeHdrCtx('env.hdr', hdr, [
|
|
842
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
843
|
+
]);
|
|
844
|
+
|
|
845
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
846
|
+
expect(produced.length).toBe(1);
|
|
847
|
+
const asset = produced[0];
|
|
848
|
+
expect(asset?.guid).toBe(HDR_GUID);
|
|
849
|
+
expect(asset?.kind).toBe('equirect');
|
|
850
|
+
|
|
851
|
+
const payload = asset?.payload as EquirectAsset;
|
|
852
|
+
expect(payload.kind).toBe('equirect');
|
|
853
|
+
expect(payload.format).toBe('rgba16float');
|
|
854
|
+
expect(payload.colorSpace).toBe('linear');
|
|
855
|
+
expect(payload.width).toBe(width);
|
|
856
|
+
expect(payload.height).toBe(height);
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('produces correct byte length: width * height * 4 * 2 (rgba16f = 8 bytes per pixel)', async () => {
|
|
860
|
+
const width = 8;
|
|
861
|
+
const height = 4;
|
|
862
|
+
const hdr = makeHdrFixture(width, height);
|
|
863
|
+
const ctx = makeHdrCtx('env.hdr', hdr, [
|
|
864
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
865
|
+
]);
|
|
866
|
+
|
|
867
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
868
|
+
const payload = (produced[0] as { payload: EquirectAsset })?.payload;
|
|
869
|
+
expect(payload.data.length).toBe(width * height * 4 * 2);
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
it('a texture sub-asset with .hdr source is NOT folded (only equirect is)', async () => {
|
|
873
|
+
const hdr = makeHdrFixture(8, 4);
|
|
874
|
+
const ctx = makeHdrCtx('env.hdr', hdr, [
|
|
875
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'texture' },
|
|
876
|
+
]);
|
|
877
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
878
|
+
expect(produced.length).toBe(0);
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
it('returns a structured validation error for a corrupt .hdr source (invalid RGBE header)', async () => {
|
|
882
|
+
const corrupt = new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
|
883
|
+
const ctx = makeHdrCtx('bad.hdr', corrupt, [
|
|
884
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
885
|
+
]);
|
|
886
|
+
|
|
887
|
+
const result = await imageImporter.import(ctx);
|
|
888
|
+
expect(result.ok).toBe(false);
|
|
889
|
+
if (result.ok) return;
|
|
890
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
891
|
+
expect(result.error.detail).toMatchObject({
|
|
892
|
+
diagnostics: [expect.objectContaining({ code: 'image-conversion-decode-hdr' })],
|
|
893
|
+
});
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
|
|
897
|
+
describe('imageImporter unknown extension fail-fast', () => {
|
|
898
|
+
const dummyBytes = new Uint8Array([0x00, 0x00, 0x00, 0x00]);
|
|
899
|
+
|
|
900
|
+
it('.tga source is recognized and invalid bytes fail during decode', async () => {
|
|
901
|
+
const ctx = makeHdrCtx('texture.tga', dummyBytes, [
|
|
902
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'texture' },
|
|
903
|
+
]);
|
|
904
|
+
const result = await imageImporter.import(ctx);
|
|
905
|
+
expect(result.ok).toBe(false);
|
|
906
|
+
if (result.ok) return;
|
|
907
|
+
expect(result.error.code).toBe('source-validation-failed');
|
|
908
|
+
expect(result.error.detail).toMatchObject({
|
|
909
|
+
diagnostics: [expect.objectContaining({ code: 'image-conversion-decode-ldr' })],
|
|
910
|
+
});
|
|
911
|
+
});
|
|
912
|
+
|
|
913
|
+
it('.bmp source still throws unsupported source extension', async () => {
|
|
914
|
+
const ctx = makeHdrCtx('texture.bmp', dummyBytes, [
|
|
915
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'texture' },
|
|
916
|
+
]);
|
|
917
|
+
await expect(imageImporter.import(ctx)).rejects.toThrow('unsupported source extension');
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
it('source with no extension still throws unsupported source extension', async () => {
|
|
921
|
+
const ctx = makeHdrCtx('texture', dummyBytes, [
|
|
922
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'texture' },
|
|
923
|
+
]);
|
|
924
|
+
await expect(imageImporter.import(ctx)).rejects.toThrow('unsupported source extension');
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
it('source with .hdr extension (upper case) is still recognized via toLowerCase()', async () => {
|
|
928
|
+
const hdr = makeHdrFixture(8, 2);
|
|
929
|
+
const ctx = makeHdrCtx('ENV.HDR', hdr, [
|
|
930
|
+
{ guid: HDR_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
931
|
+
]);
|
|
932
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
933
|
+
expect(produced.length).toBe(1);
|
|
934
|
+
expect(produced[0]?.guid).toBe(HDR_GUID);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
{
|
|
941
|
+
// ─── from image-importer.test.ts ───
|
|
942
|
+
|
|
943
|
+
const IMP_GUID = '019e3969-1d43-7610-8810-e80dbd491d90';
|
|
944
|
+
|
|
945
|
+
function makeImpCtx(
|
|
946
|
+
source: string,
|
|
947
|
+
bytes: Uint8Array,
|
|
948
|
+
subAssets: readonly ImportSubAsset[],
|
|
949
|
+
importSettings: Readonly<Record<string, unknown>> = {},
|
|
950
|
+
): ImportContext {
|
|
951
|
+
return {
|
|
952
|
+
source,
|
|
953
|
+
readSource: async () => ({ ok: true, value: bytes }),
|
|
954
|
+
readSibling: async () => ({ ok: true, value: new Uint8Array() }),
|
|
955
|
+
decodeImage: async () => {
|
|
956
|
+
throw new Error('decodeImage not used by imageImporter');
|
|
957
|
+
},
|
|
958
|
+
subAssets,
|
|
959
|
+
importSettings,
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
describe('image-importer.test.ts', () => {
|
|
964
|
+
describe('imageImporter dispatch (AC-18)', () => {
|
|
965
|
+
it('an ImporterRegistry resolves meta.importer="image" to imageImporter', () => {
|
|
966
|
+
const registry = new ImporterRegistry();
|
|
967
|
+
registry.register(imageImporter);
|
|
968
|
+
const resolved = registry.get('image');
|
|
969
|
+
expect(resolved).toBe(imageImporter);
|
|
970
|
+
expect(resolved?.key).toBe('image');
|
|
971
|
+
expect(typeof resolved?.import).toBe('function');
|
|
972
|
+
});
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
describe('imageImporter end-to-end (AC-17)', () => {
|
|
976
|
+
it('a synthetic PNG imports into a TextureAsset POD with the right shape', async () => {
|
|
977
|
+
const png = makePng(8, 4, [200, 100, 50, 255]);
|
|
978
|
+
const ctx = makeImpCtx(
|
|
979
|
+
'tile.png',
|
|
980
|
+
png,
|
|
981
|
+
[{ guid: IMP_GUID, sourceIndex: 0, kind: 'texture' }],
|
|
982
|
+
{
|
|
983
|
+
colorSpace: 'srgb',
|
|
984
|
+
mipmap: 'none',
|
|
985
|
+
},
|
|
986
|
+
);
|
|
987
|
+
|
|
988
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
989
|
+
expect(produced.length).toBe(1);
|
|
990
|
+
const asset = produced[0];
|
|
991
|
+
expect(asset?.guid).toBe(IMP_GUID);
|
|
992
|
+
expect(asset?.kind).toBe('texture');
|
|
993
|
+
|
|
994
|
+
const payload = asset?.payload as TextureAsset;
|
|
995
|
+
expect(payload.kind).toBe('texture');
|
|
996
|
+
expect(payload.width).toBe(8);
|
|
997
|
+
expect(payload.height).toBe(4);
|
|
998
|
+
expect(payload.format).toBe('rgba8unorm-srgb');
|
|
999
|
+
expect(payload.colorSpace).toBe('srgb');
|
|
1000
|
+
expect(payload.data.length).toBe(8 * 4 * 4);
|
|
1001
|
+
});
|
|
1002
|
+
|
|
1003
|
+
it('linear colorSpace folds to the non-srgb format', async () => {
|
|
1004
|
+
const png = makePng(2, 2, [10, 20, 30, 255]);
|
|
1005
|
+
const ctx = makeImpCtx(
|
|
1006
|
+
'normal.png',
|
|
1007
|
+
png,
|
|
1008
|
+
[{ guid: IMP_GUID, sourceIndex: 0, kind: 'texture' }],
|
|
1009
|
+
{
|
|
1010
|
+
colorSpace: 'linear',
|
|
1011
|
+
mipmap: 'none',
|
|
1012
|
+
},
|
|
1013
|
+
);
|
|
1014
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
1015
|
+
const payload = produced[0]?.payload as TextureAsset;
|
|
1016
|
+
expect(payload.format).toBe('rgba8unorm');
|
|
1017
|
+
expect(payload.colorSpace).toBe('linear');
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
it('a non-texture sub-asset is not folded by the PNG/JPEG arm', async () => {
|
|
1021
|
+
const png = makePng(2, 2, [0, 0, 0, 255]);
|
|
1022
|
+
const ctx = makeImpCtx('env.png', png, [
|
|
1023
|
+
{ guid: IMP_GUID, sourceIndex: 0, kind: 'equirect' },
|
|
1024
|
+
]);
|
|
1025
|
+
const produced = unwrap(await imageImporter.import(ctx));
|
|
1026
|
+
expect(produced.length).toBe(0);
|
|
1027
|
+
});
|
|
1028
|
+
});
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
{
|
|
1033
|
+
// ─── from parse-image.test.ts ───
|
|
1034
|
+
|
|
1035
|
+
const FIXTURES: Record<string, () => Uint8Array> = {
|
|
1036
|
+
'wood-1x1.png': () => makePng(1, 1, [137, 96, 71, 255]),
|
|
1037
|
+
'jpg-2x2.jpg': () => makeJpg(2, 2, [120, 80, 60, 255]),
|
|
1038
|
+
'corrupt.png': () => makeCorruptPng(),
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
function readFixture(name: string): Uint8Array {
|
|
1042
|
+
const make = FIXTURES[name];
|
|
1043
|
+
if (!make) throw new Error(`unknown fixture: ${name}`);
|
|
1044
|
+
return make();
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
describe('parse-image.test.ts', () => {
|
|
1048
|
+
describe('parseImage -- pure function: bytes + mime -> Result<DecodedImage, ImageError>', () => {
|
|
1049
|
+
it('decodes a valid 1x1 PNG into a DecodedImage POD (charter P3 explicit success)', () => {
|
|
1050
|
+
const bytes = readFixture('wood-1x1.png');
|
|
1051
|
+
const r = parseImage(bytes, 'image/png');
|
|
1052
|
+
expect(r.ok).toBe(true);
|
|
1053
|
+
if (!r.ok) return;
|
|
1054
|
+
expect(r.value.width).toBe(1);
|
|
1055
|
+
expect(r.value.height).toBe(1);
|
|
1056
|
+
expect(r.value.mime).toBe('image/png');
|
|
1057
|
+
expect(r.value.bytes).toBeInstanceOf(Uint8Array);
|
|
1058
|
+
expect(r.value.bytes.length).toBe(4);
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
it('decodes a valid 2x2 JPG into a DecodedImage POD', () => {
|
|
1062
|
+
const bytes = readFixture('jpg-2x2.jpg');
|
|
1063
|
+
const r = parseImage(bytes, 'image/jpeg');
|
|
1064
|
+
expect(r.ok).toBe(true);
|
|
1065
|
+
if (!r.ok) return;
|
|
1066
|
+
expect(r.value.width).toBe(2);
|
|
1067
|
+
expect(r.value.height).toBe(2);
|
|
1068
|
+
expect(r.value.mime).toBe('image/jpeg');
|
|
1069
|
+
expect(r.value.bytes.length).toBe(16);
|
|
1070
|
+
});
|
|
1071
|
+
|
|
1072
|
+
it('returns image-decode-failed on a corrupted PNG byte stream', () => {
|
|
1073
|
+
const bytes = readFixture('corrupt.png');
|
|
1074
|
+
const r = parseImage(bytes, 'image/png');
|
|
1075
|
+
expect(r.ok).toBe(false);
|
|
1076
|
+
if (r.ok) return;
|
|
1077
|
+
expect(r.error.code).toBe('image-decode-failed');
|
|
1078
|
+
expect(r.error.detail.code).toBe('image-decode-failed');
|
|
1079
|
+
if (r.error.detail.code !== 'image-decode-failed') return;
|
|
1080
|
+
expect(typeof r.error.detail.reason).toBe('string');
|
|
1081
|
+
expect(r.error.detail.reason.length).toBeGreaterThan(0);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it('returns image-format-unsupported for image/webp mime', () => {
|
|
1085
|
+
const bytes = readFixture('jpg-2x2.jpg');
|
|
1086
|
+
const r = parseImage(bytes, 'image/webp');
|
|
1087
|
+
expect(r.ok).toBe(false);
|
|
1088
|
+
if (r.ok) return;
|
|
1089
|
+
expect(r.error.code).toBe('image-format-unsupported');
|
|
1090
|
+
if (r.error.detail.code !== 'image-format-unsupported') return;
|
|
1091
|
+
expect(r.error.detail.actualMime).toBe('image/webp');
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it('returns image-format-unsupported for image/gif mime', () => {
|
|
1095
|
+
const bytes = readFixture('jpg-2x2.jpg');
|
|
1096
|
+
const r = parseImage(bytes, 'image/gif');
|
|
1097
|
+
expect(r.ok).toBe(false);
|
|
1098
|
+
if (r.ok) return;
|
|
1099
|
+
expect(r.error.code).toBe('image-format-unsupported');
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
it('returns image-format-unsupported for image/bmp mime', () => {
|
|
1103
|
+
const bytes = readFixture('jpg-2x2.jpg');
|
|
1104
|
+
const r = parseImage(bytes, 'image/bmp');
|
|
1105
|
+
expect(r.ok).toBe(false);
|
|
1106
|
+
if (r.ok) return;
|
|
1107
|
+
expect(r.error.code).toBe('image-format-unsupported');
|
|
1108
|
+
});
|
|
1109
|
+
|
|
1110
|
+
it('returns image-dimension-out-of-bounds when width or height exceeds maxDimension', () => {
|
|
1111
|
+
const bytes = readFixture('jpg-2x2.jpg');
|
|
1112
|
+
const r = parseImage(bytes, 'image/jpeg', { maxDimension: 1 });
|
|
1113
|
+
expect(r.ok).toBe(false);
|
|
1114
|
+
if (r.ok) return;
|
|
1115
|
+
expect(r.error.code).toBe('image-dimension-out-of-bounds');
|
|
1116
|
+
if (r.error.detail.code !== 'image-dimension-out-of-bounds') return;
|
|
1117
|
+
expect(r.error.detail.requested).toEqual({ width: 2, height: 2 });
|
|
1118
|
+
expect(r.error.detail.limit).toBe(1);
|
|
1119
|
+
});
|
|
1120
|
+
});
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
{
|
|
1125
|
+
// ─── from reimport-reuse-meta.test.ts ───
|
|
1126
|
+
|
|
1127
|
+
function makeDecoded(): DecodedImage {
|
|
1128
|
+
return {
|
|
1129
|
+
bytes: new Uint8Array(4),
|
|
1130
|
+
width: 1,
|
|
1131
|
+
height: 1,
|
|
1132
|
+
mime: 'image/png',
|
|
1133
|
+
colorSpace: 'srgb',
|
|
1134
|
+
mipmap: true,
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const STABLE_GUID = '01928000-7c00-7000-8000-000000000042';
|
|
1139
|
+
|
|
1140
|
+
function existingMeta(): ExistingExternalAssetPackage {
|
|
1141
|
+
return {
|
|
1142
|
+
schemaVersion: '1.0.0',
|
|
1143
|
+
kind: 'external-asset-package',
|
|
1144
|
+
importer: 'image',
|
|
1145
|
+
source: 'wood.png',
|
|
1146
|
+
importSettings: {
|
|
1147
|
+
colorSpace: 'srgb',
|
|
1148
|
+
mipmap: 'auto',
|
|
1149
|
+
addressMode: 'repeat',
|
|
1150
|
+
filterMode: 'linear',
|
|
1151
|
+
},
|
|
1152
|
+
subAssets: [
|
|
1153
|
+
{
|
|
1154
|
+
guid: STABLE_GUID,
|
|
1155
|
+
sourceIndex: 0,
|
|
1156
|
+
kind: 'texture',
|
|
1157
|
+
},
|
|
1158
|
+
],
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
describe('reimport-reuse-meta.test.ts', () => {
|
|
1163
|
+
describe('reimportReuseMeta -- two-phase matching (kind+name+idx -> kind+idx -> fresh v7) AC-16', () => {
|
|
1164
|
+
it('first pass (no existing meta): emits all-fresh subAssets[]', () => {
|
|
1165
|
+
const subs = reimportReuseMeta(makeDecoded(), undefined);
|
|
1166
|
+
expect(subs).toHaveLength(1);
|
|
1167
|
+
expect(subs[0]?.kind).toBe('texture');
|
|
1168
|
+
expect(subs[0]?.guid).toMatch(
|
|
1169
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
|
1170
|
+
);
|
|
1171
|
+
});
|
|
1172
|
+
|
|
1173
|
+
it('reimport with existing meta + identical sub-asset-key reuses GUID byte-for-byte (AC-16 byte-identical)', () => {
|
|
1174
|
+
const subs = reimportReuseMeta(makeDecoded(), existingMeta());
|
|
1175
|
+
expect(subs[0]?.guid).toBe(STABLE_GUID);
|
|
1176
|
+
});
|
|
1177
|
+
|
|
1178
|
+
it('reimport with existing meta but different name still falls back via indexFallback (kind+idx phase 2)', () => {
|
|
1179
|
+
const meta: ExistingExternalAssetPackage = {
|
|
1180
|
+
...existingMeta(),
|
|
1181
|
+
subAssets: [{ guid: STABLE_GUID, sourceIndex: 0, kind: 'texture' }],
|
|
1182
|
+
};
|
|
1183
|
+
const subs = reimportReuseMeta(makeDecoded(), meta);
|
|
1184
|
+
expect(subs[0]?.guid).toBe(STABLE_GUID);
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
it('reimport with existing meta but different kind generates a fresh GUID (no cross-kind reuse)', () => {
|
|
1188
|
+
const meta: ExistingExternalAssetPackage = {
|
|
1189
|
+
...existingMeta(),
|
|
1190
|
+
subAssets: [{ guid: STABLE_GUID, sourceIndex: 0, kind: 'mesh' }],
|
|
1191
|
+
};
|
|
1192
|
+
const subs = reimportReuseMeta(makeDecoded(), meta);
|
|
1193
|
+
expect(subs[0]?.guid).not.toBe(STABLE_GUID);
|
|
1194
|
+
expect(subs[0]?.kind).toBe('texture');
|
|
1195
|
+
});
|
|
1196
|
+
|
|
1197
|
+
it('two consecutive reimports of byte-identical bytes produce byte-identical subAssets[] JSON', () => {
|
|
1198
|
+
const a = JSON.stringify(reimportReuseMeta(makeDecoded(), existingMeta()));
|
|
1199
|
+
const b = JSON.stringify(reimportReuseMeta(makeDecoded(), existingMeta()));
|
|
1200
|
+
expect(a).toBe(b);
|
|
1201
|
+
});
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
describe('t3 - *.image.meta.json HDR colorSpace validation (plan-strategy D-8)', () => {
|
|
1205
|
+
it('.hdr extension accepts colorSpace=linear (valid HDR sidecar round-trip)', () => {
|
|
1206
|
+
const r = validateColorSpaceForHdr('.hdr', 'linear');
|
|
1207
|
+
expect(r.ok).toBe(true);
|
|
1208
|
+
if (r.ok) expect(r.value).toBe('linear');
|
|
1209
|
+
});
|
|
1210
|
+
|
|
1211
|
+
it('.hdr extension rejects colorSpace=srgb with explicit expected/actual', () => {
|
|
1212
|
+
const r = validateColorSpaceForHdr('.HDR', 'srgb');
|
|
1213
|
+
expect(r.ok).toBe(false);
|
|
1214
|
+
if (!r.ok) {
|
|
1215
|
+
expect(r.expected).toContain('linear');
|
|
1216
|
+
expect(r.actual).toContain('srgb');
|
|
1217
|
+
}
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
it('.hdr extension case-insensitive accepts colorSpace=linear', () => {
|
|
1221
|
+
const r = validateColorSpaceForHdr('.Hdr', 'linear');
|
|
1222
|
+
expect(r.ok).toBe(true);
|
|
1223
|
+
});
|
|
1224
|
+
|
|
1225
|
+
it('.exr extension (future HDR format) also accepts linear only', () => {
|
|
1226
|
+
const r = validateColorSpaceForHdr('.exr', 'linear');
|
|
1227
|
+
expect(r.ok).toBe(true);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
it('.exr extension rejects srgb', () => {
|
|
1231
|
+
const r = validateColorSpaceForHdr('.exr', 'srgb');
|
|
1232
|
+
expect(r.ok).toBe(false);
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
it('.png extension passes through srgb unchanged (existing behavior preserved)', () => {
|
|
1236
|
+
const r = validateColorSpaceForHdr('.png', 'srgb');
|
|
1237
|
+
expect(r.ok).toBe(true);
|
|
1238
|
+
if (r.ok) expect(r.value).toBe('srgb');
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it('.png extension passes through linear unchanged', () => {
|
|
1242
|
+
const r = validateColorSpaceForHdr('.png', 'linear');
|
|
1243
|
+
expect(r.ok).toBe(true);
|
|
1244
|
+
if (r.ok) expect(r.value).toBe('linear');
|
|
1245
|
+
});
|
|
1246
|
+
|
|
1247
|
+
it('.jpg extension passes through srgb unchanged', () => {
|
|
1248
|
+
const r = validateColorSpaceForHdr('.jpg', 'srgb');
|
|
1249
|
+
expect(r.ok).toBe(true);
|
|
1250
|
+
if (r.ok) expect(r.value).toBe('srgb');
|
|
1251
|
+
});
|
|
1252
|
+
});
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
{
|
|
1257
|
+
// ─── from sub-asset-key.test.ts ───
|
|
1258
|
+
|
|
1259
|
+
describe('sub-asset-key.test.ts', () => {
|
|
1260
|
+
describe('subAssetKey -- shape mirrors gltf loader (kind, name?, indexFallback)', () => {
|
|
1261
|
+
it('emits kind="texture" + indexFallback="textures/0" for a single-image source (AC-14)', () => {
|
|
1262
|
+
const k = subAssetKey({ kind: 'texture', sourceIndex: 0 });
|
|
1263
|
+
expect(k.kind).toBe('texture');
|
|
1264
|
+
expect(k.indexFallback).toBe('textures/0');
|
|
1265
|
+
expect(k.name).toBeUndefined();
|
|
1266
|
+
});
|
|
1267
|
+
|
|
1268
|
+
it('preserves the optional name field when supplied', () => {
|
|
1269
|
+
const k = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'wood-diffuse' });
|
|
1270
|
+
expect(k.name).toBe('wood-diffuse');
|
|
1271
|
+
expect(k.indexFallback).toBe('textures/0');
|
|
1272
|
+
});
|
|
1273
|
+
|
|
1274
|
+
it('subAssetKeyEqual returns true for same (kind+name+idx) triple', () => {
|
|
1275
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'wood' });
|
|
1276
|
+
const b = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'wood' });
|
|
1277
|
+
expect(subAssetKeyEqual(a, b)).toBe(true);
|
|
1278
|
+
});
|
|
1279
|
+
|
|
1280
|
+
it('subAssetKeyEqual returns false when name differs', () => {
|
|
1281
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'a' });
|
|
1282
|
+
const b = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'b' });
|
|
1283
|
+
expect(subAssetKeyEqual(a, b)).toBe(false);
|
|
1284
|
+
});
|
|
1285
|
+
|
|
1286
|
+
it('subAssetKeyEqual returns false when sourceIndex differs (indexFallback path differs)', () => {
|
|
1287
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0 });
|
|
1288
|
+
const b = subAssetKey({ kind: 'texture', sourceIndex: 1 });
|
|
1289
|
+
expect(subAssetKeyEqual(a, b)).toBe(false);
|
|
1290
|
+
});
|
|
1291
|
+
|
|
1292
|
+
it('subAssetKeyEqual returns false when kind differs (gltf vs image co-existence)', () => {
|
|
1293
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0 });
|
|
1294
|
+
const b = subAssetKey({ kind: 'mesh', sourceIndex: 0 });
|
|
1295
|
+
expect(subAssetKeyEqual(a, b)).toBe(false);
|
|
1296
|
+
});
|
|
1297
|
+
|
|
1298
|
+
// P1: sub-asset-key interface shape invariant — kind is string-typed, not
|
|
1299
|
+
// restricted to 'image'. indexFallback formula uses `${kind}s/${sourceIndex}`
|
|
1300
|
+
// and must survive the kind value change from 'image' to 'texture'.
|
|
1301
|
+
it('P1: subAssetKey with kind="texture" produces indexFallback="textures/0"', () => {
|
|
1302
|
+
const k = subAssetKey({ kind: 'texture', sourceIndex: 0 });
|
|
1303
|
+
expect(k.kind).toBe('texture');
|
|
1304
|
+
expect(k.indexFallback).toBe('textures/0');
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
it('P1: subAssetKey with kind="texture" + name preserves name and indexFallback', () => {
|
|
1308
|
+
const k = subAssetKey({ kind: 'texture', sourceIndex: 1, name: 'atlas-msdf' });
|
|
1309
|
+
expect(k.kind).toBe('texture');
|
|
1310
|
+
expect(k.name).toBe('atlas-msdf');
|
|
1311
|
+
expect(k.indexFallback).toBe('textures/1');
|
|
1312
|
+
});
|
|
1313
|
+
|
|
1314
|
+
it('P1: subAssetKeyEqual works with kind="texture" (same kind+name+idx)', () => {
|
|
1315
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'diffuse' });
|
|
1316
|
+
const b = subAssetKey({ kind: 'texture', sourceIndex: 0, name: 'diffuse' });
|
|
1317
|
+
expect(subAssetKeyEqual(a, b)).toBe(true);
|
|
1318
|
+
});
|
|
1319
|
+
|
|
1320
|
+
// subAssetKeyEqual cross-kind: 'texture' is not equal to 'font'.
|
|
1321
|
+
it('P1: subAssetKeyEqual distinguishes "texture" from "font"', () => {
|
|
1322
|
+
const a = subAssetKey({ kind: 'texture', sourceIndex: 0 });
|
|
1323
|
+
const b = subAssetKey({ kind: 'font', sourceIndex: 0 });
|
|
1324
|
+
expect(subAssetKeyEqual(a, b)).toBe(false);
|
|
1325
|
+
});
|
|
1326
|
+
});
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
{
|
|
1331
|
+
// ─── from to-asset-pack.test.ts ───
|
|
1332
|
+
|
|
1333
|
+
function makeTAPDecoded(): DecodedImage {
|
|
1334
|
+
return {
|
|
1335
|
+
bytes: new Uint8Array(4),
|
|
1336
|
+
width: 1,
|
|
1337
|
+
height: 1,
|
|
1338
|
+
mime: 'image/png',
|
|
1339
|
+
colorSpace: 'srgb',
|
|
1340
|
+
mipmap: true,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
function makeTAPMeta(): ImageMeta {
|
|
1345
|
+
return {
|
|
1346
|
+
guid: '01928000-7c00-7000-8000-000000000010',
|
|
1347
|
+
colorSpace: 'srgb',
|
|
1348
|
+
mipmap: 'auto',
|
|
1349
|
+
addressMode: 'repeat',
|
|
1350
|
+
filterMode: 'linear',
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
describe('to-asset-pack.test.ts', () => {
|
|
1355
|
+
describe('toAssetPack -- pure function: DecodedImage + ImageMeta -> external-asset-package SubAsset list', () => {
|
|
1356
|
+
it('emits a single subAsset with kind="texture" + guid copied from meta', () => {
|
|
1357
|
+
const pack = toAssetPack(makeTAPDecoded(), makeTAPMeta());
|
|
1358
|
+
expect(pack.subAssets).toHaveLength(1);
|
|
1359
|
+
expect(pack.subAssets[0]?.kind).toBe('texture');
|
|
1360
|
+
expect(pack.subAssets[0]?.guid).toBe('01928000-7c00-7000-8000-000000000010');
|
|
1361
|
+
expect(pack.subAssets[0]?.sourceIndex).toBe(0);
|
|
1362
|
+
});
|
|
1363
|
+
|
|
1364
|
+
it('emits importSettings carrying the 4 free-form fields verbatim', () => {
|
|
1365
|
+
const pack = toAssetPack(makeTAPDecoded(), makeTAPMeta());
|
|
1366
|
+
expect(pack.importSettings.colorSpace).toBe('srgb');
|
|
1367
|
+
expect(pack.importSettings.mipmap).toBe('auto');
|
|
1368
|
+
expect(pack.importSettings.addressMode).toBe('repeat');
|
|
1369
|
+
expect(pack.importSettings.filterMode).toBe('linear');
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
it('byte-stable JSON round-trip: stringify(toAssetPack(...)) is idempotent (AC-16)', () => {
|
|
1373
|
+
const a = JSON.stringify(toAssetPack(makeTAPDecoded(), makeTAPMeta()));
|
|
1374
|
+
const b = JSON.stringify(toAssetPack(makeTAPDecoded(), makeTAPMeta()));
|
|
1375
|
+
expect(a).toBe(b);
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
// P1: toAssetPack produces subAssets[].kind === 'texture' instead of 'image'.
|
|
1379
|
+
it('P1: emits subAssets[].kind = "texture" (not "image")', () => {
|
|
1380
|
+
const pack = toAssetPack(makeTAPDecoded(), makeTAPMeta());
|
|
1381
|
+
expect(pack.subAssets).toHaveLength(1);
|
|
1382
|
+
expect(pack.subAssets[0]?.kind).toBe('texture');
|
|
1383
|
+
expect(pack.subAssets[0]?.guid).toBe('01928000-7c00-7000-8000-000000000010');
|
|
1384
|
+
});
|
|
1385
|
+
});
|
|
1386
|
+
});
|
|
1387
|
+
}
|