@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,126 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { basisEncode } from '@forgeax/engine-codec/encode';
|
|
4
|
+
import { ImportError } from '@forgeax/engine-types';
|
|
5
|
+
import { describe, expect, it } from 'vitest';
|
|
6
|
+
import type {
|
|
7
|
+
BasisEncoderModule,
|
|
8
|
+
BasisModuleFactory,
|
|
9
|
+
} from '../../../codec/src/wasm/basis-types.js';
|
|
10
|
+
import { imageImporter } from '../image-importer.js';
|
|
11
|
+
|
|
12
|
+
const TRANSCODER_GLUE = new URL('../../../codec/pkg/basis_transcoder.mjs', import.meta.url);
|
|
13
|
+
const ENCODER_GLUE = new URL('../../../codec/pkg/encode/basis_encoder.mjs', import.meta.url);
|
|
14
|
+
const pkgBuilt =
|
|
15
|
+
existsSync(fileURLToPath(TRANSCODER_GLUE)) && existsSync(fileURLToPath(ENCODER_GLUE));
|
|
16
|
+
|
|
17
|
+
const GUID = '019f0000-0000-7000-8000-000000000301';
|
|
18
|
+
|
|
19
|
+
async function makeKtx2(): Promise<Uint8Array> {
|
|
20
|
+
const result = await basisEncode(new Uint8Array(4 * 4 * 4).fill(127), {
|
|
21
|
+
mode: 'uastc-ldr',
|
|
22
|
+
width: 4,
|
|
23
|
+
height: 4,
|
|
24
|
+
srgb: false,
|
|
25
|
+
perceptual: false,
|
|
26
|
+
uastcSupercompression: true,
|
|
27
|
+
mipGen: false,
|
|
28
|
+
});
|
|
29
|
+
if (!result.ok) throw new Error(result.error.code);
|
|
30
|
+
return result.value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function loadEncoder(): Promise<BasisEncoderModule> {
|
|
34
|
+
const factory = (
|
|
35
|
+
(await import(/* @vite-ignore */ ENCODER_GLUE.href)) as {
|
|
36
|
+
default: BasisModuleFactory<BasisEncoderModule>;
|
|
37
|
+
}
|
|
38
|
+
).default;
|
|
39
|
+
const mod = await factory({
|
|
40
|
+
locateFile: () => new URL('../../../codec/pkg/encode/basis_encoder.wasm', import.meta.url).href,
|
|
41
|
+
});
|
|
42
|
+
mod.initializeBasis();
|
|
43
|
+
return mod;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function makeBasis(): Promise<Uint8Array> {
|
|
47
|
+
const mod = await loadEncoder();
|
|
48
|
+
const encoder = new mod.BasisEncoder();
|
|
49
|
+
try {
|
|
50
|
+
encoder.setSliceSourceImage(0, new Uint8Array(4 * 4 * 4).fill(127), 4, 4, 0);
|
|
51
|
+
encoder.setCreateKTX2File(false);
|
|
52
|
+
encoder.setFormatMode(mod.basis_tex_format.cUASTC_LDR_4x4.value);
|
|
53
|
+
encoder.setPerceptual(false);
|
|
54
|
+
encoder.setMipGen(false);
|
|
55
|
+
const bytes = new Uint8Array(1 << 20);
|
|
56
|
+
const length = encoder.encode(bytes);
|
|
57
|
+
if (length <= 0) throw new Error('raw Basis encode failed');
|
|
58
|
+
return bytes.slice(0, length);
|
|
59
|
+
} finally {
|
|
60
|
+
encoder.delete();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function context(source: string, bytes: Uint8Array) {
|
|
65
|
+
return {
|
|
66
|
+
source,
|
|
67
|
+
readSource: async () => ({ ok: true as const, value: bytes }),
|
|
68
|
+
readSibling: async () => ({
|
|
69
|
+
ok: false as const,
|
|
70
|
+
error: new ImportError({
|
|
71
|
+
code: 'source-read-failed',
|
|
72
|
+
expected: 'no sibling source',
|
|
73
|
+
hint: 'test source has no sibling',
|
|
74
|
+
detail: { source, reason: 'no sibling' },
|
|
75
|
+
}),
|
|
76
|
+
}),
|
|
77
|
+
decodeImage: async () => {
|
|
78
|
+
throw new Error('precompressed source must not call decodeImage');
|
|
79
|
+
},
|
|
80
|
+
subAssets: [{ guid: GUID, sourceIndex: 0, sourceKey: 'main', kind: 'texture' as const }],
|
|
81
|
+
importSettings: { colorSpace: 'linear' as const },
|
|
82
|
+
sourceOverrides: { main: { colorSpace: 'linear' as const } },
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe.skipIf(!pkgBuilt)('KTX2/Basis source importer', () => {
|
|
87
|
+
it('rejects an unsupported precompressed extension before image decoding', async () => {
|
|
88
|
+
await expect(imageImporter.import(context('fixture.dds', new Uint8Array()))).rejects.toThrow(
|
|
89
|
+
'unsupported source extension',
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('imports a KTX2 source without decoding it as PNG/JPEG and preserves codec facts', async () => {
|
|
94
|
+
const bytes = await makeKtx2();
|
|
95
|
+
const result = await imageImporter.import(context('fixture.ktx2', bytes));
|
|
96
|
+
expect(result.ok).toBe(true);
|
|
97
|
+
if (!result.ok) return;
|
|
98
|
+
expect(result.value.assets[0]?.payload).toMatchObject({
|
|
99
|
+
kind: 'texture',
|
|
100
|
+
width: 4,
|
|
101
|
+
height: 4,
|
|
102
|
+
format: 'rgba8unorm',
|
|
103
|
+
colorSpace: 'linear',
|
|
104
|
+
mipmap: false,
|
|
105
|
+
});
|
|
106
|
+
expect(result.value.assets[0]?.artifacts.body?.assetCodec).toMatchObject({
|
|
107
|
+
name: 'basis',
|
|
108
|
+
container: 'ktx2',
|
|
109
|
+
profile: 'uastc-ldr',
|
|
110
|
+
});
|
|
111
|
+
expect(result.value.assets[0]?.artifacts.body?.bytes).toEqual(bytes);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('imports raw Basis with authored Meta color-space provenance', async () => {
|
|
115
|
+
const bytes = await makeBasis();
|
|
116
|
+
const result = await imageImporter.import(context('fixture.basis', bytes));
|
|
117
|
+
expect(result.ok).toBe(true);
|
|
118
|
+
if (!result.ok) return;
|
|
119
|
+
expect(result.value.assets[0]?.payload.colorSpace).toBe('linear');
|
|
120
|
+
expect(result.value.assets[0]?.artifacts.body?.assetCodec).toMatchObject({
|
|
121
|
+
name: 'basis',
|
|
122
|
+
container: 'basis',
|
|
123
|
+
profile: 'uastc-ldr',
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import type { BasisEncodeMode } from '@forgeax/engine-codec/encode';
|
|
5
|
+
import { describe, expect, expectTypeOf, it } from 'vitest';
|
|
6
|
+
import type {
|
|
7
|
+
BasisEncodeParams,
|
|
8
|
+
CompressionMode,
|
|
9
|
+
EncodeTextureResult,
|
|
10
|
+
ResolvedEncodeMode,
|
|
11
|
+
} from '../ktx2-encode.js';
|
|
12
|
+
|
|
13
|
+
type ExpectedCompressionMode = 'auto' | 'etc1s' | 'uastc' | 'none';
|
|
14
|
+
type ExpectedResolvedEncodeMode = 'etc1s' | 'uastc' | 'uastc-hdr' | 'none';
|
|
15
|
+
type EncodedMode = Extract<EncodeTextureResult, { readonly ok: true }>['value']['mode'];
|
|
16
|
+
type FailedMode = Extract<EncodeTextureResult, { readonly ok: false }>['error']['mode'];
|
|
17
|
+
|
|
18
|
+
const encodeSource = readFileSync(new URL('../ktx2-encode.ts', import.meta.url), 'utf8');
|
|
19
|
+
|
|
20
|
+
describe('image KTX2 encode mode owner', () => {
|
|
21
|
+
it('keeps both exact image-side vocabularies and their public projections', () => {
|
|
22
|
+
expectTypeOf<CompressionMode>().toEqualTypeOf<ExpectedCompressionMode>();
|
|
23
|
+
expectTypeOf<ExpectedCompressionMode>().toEqualTypeOf<CompressionMode>();
|
|
24
|
+
expectTypeOf<ResolvedEncodeMode>().toEqualTypeOf<ExpectedResolvedEncodeMode>();
|
|
25
|
+
expectTypeOf<ExpectedResolvedEncodeMode>().toEqualTypeOf<ResolvedEncodeMode>();
|
|
26
|
+
|
|
27
|
+
const acceptsCompressionMode = (mode: CompressionMode): CompressionMode => mode;
|
|
28
|
+
acceptsCompressionMode('auto');
|
|
29
|
+
acceptsCompressionMode('etc1s');
|
|
30
|
+
acceptsCompressionMode('uastc');
|
|
31
|
+
acceptsCompressionMode('none');
|
|
32
|
+
// @ts-expect-error Delivery modes are not sidecar control-plane values.
|
|
33
|
+
acceptsCompressionMode('uastc-hdr');
|
|
34
|
+
|
|
35
|
+
const acceptsResolvedMode = (mode: ResolvedEncodeMode): ResolvedEncodeMode => mode;
|
|
36
|
+
acceptsResolvedMode('etc1s');
|
|
37
|
+
acceptsResolvedMode('uastc');
|
|
38
|
+
acceptsResolvedMode('uastc-hdr');
|
|
39
|
+
acceptsResolvedMode('none');
|
|
40
|
+
// @ts-expect-error The control-plane-only auto mode is not a resolved encoding.
|
|
41
|
+
acceptsResolvedMode('auto');
|
|
42
|
+
|
|
43
|
+
expectTypeOf<EncodedMode>().toEqualTypeOf<ResolvedEncodeMode>();
|
|
44
|
+
expectTypeOf<FailedMode>().toEqualTypeOf<ResolvedEncodeMode>();
|
|
45
|
+
expectTypeOf<BasisEncodeParams['mode']>().toEqualTypeOf<BasisEncodeMode>();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('keeps the runtime tuples as the single image-side mode owners', () => {
|
|
49
|
+
expect(encodeSource).toContain(
|
|
50
|
+
"const COMPRESSION_MODES = ['auto', 'etc1s', 'uastc', 'none'] as const;",
|
|
51
|
+
);
|
|
52
|
+
expect(encodeSource).toContain(
|
|
53
|
+
'export type CompressionMode = (typeof COMPRESSION_MODES)[number];',
|
|
54
|
+
);
|
|
55
|
+
expect(encodeSource).toContain(
|
|
56
|
+
"const RESOLVED_ENCODE_MODES = ['etc1s', 'uastc', 'uastc-hdr', 'none'] as const;",
|
|
57
|
+
);
|
|
58
|
+
expect(encodeSource).toContain(
|
|
59
|
+
'export type ResolvedEncodeMode = (typeof RESOLVED_ENCODE_MODES)[number];',
|
|
60
|
+
);
|
|
61
|
+
expect(encodeSource).toContain('case COMPRESSION_MODES[3]:');
|
|
62
|
+
expect(encodeSource).toContain('case RESOLVED_ENCODE_MODES[2]:');
|
|
63
|
+
expect(encodeSource).not.toContain(
|
|
64
|
+
"export type CompressionMode = 'auto' | 'etc1s' | 'uastc' | 'none';",
|
|
65
|
+
);
|
|
66
|
+
expect(encodeSource).not.toContain(
|
|
67
|
+
"export type ResolvedEncodeMode = 'etc1s' | 'uastc' | 'uastc-hdr' | 'none';",
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Synthetic image fixtures generated algorithmically so the engine repo
|
|
2
|
+
// carries zero committed image binaries (and zero inlined asset byte blobs,
|
|
3
|
+
// which would just be a relabelled asset). The PNG path hand-builds the
|
|
4
|
+
// chunk structure (IHDR + IDAT + IEND) and deflates the raw scanlines with
|
|
5
|
+
// node's zlib; the JPEG path uses jpeg-js, already a runtime dep of
|
|
6
|
+
// @forgeax/engine-image. Both produce standard-conformant streams that the
|
|
7
|
+
// production decoder (upng-js / jpeg-js in parse-image.ts) reads back.
|
|
8
|
+
import { deflateSync } from 'node:zlib';
|
|
9
|
+
import * as jpeg from 'jpeg-js';
|
|
10
|
+
|
|
11
|
+
const jpegEncode = ((jpeg as unknown as { encode?: typeof jpeg.encode }).encode ??
|
|
12
|
+
(jpeg as unknown as { default: { encode: typeof jpeg.encode } }).default
|
|
13
|
+
.encode) as typeof jpeg.encode;
|
|
14
|
+
|
|
15
|
+
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
16
|
+
|
|
17
|
+
// CRC-32 (PNG polynomial) over a chunk's type+data.
|
|
18
|
+
function crc32(bytes: Uint8Array): number {
|
|
19
|
+
let crc = 0xffffffff;
|
|
20
|
+
for (const byte of bytes) {
|
|
21
|
+
crc ^= byte;
|
|
22
|
+
for (let k = 0; k < 8; k++) {
|
|
23
|
+
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function u32(n: number): number[] {
|
|
30
|
+
return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function chunk(type: string, data: Uint8Array): number[] {
|
|
34
|
+
const typeBytes = [...type].map((c) => c.charCodeAt(0));
|
|
35
|
+
const body = new Uint8Array([...typeBytes, ...data]);
|
|
36
|
+
return [...u32(data.length), ...body, ...u32(crc32(body))];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Solid-colour 8-bit RGBA PNG, standard zlib/deflate encoding. */
|
|
40
|
+
export function makePng(
|
|
41
|
+
width: number,
|
|
42
|
+
height: number,
|
|
43
|
+
rgba: [number, number, number, number],
|
|
44
|
+
): Uint8Array {
|
|
45
|
+
// IHDR: width, height, bit-depth=8, colour-type=6 (RGBA), no interlace.
|
|
46
|
+
const ihdr = new Uint8Array([...u32(width), ...u32(height), 8, 6, 0, 0, 0]);
|
|
47
|
+
// Raw image: each scanline prefixed with a filter-type byte (0 = none).
|
|
48
|
+
const raw = new Uint8Array(height * (1 + width * 4));
|
|
49
|
+
let o = 0;
|
|
50
|
+
for (let y = 0; y < height; y++) {
|
|
51
|
+
raw[o++] = 0;
|
|
52
|
+
for (let x = 0; x < width; x++) {
|
|
53
|
+
raw[o++] = rgba[0];
|
|
54
|
+
raw[o++] = rgba[1];
|
|
55
|
+
raw[o++] = rgba[2];
|
|
56
|
+
raw[o++] = rgba[3];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const idat = new Uint8Array(deflateSync(raw));
|
|
60
|
+
return new Uint8Array([
|
|
61
|
+
...PNG_MAGIC,
|
|
62
|
+
...chunk('IHDR', ihdr),
|
|
63
|
+
...chunk('IDAT', idat),
|
|
64
|
+
...chunk('IEND', new Uint8Array(0)),
|
|
65
|
+
]);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Solid-colour RGBA JPEG of the given dimensions. */
|
|
69
|
+
export function makeJpg(
|
|
70
|
+
width: number,
|
|
71
|
+
height: number,
|
|
72
|
+
rgba: [number, number, number, number],
|
|
73
|
+
): Uint8Array {
|
|
74
|
+
const data = new Uint8Array(width * height * 4);
|
|
75
|
+
for (let i = 0; i < width * height; i++) {
|
|
76
|
+
data[i * 4 + 0] = rgba[0];
|
|
77
|
+
data[i * 4 + 1] = rgba[1];
|
|
78
|
+
data[i * 4 + 2] = rgba[2];
|
|
79
|
+
data[i * 4 + 3] = rgba[3];
|
|
80
|
+
}
|
|
81
|
+
const encoded = jpegEncode({ data: Buffer.from(data), width, height }, 90);
|
|
82
|
+
return new Uint8Array(encoded.data);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A valid PNG header + IHDR followed by an IDAT whose deflate stream is
|
|
87
|
+
* garbage, so a real PNG decoder fails (image-decode-failed). Built from the
|
|
88
|
+
* same chunk machinery as makePng, with a corrupted IDAT body — replaces the
|
|
89
|
+
* former committed corrupt.png fixture.
|
|
90
|
+
*/
|
|
91
|
+
export function makeCorruptPng(): Uint8Array {
|
|
92
|
+
const ihdr = new Uint8Array([...u32(1), ...u32(1), 8, 6, 0, 0, 0]);
|
|
93
|
+
const garbageIdat = new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff]);
|
|
94
|
+
return new Uint8Array([
|
|
95
|
+
...PNG_MAGIC,
|
|
96
|
+
...chunk('IHDR', ihdr),
|
|
97
|
+
...chunk('IDAT', garbageIdat),
|
|
98
|
+
...chunk('IEND', new Uint8Array(0)),
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseImage } from '../parse-image.js';
|
|
3
|
+
import { makeJpg } from './make-fixture.js';
|
|
4
|
+
|
|
5
|
+
describe('parseImage asset-owned downscale', () => {
|
|
6
|
+
it('emits an aspect-preserving cooked payload under the declared target', async () => {
|
|
7
|
+
const result = parseImage(makeJpg(2, 2, [255, 0, 0, 255]), 'image/jpeg', {
|
|
8
|
+
downscaleMaxDimension: 1,
|
|
9
|
+
maxDimension: 1,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(result.ok).toBe(true);
|
|
13
|
+
if (!result.ok) return;
|
|
14
|
+
expect(result.value.width).toBe(1);
|
|
15
|
+
expect(result.value.height).toBe(1);
|
|
16
|
+
expect(result.value.bytes).toHaveLength(4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('keeps current hard-bound behavior when no downscale target is declared', async () => {
|
|
20
|
+
const result = parseImage(makeJpg(2, 2, [255, 0, 0, 255]), 'image/jpeg', { maxDimension: 1 });
|
|
21
|
+
|
|
22
|
+
expect(result.ok).toBe(false);
|
|
23
|
+
if (result.ok) return;
|
|
24
|
+
expect(result.error.code).toBe('image-dimension-out-of-bounds');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { decodeImageBytes } from '../runtime/decode-image-bytes';
|
|
3
|
+
|
|
4
|
+
describe('image runtime decode owner', () => {
|
|
5
|
+
it('returns a closed failure when the host has no browser decoder', async () => {
|
|
6
|
+
const result = await decodeImageBytes(new Uint8Array([0x89, 0x50]), 'image/png');
|
|
7
|
+
|
|
8
|
+
expect(result.ok).toBe(false);
|
|
9
|
+
if (result.ok) return;
|
|
10
|
+
expect(result.error.code).toBe('image-decode-failed');
|
|
11
|
+
expect(result.error.expected).toContain('decodes successfully');
|
|
12
|
+
expect(result.error.hint).toBeTruthy();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('does not silently reinterpret a codec MIME as a different image kind', async () => {
|
|
16
|
+
const result = await decodeImageBytes(new Uint8Array([1, 2, 3]), 'image/jpeg');
|
|
17
|
+
|
|
18
|
+
expect(result.ok).toBe(false);
|
|
19
|
+
if (result.ok) return;
|
|
20
|
+
expect(result.error.detail.code).toBe('image-decode-failed');
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { type ExistingExternalAssetPackage, reimportReuseMeta } from '../reimport-reuse-meta.js';
|
|
3
|
+
import { deriveImageSourceKey, deriveImageSourceKeys } from '../source-key.js';
|
|
4
|
+
import { subAssetKey } from '../sub-asset-key.js';
|
|
5
|
+
|
|
6
|
+
const GUID = '01928000-7c00-7000-8000-000000000042';
|
|
7
|
+
|
|
8
|
+
function decoded(): Parameters<typeof reimportReuseMeta>[0] {
|
|
9
|
+
return {
|
|
10
|
+
bytes: new Uint8Array(4),
|
|
11
|
+
width: 1,
|
|
12
|
+
height: 1,
|
|
13
|
+
mime: 'image/png',
|
|
14
|
+
colorSpace: 'srgb',
|
|
15
|
+
mipmap: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function existing(): ExistingExternalAssetPackage {
|
|
20
|
+
return {
|
|
21
|
+
schemaVersion: '1.0.0',
|
|
22
|
+
kind: 'external-asset-package',
|
|
23
|
+
importer: 'image',
|
|
24
|
+
source: 'renamed.png',
|
|
25
|
+
importSettings: {},
|
|
26
|
+
subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'texture', sourceKey: 'image:texture' }],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('image producer sourceKey', () => {
|
|
31
|
+
it('uses a role key independent of path and sourceIndex', () => {
|
|
32
|
+
expect(deriveImageSourceKey('texture')).toBe('image:texture');
|
|
33
|
+
expect(deriveImageSourceKey('texture', { sourcePath: 'moved/wood.png', sourceIndex: 8 })).toBe(
|
|
34
|
+
'image:texture',
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('rejects an empty role instead of manufacturing an index key', () => {
|
|
39
|
+
expect(deriveImageSourceKey('')).toBeUndefined();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('requires unique semantic roles for multi-output images', () => {
|
|
43
|
+
expect(deriveImageSourceKeys(['texture', 'equirect'])).toEqual({
|
|
44
|
+
ok: true,
|
|
45
|
+
keys: ['image:texture', 'image:equirect'],
|
|
46
|
+
});
|
|
47
|
+
expect(deriveImageSourceKeys(['texture', 'texture'])).toMatchObject({
|
|
48
|
+
ok: false,
|
|
49
|
+
code: 'duplicate-source-key',
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('keeps legacy indexFallback separate from producer sourceKey', () => {
|
|
54
|
+
expect(subAssetKey({ kind: 'texture', sourceIndex: 0 })).toEqual({
|
|
55
|
+
kind: 'texture',
|
|
56
|
+
indexFallback: 'textures/0',
|
|
57
|
+
});
|
|
58
|
+
expect(deriveImageSourceKey('texture')).not.toContain('0');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reuses identity after source relocation while emitting the role key', () => {
|
|
62
|
+
const result = reimportReuseMeta(decoded(), existing());
|
|
63
|
+
expect(result[0]).toMatchObject({ guid: GUID, sourceIndex: 0, sourceKey: 'image:texture' });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseImage } from '../parse-image.js';
|
|
3
|
+
|
|
4
|
+
function makeTga24(
|
|
5
|
+
width: number,
|
|
6
|
+
height: number,
|
|
7
|
+
bgrPixels: readonly number[],
|
|
8
|
+
imageDescriptor = 0,
|
|
9
|
+
): Uint8Array {
|
|
10
|
+
const header = new Uint8Array(18);
|
|
11
|
+
header[2] = 2; // uncompressed true-color
|
|
12
|
+
header[12] = width & 0xff;
|
|
13
|
+
header[13] = (width >>> 8) & 0xff;
|
|
14
|
+
header[14] = height & 0xff;
|
|
15
|
+
header[15] = (height >>> 8) & 0xff;
|
|
16
|
+
header[16] = 24;
|
|
17
|
+
header[17] = imageDescriptor;
|
|
18
|
+
return new Uint8Array([...header, ...bgrPixels]);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('parseImage TGA', () => {
|
|
22
|
+
it('decodes 24-bit true-color pixels to RGBA and honours bottom-left origin', () => {
|
|
23
|
+
// File order is bottom row first: blue, green, then top row red, yellow.
|
|
24
|
+
// The descriptor has no top bit, so the decoded image must be top row
|
|
25
|
+
// first while preserving left-to-right order.
|
|
26
|
+
const tga = makeTga24(2, 2, [255, 0, 0, 0, 255, 0, 0, 0, 255, 0, 255, 255]);
|
|
27
|
+
const result = parseImage(tga, 'image/x-tga', { mipmap: false });
|
|
28
|
+
|
|
29
|
+
expect(result.ok).toBe(true);
|
|
30
|
+
if (!result.ok) return;
|
|
31
|
+
expect(result.value.mime).toBe('image/x-tga');
|
|
32
|
+
expect(Array.from(result.value.bytes)).toEqual([
|
|
33
|
+
255, 0, 0, 255, 255, 255, 0, 255, 0, 0, 255, 255, 0, 255, 0, 255,
|
|
34
|
+
]);
|
|
35
|
+
expect(result.value.width).toBe(2);
|
|
36
|
+
expect(result.value.height).toBe(2);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('decodes 32-bit RLE pixels and preserves alpha', () => {
|
|
40
|
+
const header = new Uint8Array(18);
|
|
41
|
+
header[2] = 10; // RLE true-color
|
|
42
|
+
header[12] = 2;
|
|
43
|
+
header[14] = 1;
|
|
44
|
+
header[16] = 32;
|
|
45
|
+
const tga = new Uint8Array([
|
|
46
|
+
...header,
|
|
47
|
+
0x81, // run of two pixels
|
|
48
|
+
10,
|
|
49
|
+
20,
|
|
50
|
+
30,
|
|
51
|
+
40,
|
|
52
|
+
]);
|
|
53
|
+
const result = parseImage(tga, 'image/x-tga');
|
|
54
|
+
|
|
55
|
+
expect(result.ok).toBe(true);
|
|
56
|
+
if (!result.ok) return;
|
|
57
|
+
expect(Array.from(result.value.bytes)).toEqual([30, 20, 10, 40, 30, 20, 10, 40]);
|
|
58
|
+
});
|
|
59
|
+
});
|