@forgeax/engine-image 0.1.7 → 0.1.20

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.
@@ -46,6 +46,7 @@ import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
46
46
  import type { CompressionMode } from './ktx2-encode.js';
47
47
  import { encodeTextureToKtx2, resolveEncodeMode } from './ktx2-encode.js';
48
48
  import { parseImage } from './parse-image.js';
49
+ import { importTextureSource } from './texture/importer.js';
49
50
 
50
51
  /** Map a source path / mime hint to the parseImage mime literal. */
51
52
  function mimeFromSource(source: string): 'image/png' | 'image/jpeg' | undefined {
@@ -319,13 +320,17 @@ async function importKtx2Source(ctx: ImportContext, bytes: Uint8Array): Promise<
319
320
  kind: 'texture',
320
321
  payload: {
321
322
  kind: 'texture',
322
- width: inspection.width,
323
- height: inspection.height,
323
+ shape: {
324
+ viewDimension: '2d',
325
+ extent: { width: inspection.width, height: inspection.height },
326
+ },
324
327
  format: colorSpaceToFormat(inspection.colorSpace),
325
328
  data: bytes,
326
329
  colorSpace: inspection.colorSpace,
327
- mipmap: inspection.levelCount > 1,
328
- mipLevelCount: inspection.levelCount,
330
+ mips:
331
+ inspection.levelCount > 1
332
+ ? { kind: 'packed', levelCount: inspection.levelCount }
333
+ : { kind: 'none' },
329
334
  },
330
335
  refs: [],
331
336
  artifacts: {
@@ -481,13 +486,17 @@ async function importBasisSource(ctx: ImportContext, bytes: Uint8Array): Promise
481
486
  kind: 'texture',
482
487
  payload: {
483
488
  kind: 'texture',
484
- width: inspection.width,
485
- height: inspection.height,
489
+ shape: {
490
+ viewDimension: '2d',
491
+ extent: { width: inspection.width, height: inspection.height },
492
+ },
486
493
  format: colorSpaceToFormat(colorSpace),
487
494
  data: bytes,
488
495
  colorSpace,
489
- mipmap: inspection.levelCount > 1,
490
- mipLevelCount: inspection.levelCount,
496
+ mips:
497
+ inspection.levelCount > 1
498
+ ? { kind: 'packed', levelCount: inspection.levelCount }
499
+ : { kind: 'none' },
491
500
  },
492
501
  refs: [],
493
502
  artifacts: {
@@ -574,6 +583,9 @@ async function maybeEncodeTextureBytes(
574
583
  }
575
584
 
576
585
  async function importImage(ctx: ImportContext): Promise<ImportResult> {
586
+ if (ctx.source.toLowerCase().endsWith('.texture.json')) {
587
+ return importTextureSource(ctx);
588
+ }
577
589
  const requiredKind = requiredImageOutputKind(ctx.source);
578
590
  if (requiredKind !== undefined) {
579
591
  const topologyError = validateImageOutputTopology(ctx, requiredKind);
@@ -730,12 +742,11 @@ async function importImage(ctx: ImportContext): Promise<ImportResult> {
730
742
  if (sub.kind !== 'texture') continue;
731
743
  const payload: TextureAsset = {
732
744
  kind: 'texture',
733
- width: dec.width,
734
- height: dec.height,
745
+ shape: { viewDimension: '2d', extent: { width: dec.width, height: dec.height } },
735
746
  format: colorSpaceToFormat(colorSpace),
736
747
  data: encodedBytes ?? dec.bytes,
737
748
  colorSpace,
738
- mipmap,
749
+ mips: mipmap ? { kind: 'generate' } : { kind: 'none' },
739
750
  };
740
751
  out.push({
741
752
  guid: sub.guid,
@@ -825,11 +836,10 @@ export const decodeImageForImport: ImportContext['decodeImage'] = async (
825
836
  texture: {
826
837
  kind: 'texture' as const,
827
838
  data: cookedBytes,
828
- width: tex.width,
829
- height: tex.height,
839
+ shape: { viewDimension: '2d', extent: { width: tex.width, height: tex.height } },
830
840
  format: colorSpaceToFormat(colorSpace),
831
841
  colorSpace,
832
- mipmap,
842
+ mips: mipmap ? { kind: 'generate' as const } : { kind: 'none' as const },
833
843
  },
834
844
  bytes: cookedBytes,
835
845
  mediaType,
@@ -37,11 +37,32 @@ function compressedImageTarget(colorSpace: TextureAsset['colorSpace']): TextureA
37
37
  return colorSpace === 'srgb' ? 'rgba8unorm-srgb' : 'rgba8unorm';
38
38
  }
39
39
 
40
- function validImageSurface(
40
+ function validTextureSurface(
41
41
  value: unknown,
42
- ): value is Pick<TextureAsset, 'width' | 'height' | 'format' | 'data' | 'colorSpace'> {
42
+ ): value is Pick<TextureAsset, 'shape' | 'format' | 'data' | 'colorSpace' | 'mips'> {
43
43
  if (value === null || typeof value !== 'object') return false;
44
44
  const candidate = value as Partial<TextureAsset>;
45
+ const shape = candidate.shape;
46
+ const extent = shape?.viewDimension === '2d' ? shape.extent : undefined;
47
+ const mips = candidate.mips;
48
+ return (
49
+ extent !== undefined &&
50
+ validDimensions(extent.width, extent.height) &&
51
+ mips !== undefined &&
52
+ (mips.kind === 'none' ||
53
+ mips.kind === 'generate' ||
54
+ (mips.kind === 'packed' && Number.isSafeInteger(mips.levelCount) && mips.levelCount > 0)) &&
55
+ typeof candidate.format === 'string' &&
56
+ imageBytes(candidate.data) !== undefined &&
57
+ (candidate.colorSpace === 'srgb' || candidate.colorSpace === 'linear')
58
+ );
59
+ }
60
+
61
+ function validEquirectSurface(
62
+ value: unknown,
63
+ ): value is Pick<EquirectAsset, 'width' | 'height' | 'format' | 'data' | 'colorSpace'> {
64
+ if (value === null || typeof value !== 'object') return false;
65
+ const candidate = value as Partial<EquirectAsset>;
45
66
  return (
46
67
  validDimensions(candidate.width ?? 0, candidate.height ?? 0) &&
47
68
  typeof candidate.format === 'string' &&
@@ -96,17 +117,24 @@ async function readImageSurface<P extends TextureAsset | EquirectAsset>(
96
117
  const mip = transcoded.value.mips[0];
97
118
  if (mip === undefined) return invalid(envelope.guid, expected, 'codec:base-mip-missing');
98
119
  data = mip.data;
99
- return readDecodedSurface(envelope.guid, expected, {
100
- ...payload,
101
- width: mip.width,
102
- height: mip.height,
103
- format: target,
104
- data,
105
- });
120
+ return readDecodedSurface(
121
+ envelope.guid,
122
+ expected,
123
+ kind,
124
+ kind === 'texture'
125
+ ? {
126
+ ...payload,
127
+ shape: { viewDimension: '2d', extent: { width: mip.width, height: mip.height } },
128
+ format: target,
129
+ data,
130
+ mips: { kind: 'none' },
131
+ }
132
+ : { ...payload, width: mip.width, height: mip.height, format: target, data },
133
+ );
106
134
  }
107
135
  }
108
136
 
109
- return readDecodedSurface(envelope.guid, expected, {
137
+ return readDecodedSurface(envelope.guid, expected, kind, {
110
138
  ...payload,
111
139
  ...(data === undefined ? {} : { data }),
112
140
  });
@@ -115,9 +143,10 @@ async function readImageSurface<P extends TextureAsset | EquirectAsset>(
115
143
  function readDecodedSurface<P extends TextureAsset | EquirectAsset>(
116
144
  guid: string,
117
145
  expected: string,
146
+ kind: P['kind'],
118
147
  candidate: unknown,
119
148
  ): ReturnType<typeof ok<P>> | ReturnType<typeof err<AssetLoadError>> {
120
- if (!validImageSurface(candidate)) {
149
+ if (kind === 'texture' ? !validTextureSurface(candidate) : !validEquirectSurface(candidate)) {
121
150
  return invalid(guid, expected, 'image owner validation failed');
122
151
  }
123
152
  return ok(candidate as P);
@@ -0,0 +1,198 @@
1
+ import type {
2
+ ImportContext,
3
+ ImportedAsset,
4
+ TextureAsset,
5
+ TextureError,
6
+ } from '@forgeax/engine-types';
7
+ import { deriveTextureLayout, err, ImportError, ok, type Result } from '@forgeax/engine-types';
8
+ import {
9
+ parseTextureSourceDescriptor,
10
+ type TextureSourceDescriptor,
11
+ type TextureSourceDescriptorError,
12
+ } from './source-descriptor.js';
13
+
14
+ export interface TextureSourceInput {
15
+ readonly descriptor: unknown;
16
+ readonly guid: string;
17
+ readonly sourceKey: string;
18
+ readSibling(
19
+ uri: string,
20
+ ): Promise<
21
+ | { readonly ok: true; readonly value: Uint8Array }
22
+ | { readonly ok: false; readonly error: unknown }
23
+ >;
24
+ }
25
+
26
+ export interface TextureSourceReadError {
27
+ readonly code: 'source-read-failed';
28
+ readonly expected: string;
29
+ readonly hint: string;
30
+ readonly detail: {
31
+ readonly sourceKey: string;
32
+ readonly sibling: string;
33
+ readonly reason: string;
34
+ };
35
+ }
36
+
37
+ export type TextureSourceError =
38
+ | TextureSourceDescriptorError
39
+ | TextureSourceReadError
40
+ | TextureError;
41
+
42
+ export type TextureSourceResult = Result<ImportedAsset<TextureAsset>, TextureSourceError>;
43
+
44
+ function sourceReadError(input: TextureSourceInput, reason: unknown): TextureSourceReadError {
45
+ return {
46
+ code: 'source-read-failed',
47
+ expected: `readable raw sibling "${input.descriptor && typeof input.descriptor === 'object' && 'rawSibling' in input.descriptor ? input.descriptor.rawSibling : 'rawSibling'}"`,
48
+ hint: 'repair the raw sibling path or bytes and re-import the same texture GUID',
49
+ detail: {
50
+ sourceKey: input.sourceKey,
51
+ sibling:
52
+ input.descriptor && typeof input.descriptor === 'object' && 'rawSibling' in input.descriptor
53
+ ? String(input.descriptor.rawSibling)
54
+ : 'rawSibling',
55
+ reason: reason instanceof Error ? reason.message : String(reason),
56
+ },
57
+ };
58
+ }
59
+
60
+ function bodyMediaType(format: GPUTextureFormat): string {
61
+ return format === 'r8unorm' ? 'application/x-forgeax-r8' : `application/x-forgeax-${format}`;
62
+ }
63
+
64
+ /** Produce one canonical TextureAsset and body artifact from a descriptor sibling. */
65
+ export async function produceTextureSource(
66
+ input: TextureSourceInput,
67
+ ): Promise<TextureSourceResult> {
68
+ const descriptorResult = parseTextureSourceDescriptor(input.descriptor);
69
+ if (!descriptorResult.ok) return descriptorResult;
70
+ const descriptor = descriptorResult.value;
71
+ const sibling = await input.readSibling(descriptor.rawSibling);
72
+ if (!sibling.ok) return err(sourceReadError(input, sibling.error));
73
+
74
+ const layout = deriveTextureLayout({
75
+ shape: descriptor.shape,
76
+ format: descriptor.format,
77
+ mips: descriptor.mips,
78
+ actualByteLength: sibling.value.byteLength,
79
+ order: 'mip-major,image-major,row-major',
80
+ });
81
+ if (!layout.ok) return layout;
82
+
83
+ const data = new Uint8Array(sibling.value);
84
+ return ok({
85
+ guid: input.guid,
86
+ kind: 'texture',
87
+ payload: {
88
+ kind: 'texture',
89
+ shape: descriptor.shape,
90
+ format: descriptor.format,
91
+ colorSpace: descriptor.colorSpace,
92
+ mips: descriptor.mips,
93
+ data,
94
+ },
95
+ refs: [],
96
+ artifacts: {
97
+ body: {
98
+ mediaType: bodyMediaType(descriptor.format),
99
+ assetCodec: { name: descriptor.format, version: '1' },
100
+ bytes: data,
101
+ },
102
+ },
103
+ });
104
+ }
105
+
106
+ function sourceValidationError(ctx: ImportContext, error: TextureSourceError): ImportError {
107
+ const detail = 'detail' in error ? error.detail : { field: 'descriptor', actual: error };
108
+ return new ImportError({
109
+ code: 'source-validation-failed',
110
+ expected: error.expected,
111
+ hint: error.hint,
112
+ detail: {
113
+ diagnostics: [
114
+ {
115
+ code: `texture-source-${error.code}`,
116
+ severity: 'error',
117
+ sourcePath: `${ctx.source}#${'field' in detail ? detail.field : 'rawSibling'}`,
118
+ sourceRange: { start: 0, end: 0, line: 1, column: 1 },
119
+ rule: 'texture-source-descriptor',
120
+ expected: error.expected,
121
+ actual: JSON.stringify(detail),
122
+ hint: error.hint,
123
+ },
124
+ ],
125
+ },
126
+ });
127
+ }
128
+
129
+ /** Import a JSON descriptor whose canonical bytes live in one raw sibling. */
130
+ export async function importTextureSource(ctx: ImportContext): Promise<
131
+ | {
132
+ readonly ok: true;
133
+ readonly value: {
134
+ readonly assets: readonly ImportedAsset[];
135
+ readonly sourceDependencies: readonly string[];
136
+ };
137
+ }
138
+ | { readonly ok: false; readonly error: ImportError }
139
+ > {
140
+ const source = await ctx.readSource();
141
+ if (!source.ok) {
142
+ const reason = String(source.error);
143
+ return {
144
+ ok: false,
145
+ error: new ImportError({
146
+ code: 'source-read-failed',
147
+ expected: `readable texture descriptor at "${ctx.source}"`,
148
+ hint: 'repair the texture descriptor path and retry the import',
149
+ detail: { source: ctx.source, reason },
150
+ }),
151
+ };
152
+ }
153
+
154
+ let descriptor: unknown;
155
+ try {
156
+ descriptor = JSON.parse(new TextDecoder().decode(source.value));
157
+ } catch (error) {
158
+ return {
159
+ ok: false,
160
+ error: sourceValidationError(ctx, {
161
+ code: 'texture-source-descriptor-invalid',
162
+ expected: 'JSON texture source descriptor',
163
+ hint: 'repair the descriptor JSON and retry the import',
164
+ detail: {
165
+ field: 'descriptor',
166
+ actual: error instanceof Error ? error.message : String(error),
167
+ },
168
+ }),
169
+ };
170
+ }
171
+ const subAsset = ctx.subAssets.length === 1 ? ctx.subAssets[0] : undefined;
172
+ if (subAsset === undefined || subAsset.kind !== 'texture' || subAsset.sourceIndex !== 0) {
173
+ return {
174
+ ok: false,
175
+ error: sourceValidationError(ctx, {
176
+ code: 'texture-source-descriptor-invalid',
177
+ expected: 'one texture subAsset at sourceIndex 0',
178
+ hint: 'repair Meta subAssets and retry the same texture GUID',
179
+ detail: { field: 'subAssets', actual: ctx.subAssets },
180
+ }),
181
+ };
182
+ }
183
+ const produced = await produceTextureSource({
184
+ descriptor,
185
+ guid: subAsset.guid,
186
+ sourceKey: subAsset.sourceKey ?? `${ctx.source}:texture`,
187
+ readSibling: ctx.readSibling,
188
+ });
189
+ if (!produced.ok) return { ok: false, error: sourceValidationError(ctx, produced.error) };
190
+ const parsed = parseTextureSourceDescriptor(descriptor);
191
+ const sibling = parsed.ok ? parsed.value.rawSibling : ctx.source;
192
+ return {
193
+ ok: true,
194
+ value: { assets: [produced.value], sourceDependencies: [ctx.source, sibling] },
195
+ };
196
+ }
197
+
198
+ export type { TextureSourceDescriptor };
@@ -0,0 +1,123 @@
1
+ import type { TextureMipPolicy, TextureShape } from '@forgeax/engine-types';
2
+ import {
3
+ err,
4
+ ok,
5
+ type Result,
6
+ type TextureError,
7
+ validateTextureShape,
8
+ } from '@forgeax/engine-types';
9
+
10
+ /** Versioned source-side declaration for one logical sampled texture. */
11
+ export interface TextureSourceDescriptor {
12
+ readonly schemaVersion: '1';
13
+ readonly shape: TextureShape;
14
+ readonly format: GPUTextureFormat;
15
+ readonly colorSpace: 'srgb' | 'linear';
16
+ readonly mips: TextureMipPolicy;
17
+ readonly rawSibling: string;
18
+ }
19
+
20
+ export interface TextureSourceDescriptorError {
21
+ readonly code: 'texture-source-descriptor-invalid';
22
+ readonly expected: string;
23
+ readonly hint: string;
24
+ readonly detail: { readonly field: string; readonly actual: unknown };
25
+ }
26
+
27
+ export type TextureSourceDescriptorResult = Result<
28
+ TextureSourceDescriptor,
29
+ TextureSourceDescriptorError | TextureError
30
+ >;
31
+
32
+ function record(value: unknown): value is Record<string, unknown> {
33
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ function positiveInteger(value: unknown): value is number {
37
+ return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
38
+ }
39
+
40
+ function parseShape(value: unknown): TextureShape | undefined {
41
+ if (!record(value) || !record(value.extent)) return undefined;
42
+ const extent = value.extent;
43
+ if (
44
+ value.viewDimension === '2d' &&
45
+ positiveInteger(extent.width) &&
46
+ positiveInteger(extent.height)
47
+ ) {
48
+ if (extent.layers === undefined && extent.depth === undefined) {
49
+ return { viewDimension: '2d', extent: { width: extent.width, height: extent.height } };
50
+ }
51
+ }
52
+ if (
53
+ value.viewDimension === '2d-array' &&
54
+ positiveInteger(extent.width) &&
55
+ positiveInteger(extent.height) &&
56
+ positiveInteger(extent.layers) &&
57
+ extent.depth === undefined
58
+ ) {
59
+ return {
60
+ viewDimension: '2d-array',
61
+ extent: { width: extent.width, height: extent.height, layers: extent.layers },
62
+ };
63
+ }
64
+ if (
65
+ value.viewDimension === '3d' &&
66
+ positiveInteger(extent.width) &&
67
+ positiveInteger(extent.height) &&
68
+ positiveInteger(extent.depth) &&
69
+ extent.layers === undefined
70
+ ) {
71
+ return {
72
+ viewDimension: '3d',
73
+ extent: { width: extent.width, height: extent.height, depth: extent.depth },
74
+ };
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ function parseMips(value: unknown): TextureMipPolicy | undefined {
80
+ if (!record(value)) return undefined;
81
+ if (value.kind === 'none' || value.kind === 'generate') return { kind: value.kind };
82
+ if (value.kind === 'packed' && positiveInteger(value.levelCount)) {
83
+ return { kind: 'packed', levelCount: value.levelCount };
84
+ }
85
+ return undefined;
86
+ }
87
+
88
+ function descriptorError(field: string, actual: unknown): TextureSourceDescriptorError {
89
+ return {
90
+ code: 'texture-source-descriptor-invalid',
91
+ expected: 'version 1 descriptor with shape, format, colorSpace, mips, and rawSibling',
92
+ hint: 'repair the texture descriptor and re-import the same texture GUID',
93
+ detail: { field, actual },
94
+ };
95
+ }
96
+
97
+ /** Parse and validate source facts before any raw sibling bytes are consumed. */
98
+ export function parseTextureSourceDescriptor(value: unknown): TextureSourceDescriptorResult {
99
+ if (!record(value)) return err(descriptorError('descriptor', value));
100
+ if (value.schemaVersion !== '1')
101
+ return err(descriptorError('schemaVersion', value.schemaVersion));
102
+ const shape = parseShape(value.shape);
103
+ if (shape === undefined) return err(descriptorError('shape', value.shape));
104
+ const mips = parseMips(value.mips);
105
+ if (mips === undefined) return err(descriptorError('mips', value.mips));
106
+ if (typeof value.format !== 'string') return err(descriptorError('format', value.format));
107
+ if (value.colorSpace !== 'srgb' && value.colorSpace !== 'linear') {
108
+ return err(descriptorError('colorSpace', value.colorSpace));
109
+ }
110
+ if (typeof value.rawSibling !== 'string' || value.rawSibling.trim().length === 0) {
111
+ return err(descriptorError('rawSibling', value.rawSibling));
112
+ }
113
+ const shapeResult = validateTextureShape(shape, mips, value.format as GPUTextureFormat);
114
+ if (!shapeResult.ok) return shapeResult;
115
+ return ok({
116
+ schemaVersion: '1',
117
+ shape,
118
+ format: value.format as GPUTextureFormat,
119
+ colorSpace: value.colorSpace,
120
+ mips,
121
+ rawSibling: value.rawSibling,
122
+ });
123
+ }