@forgeax/engine-image 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +12 -2
  2. package/dist/__tests__/cube-parser.unit.test.d.ts +2 -0
  3. package/dist/__tests__/cube-parser.unit.test.d.ts.map +1 -0
  4. package/dist/__tests__/cube-producer.integration.test.d.ts +2 -0
  5. package/dist/__tests__/cube-producer.integration.test.d.ts.map +1 -0
  6. package/dist/__tests__/cube-recovery.integration.test.d.ts +2 -0
  7. package/dist/__tests__/cube-recovery.integration.test.d.ts.map +1 -0
  8. package/dist/decode-image-from-file.d.ts +1 -1
  9. package/dist/decode-image-from-file.mjs.map +1 -1
  10. package/dist/errors.d.ts +13 -0
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/hdr-decoder.mjs.map +1 -1
  13. package/dist/image-importer.d.ts +2 -0
  14. package/dist/image-importer.d.ts.map +1 -1
  15. package/dist/image-importer.mjs +286 -2
  16. package/dist/image-importer.mjs.map +1 -1
  17. package/dist/index.mjs.map +1 -1
  18. package/dist/lut/cube-parser.d.ts +18 -0
  19. package/dist/lut/cube-parser.d.ts.map +1 -0
  20. package/dist/lut/cube-producer.d.ts +17 -0
  21. package/dist/lut/cube-producer.d.ts.map +1 -0
  22. package/dist/parse-image.mjs.map +1 -1
  23. package/dist/to-asset-pack.d.ts +1 -1
  24. package/package.json +6 -6
  25. package/src/__tests__/cube-parser.unit.test.ts +84 -0
  26. package/src/__tests__/cube-producer.integration.test.ts +94 -0
  27. package/src/__tests__/cube-recovery.integration.test.ts +52 -0
  28. package/src/__tests__/errors.test-d.ts +2 -2
  29. package/src/__tests__/image-importer-topology.unit.test.ts +27 -0
  30. package/src/__tests__/image.unit.test.ts +1 -1
  31. package/src/decode-image-from-file.ts +1 -1
  32. package/src/errors.ts +35 -0
  33. package/src/image-importer.ts +13 -1
  34. package/src/lut/cube-parser.ts +185 -0
  35. package/src/lut/cube-producer.ts +135 -0
  36. package/src/to-asset-pack.ts +1 -1
@@ -0,0 +1,84 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { type CubeLut, cubeLutBytes, parseCubeLut } from '../lut/cube-parser.js';
4
+
5
+ function cubeSource(
6
+ size: number,
7
+ options: { readonly extra?: string; readonly rows?: string[] } = {},
8
+ ) {
9
+ const rows =
10
+ options.rows ??
11
+ Array.from({ length: size ** 3 }, (_, index) => {
12
+ const value = index / Math.max(1, size ** 3 - 1);
13
+ return `${value.toFixed(8)} ${value.toFixed(8)} ${value.toFixed(8)}`;
14
+ });
15
+ return [
16
+ '# canonical test source',
17
+ `LUT_3D_SIZE ${size}`,
18
+ 'DOMAIN_MIN 0.0 0.0 0.0',
19
+ 'DOMAIN_MAX 1.0 1.0 1.0',
20
+ options.extra ?? '',
21
+ ...rows,
22
+ ].join('\n');
23
+ }
24
+
25
+ describe('parseCubeLut', () => {
26
+ it.each([16, 32, 64])('accepts canonical %s^3 data with R-fastest ordering', (size) => {
27
+ const parsed = parseCubeLut(cubeSource(size), `fixture/canonical-${size}.cube`);
28
+ expect(parsed.ok).toBe(true);
29
+ if (!parsed.ok) return;
30
+ expect(parsed.value).toMatchObject({
31
+ size,
32
+ domainMin: [0, 0, 0],
33
+ domainMax: [1, 1, 1],
34
+ });
35
+ expect(parsed.value.values).toHaveLength(size ** 3 * 3);
36
+ expect(parsed.value.values.slice(0, 3)).toEqual(new Float32Array([0, 0, 0]));
37
+ expect(parsed.value.values.slice(-3)).toEqual(new Float32Array([1, 1, 1]));
38
+ });
39
+
40
+ it('emits deterministic linear rgba16float bytes with alpha one', () => {
41
+ const parsed = parseCubeLut(cubeSource(16), 'fixture/deterministic.cube');
42
+ expect(parsed.ok).toBe(true);
43
+ if (!parsed.ok) return;
44
+ const first = cubeLutBytes(parsed.value);
45
+ const second = cubeLutBytes(parsed.value);
46
+ expect(first).toEqual(second);
47
+ expect(first.byteLength).toBe(16 ** 3 * 4 * 2);
48
+ expect(createHash('sha256').update(first).digest('hex')).toBe(
49
+ '38344bf4db2170fd88c41f0acda182470777eed00688bcf214f2127ce62896e9',
50
+ );
51
+ });
52
+
53
+ it.each([
54
+ ['missing size', '# no size\n0 0 0'],
55
+ ['non-cubic size', cubeSource(16, { extra: 'LUT_3D_SIZE 32' })],
56
+ ['short data', cubeSource(16, { rows: ['0 0 0'] })],
57
+ [
58
+ 'extra data',
59
+ cubeSource(16, { rows: [...Array.from({ length: 16 ** 3 }, () => '0 0 0'), '0 0 0'] }),
60
+ ],
61
+ [
62
+ 'non-finite data',
63
+ cubeSource(16, { rows: ['nan 0 0', ...Array.from({ length: 16 ** 3 - 1 }, () => '0 0 0')] }),
64
+ ],
65
+ [
66
+ 'out of domain',
67
+ cubeSource(16, { rows: ['2 0 0', ...Array.from({ length: 16 ** 3 - 1 }, () => '0 0 0')] }),
68
+ ],
69
+ ])('rejects %s with structured source diagnostics', (_name, source) => {
70
+ const parsed = parseCubeLut(source, 'fixture/invalid.cube');
71
+ expect(parsed.ok).toBe(false);
72
+ if (parsed.ok) return;
73
+ expect(parsed.error.code).toMatch(/^cube-/);
74
+ expect(parsed.error.expected).toContain('LUT_3D_SIZE');
75
+ expect(parsed.error.hint).toContain('sourceKey');
76
+ expect(parsed.error.detail).toMatchObject({ sourceKey: 'fixture/invalid.cube' });
77
+ });
78
+
79
+ it('keeps the parser type independent from TextureAsset runtime loading', () => {
80
+ const parsed = parseCubeLut(cubeSource(16), 'fixture/runtime-boundary.cube');
81
+ expect(parsed.ok).toBe(true);
82
+ expect(typeof ({} as CubeLut)).toBe('object');
83
+ });
84
+ });
@@ -0,0 +1,94 @@
1
+ import { Buffer } from 'node:buffer';
2
+ import { createHash } from 'node:crypto';
3
+ import {
4
+ finalizeImportProducts,
5
+ projectImportProductForBuild,
6
+ textureAssetOutputProducer,
7
+ } from '@forgeax/engine-import';
8
+ import { describe, expect, it } from 'vitest';
9
+ import { produceCubeTexture } from '../lut/cube-producer.js';
10
+
11
+ const GUID = '019ffa97-3000-7000-8000-000000000901';
12
+ const SOURCE_KEY = 'color-grading/neutral';
13
+
14
+ function cubeSource(size = 16): string {
15
+ const rows = Array.from({ length: size ** 3 }, () => '0 0.5 1');
16
+ return [
17
+ 'TITLE "ForgeaX neutral LUT"',
18
+ `LUT_3D_SIZE ${size}`,
19
+ 'DOMAIN_MIN 0 0 0',
20
+ 'DOMAIN_MAX 1 1 1',
21
+ ...rows,
22
+ '',
23
+ ].join('\n');
24
+ }
25
+
26
+ function digest(bytes: Uint8Array): string {
27
+ return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
28
+ }
29
+
30
+ describe('cube texture producer identity', () => {
31
+ it('keeps ordinary texture shape, bytes, and Pack artifact identity closed', async () => {
32
+ const produced = produceCubeTexture(
33
+ { guid: GUID, source: 'assets/neutral.cube', sourceKey: SOURCE_KEY },
34
+ cubeSource(),
35
+ );
36
+ expect(produced.ok).toBe(true);
37
+ if (!produced.ok) return;
38
+
39
+ const asset = produced.value;
40
+ expect(asset).toMatchObject({ guid: GUID, kind: 'texture' });
41
+ expect(asset.payload.kind).toBe('texture');
42
+ expect(asset.payload.shape).toEqual({
43
+ viewDimension: '3d',
44
+ extent: { width: 16, height: 16, depth: 16 },
45
+ });
46
+ expect(asset.payload.format).toBe('rgba16float');
47
+ expect(asset.payload.colorSpace).toBe('linear');
48
+ expect(asset.payload.mips).toEqual({ kind: 'none' });
49
+
50
+ const standard = await textureAssetOutputProducer.produce({
51
+ guid: GUID,
52
+ sourceKey: SOURCE_KEY,
53
+ asset: asset.payload,
54
+ });
55
+ expect(standard.ok).toBe(true);
56
+ if (!standard.ok) return;
57
+ const producedBody = asset.artifacts.body;
58
+ const standardBody = standard.value.artifacts.body;
59
+ expect(producedBody).toBeDefined();
60
+ expect(standardBody).toBeDefined();
61
+ if (producedBody === undefined || standardBody === undefined) return;
62
+ expect(standardBody.mediaType).toBe(producedBody.mediaType);
63
+ expect(standardBody.assetCodec).toEqual(producedBody.assetCodec);
64
+ expect(Buffer.from(standardBody.bytes).equals(Buffer.from(producedBody.bytes))).toBe(true);
65
+
66
+ const pack = projectImportProductForBuild({
67
+ assets: [{ ...asset, artifacts: standard.value.artifacts }],
68
+ });
69
+ expect(pack.assets).toHaveLength(1);
70
+ expect(pack.assets[0]).toMatchObject({ guid: GUID, kind: 'texture' });
71
+ expect(pack.assets[0]?.payload.kind).toBe('texture');
72
+ expect(pack.assets[0]?.payload.shape).toEqual({
73
+ viewDimension: '3d',
74
+ extent: { width: 16, height: 16, depth: 16 },
75
+ });
76
+ expect(pack.assets[0]?.payload.format).toBe('rgba16float');
77
+ const products = await finalizeImportProducts(
78
+ {
79
+ assets: [{ ...asset, artifacts: standard.value.artifacts }],
80
+ sourceDependencies: ['assets/neutral.cube'],
81
+ },
82
+ 'sha256:source-neutral',
83
+ );
84
+ expect(products[0]).toMatchObject({
85
+ guid: GUID,
86
+ digest: expect.stringMatching(/^sha256:/),
87
+ receipt: { guid: GUID, inputFingerprint: 'sha256:source-neutral', status: 'succeeded' },
88
+ });
89
+ expect(products[0]?.artifacts.body?.integrity).toEqual({
90
+ algorithm: 'sha256',
91
+ digest: digest(asset.artifacts.body?.bytes ?? new Uint8Array()),
92
+ });
93
+ });
94
+ });
@@ -0,0 +1,52 @@
1
+ import type { ImportContext } from '@forgeax/engine-types';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { importCubeSource } from '../lut/cube-producer.js';
4
+
5
+ const GUID = '019ffa97-3000-7000-8000-000000000902';
6
+ const SOURCE_KEY = 'color-grading/recovery';
7
+
8
+ function context(source: string, bytes: Uint8Array): ImportContext {
9
+ return {
10
+ source,
11
+ subAssets: [{ guid: GUID, sourceIndex: 0, kind: 'texture' as const, sourceKey: SOURCE_KEY }],
12
+ importSettings: {},
13
+ readSource: async () => ({ ok: true as const, value: bytes }),
14
+ readSibling: async () => {
15
+ throw new Error('not used');
16
+ },
17
+ decodeImage: async () => {
18
+ throw new Error('not used');
19
+ },
20
+ };
21
+ }
22
+
23
+ function cubeSource(): string {
24
+ const rows = Array.from({ length: 16 ** 3 }, () => '0.5 0.5 0.5');
25
+ return ['LUT_3D_SIZE 16', 'DOMAIN_MIN 0 0 0', 'DOMAIN_MAX 1 1 1', ...rows, ''].join('\n');
26
+ }
27
+
28
+ describe('cube source recovery', () => {
29
+ it('rejects invalid source before Pack and retries the same identity after repair', async () => {
30
+ const source = 'assets/recovery.cube';
31
+ const invalid = await importCubeSource(
32
+ context(source, new TextEncoder().encode('LUT_3D_SIZE 16')),
33
+ );
34
+ expect(invalid.ok).toBe(false);
35
+ if (invalid.ok) return;
36
+ expect(invalid.error.code).toBe('source-validation-failed');
37
+ expect(JSON.stringify(invalid.error.detail)).toContain(`${source}#DOMAIN_MIN/MAX`);
38
+ expect(JSON.stringify(invalid.error.detail)).toContain('cube-source-parser');
39
+
40
+ const repaired = await importCubeSource(
41
+ context(source, new TextEncoder().encode(cubeSource())),
42
+ );
43
+ expect(repaired.ok).toBe(true);
44
+ if (!repaired.ok) return;
45
+ expect(repaired.value.sourceDependencies).toEqual([source]);
46
+ expect(repaired.value.assets[0]).toMatchObject({ guid: GUID, kind: 'texture' });
47
+ expect(repaired.value.assets[0]?.payload).toMatchObject({
48
+ shape: { viewDimension: '3d', extent: { width: 16, height: 16, depth: 16 } },
49
+ format: 'rgba16float',
50
+ });
51
+ });
52
+ });
@@ -47,11 +47,11 @@ describe('ImageErrorCode + ImageErrorDetail closed union compile contract', () =
47
47
  case 'image-dimension-out-of-bounds':
48
48
  return 'downscale source under device caps maxDimension';
49
49
  case 'image-meta-missing':
50
- return 'run forgeax-engine-remote-asset import <path>';
50
+ return 'run forgeax asset import <path> --root <project>';
51
51
  case 'image-hdr-decode-failed':
52
52
  return 'verify Radiance RGBE header and FORMAT=32-bit_rle_rgbe field';
53
53
  case 'atlas-empty-input':
54
- return 'verify forgeax-engine-remote-asset atlas --input glob matches at least 1 PNG';
54
+ return 'verify forgeax asset atlas --input glob matches at least 1 PNG';
55
55
  case 'atlas-size-exceeded':
56
56
  return 'downscale source or split atlas under maxAtlasSize cap';
57
57
  case 'atlas-region-mismatch':
@@ -26,6 +26,24 @@ function readSourceFor(bytes: Uint8Array): ImportContext['readSource'] {
26
26
  }
27
27
 
28
28
  describe('image importer required output topology', () => {
29
+ it('reserves .cube sources for the ordinary texture producer topology', async () => {
30
+ const registry = imageRegistry();
31
+ const result = await runImport(
32
+ imageMeta('grading.cube', [
33
+ { guid: PNG_GUID, sourceIndex: 0, sourceKey: 'grading/lut', kind: 'texture' },
34
+ ]),
35
+ registry,
36
+ { readSource: readSourceFor(new TextEncoder().encode(cubeFixture(16))) },
37
+ );
38
+ expect(result.ok).toBe(true);
39
+ if (!result.ok || 'skipped' in result.value) return;
40
+ expect(result.value.product.assets[0]).toMatchObject({
41
+ guid: PNG_GUID,
42
+ kind: 'texture',
43
+ payload: { kind: 'texture', shape: { viewDimension: '3d' } },
44
+ });
45
+ });
46
+
29
47
  it.each([
30
48
  { source: 'texture.png', label: 'PNG' },
31
49
  { source: 'texture.jpeg', label: 'JPEG' },
@@ -207,3 +225,12 @@ describe('image importer required output topology', () => {
207
225
  );
208
226
  });
209
227
  });
228
+
229
+ function cubeFixture(size: number): string {
230
+ return [
231
+ `LUT_3D_SIZE ${size}`,
232
+ 'DOMAIN_MIN 0 0 0',
233
+ 'DOMAIN_MAX 1 1 1',
234
+ ...Array.from({ length: size ** 3 }, () => '0 0 0'),
235
+ ].join('\n');
236
+ }
@@ -89,7 +89,7 @@ import { makeCorruptPng, makeJpg, makePng } from './make-fixture.js';
89
89
  if (r.error.detail.code !== 'image-meta-missing') return;
90
90
  expect(r.error.detail.sourcePath).toBe(sourcePath);
91
91
  expect(r.error.detail.expectedSidecarPath).toBe(join(dir, 'wood.png.meta.json'));
92
- expect(r.error.hint).toContain('forgeax-engine-remote-asset');
92
+ expect(r.error.hint).toContain('forgeax asset');
93
93
  } finally {
94
94
  rmSync(dir, { recursive: true, force: true });
95
95
  }
@@ -82,7 +82,7 @@ function deriveSidecarPath(sourcePath: string): string {
82
82
  *
83
83
  * AC-17 path (a) lock: when the sidecar is absent, the returned error
84
84
  * carries `detail.sourcePath` + `detail.expectedSidecarPath` so AI users
85
- * read .hint and run `forgeax-engine-remote-asset import <path>` to
85
+ * read .hint and run `forgeax asset import <path> --root <project>` to
86
86
  * self-recover (charter P3 explicit failure + IDE jump-to-source).
87
87
  */
88
88
  export async function decodeImageFromFile(
package/src/errors.ts CHANGED
@@ -60,3 +60,38 @@ export function imageError<C extends ImageErrorCode>(
60
60
  }
61
61
 
62
62
  export { IMAGE_ERROR_EXPECTED };
63
+
64
+ export type CubeParserErrorCode =
65
+ | 'cube-header-invalid'
66
+ | 'cube-size-invalid'
67
+ | 'cube-domain-invalid'
68
+ | 'cube-row-invalid'
69
+ | 'cube-data-count-invalid';
70
+
71
+ export interface CubeParserError {
72
+ readonly code: CubeParserErrorCode;
73
+ readonly expected: string;
74
+ readonly hint: string;
75
+ readonly detail: {
76
+ readonly sourceKey: string;
77
+ readonly line?: number;
78
+ readonly field: string;
79
+ readonly actual: unknown;
80
+ };
81
+ }
82
+
83
+ export function cubeParserError(
84
+ code: CubeParserErrorCode,
85
+ sourceKey: string,
86
+ field: string,
87
+ actual: unknown,
88
+ line?: number,
89
+ ): CubeParserError {
90
+ const lineDetail = line === undefined ? '' : ` at line ${line}`;
91
+ return {
92
+ code,
93
+ expected: `valid .cube source with LUT_3D_SIZE, DOMAIN_MIN/MAX, and exactly N^3 RGB rows${lineDetail}`,
94
+ hint: `repair field ${field} in sourceKey ${sourceKey} and re-run the Node image producer`,
95
+ detail: { sourceKey, field, actual, ...(line === undefined ? {} : { line }) },
96
+ };
97
+ }
@@ -51,6 +51,7 @@ import type {
51
51
  import { IMPORT_ERROR_HINTS, ImportError } from '@forgeax/engine-types';
52
52
  import type { CompressionMode } from './ktx2-encode.js';
53
53
  import { encodeTextureToKtx2, resolveEncodeMode } from './ktx2-encode.js';
54
+ import { importCubeSource } from './lut/cube-producer.js';
54
55
  import { parseImage } from './parse-image.js';
55
56
  import { importTextureSource } from './texture/importer.js';
56
57
 
@@ -67,7 +68,12 @@ type RequiredImageOutputKind = 'texture' | 'equirect';
67
68
  function requiredImageOutputKind(source: string): RequiredImageOutputKind | undefined {
68
69
  const lower = source.toLowerCase();
69
70
  if (lower.endsWith('.hdr')) return 'equirect';
70
- if (mimeFromSource(source) !== undefined || lower.endsWith('.basis') || lower.endsWith('.ktx2')) {
71
+ if (
72
+ mimeFromSource(source) !== undefined ||
73
+ lower.endsWith('.basis') ||
74
+ lower.endsWith('.ktx2') ||
75
+ lower.endsWith('.cube')
76
+ ) {
71
77
  return 'texture';
72
78
  }
73
79
  return undefined;
@@ -590,6 +596,9 @@ async function importImage(ctx: ImportContext): Promise<ImportResult> {
590
596
  if (ctx.source.toLowerCase().endsWith('.texture.json')) {
591
597
  return importTextureSource(ctx);
592
598
  }
599
+ if (ctx.source.toLowerCase().endsWith('.cube')) {
600
+ return importCubeSource(ctx);
601
+ }
593
602
  const requiredKind = requiredImageOutputKind(ctx.source);
594
603
  if (requiredKind !== undefined) {
595
604
  const topologyError = validateImageOutputTopology(ctx, requiredKind);
@@ -870,3 +879,6 @@ export const imageImporter: Importer = {
870
879
  import: importImage,
871
880
  capabilities: { decodeImage: decodeImageForImport },
872
881
  };
882
+
883
+ export { cubeLutBytes, parseCubeLut } from './lut/cube-parser.js';
884
+ export { importCubeSource, produceCubeTexture } from './lut/cube-producer.js';
@@ -0,0 +1,185 @@
1
+ import type { CubeParserError } from '../errors.js';
2
+ import { cubeParserError } from '../errors.js';
3
+
4
+ export interface CubeLut {
5
+ readonly size: 16 | 32 | 64;
6
+ readonly domainMin: readonly [number, number, number];
7
+ readonly domainMax: readonly [number, number, number];
8
+ readonly values: Float32Array;
9
+ readonly sourceKey: string;
10
+ }
11
+
12
+ export type CubeLutResult =
13
+ | { readonly ok: true; readonly value: CubeLut }
14
+ | { readonly ok: false; readonly error: CubeParserError };
15
+
16
+ const ALLOWED_SIZES = new Set([16, 32, 64]);
17
+
18
+ function finite(value: number): boolean {
19
+ return Number.isFinite(value);
20
+ }
21
+
22
+ function parseTriple(tokens: readonly string[]): readonly [number, number, number] | undefined {
23
+ if (tokens.length !== 3) return undefined;
24
+ const values = tokens.map(Number);
25
+ if (!values.every(finite)) return undefined;
26
+ const [red, green, blue] = values;
27
+ if (red === undefined || green === undefined || blue === undefined) return undefined;
28
+ return [red, green, blue];
29
+ }
30
+
31
+ function float32ToFloat16(value: number): number {
32
+ const input = new Float32Array([value]);
33
+ const bits = new Uint32Array(input.buffer).at(0) ?? 0;
34
+ const sign = (bits >>> 16) & 0x8000;
35
+ const exponent = (bits >>> 23) & 0xff;
36
+ const fraction = bits & 0x7fffff;
37
+ if (exponent === 0xff) return sign | (fraction === 0 ? 0x7c00 : 0x7e00);
38
+ const halfExponent = exponent - 127 + 15;
39
+ if (halfExponent >= 0x1f) return sign | 0x7c00;
40
+ if (halfExponent <= 0) {
41
+ if (halfExponent < -10) return sign;
42
+ const mantissa = (fraction | 0x800000) >>> (1 - halfExponent);
43
+ return sign | ((mantissa + 0x1000) >>> 13);
44
+ }
45
+ return sign | (halfExponent << 10) | ((fraction + 0x1000) >>> 13);
46
+ }
47
+
48
+ export function parseCubeLut(source: string, sourceKey = '<inline .cube>'): CubeLutResult {
49
+ let size: number | undefined;
50
+ let domainMin: readonly [number, number, number] | undefined;
51
+ let domainMax: readonly [number, number, number] | undefined;
52
+ const values: number[] = [];
53
+ const lines = source.split(/\r?\n/);
54
+
55
+ for (const [index, rawLine] of lines.entries()) {
56
+ const line = rawLine.replace(/#.*/, '').trim();
57
+ const lineNumber = index + 1;
58
+ if (line.length === 0) continue;
59
+ const tokens = line.split(/\s+/);
60
+ const directive = tokens.shift();
61
+ if (directive === undefined) continue;
62
+ if (directive === 'TITLE') continue;
63
+ if (directive === 'LUT_3D_SIZE') {
64
+ const sizeToken = tokens[0];
65
+ if (
66
+ size !== undefined ||
67
+ tokens.length !== 1 ||
68
+ sizeToken === undefined ||
69
+ !/^\d+$/.test(sizeToken)
70
+ ) {
71
+ return {
72
+ ok: false,
73
+ error: cubeParserError('cube-size-invalid', sourceKey, 'LUT_3D_SIZE', line, lineNumber),
74
+ };
75
+ }
76
+ size = Number(sizeToken);
77
+ if (!ALLOWED_SIZES.has(size)) {
78
+ return {
79
+ ok: false,
80
+ error: cubeParserError('cube-size-invalid', sourceKey, 'LUT_3D_SIZE', size, lineNumber),
81
+ };
82
+ }
83
+ continue;
84
+ }
85
+ if (directive === 'DOMAIN_MIN' || directive === 'DOMAIN_MAX') {
86
+ const triple = parseTriple(tokens);
87
+ if (triple === undefined) {
88
+ return {
89
+ ok: false,
90
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
91
+ };
92
+ }
93
+ if (directive === 'DOMAIN_MIN') {
94
+ if (domainMin !== undefined)
95
+ return {
96
+ ok: false,
97
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
98
+ };
99
+ domainMin = triple;
100
+ } else {
101
+ if (domainMax !== undefined)
102
+ return {
103
+ ok: false,
104
+ error: cubeParserError('cube-domain-invalid', sourceKey, directive, line, lineNumber),
105
+ };
106
+ domainMax = triple;
107
+ }
108
+ continue;
109
+ }
110
+ if (directive === 'LUT_1D_SIZE') {
111
+ return {
112
+ ok: false,
113
+ error: cubeParserError('cube-header-invalid', sourceKey, directive, line, lineNumber),
114
+ };
115
+ }
116
+ const row = parseTriple([directive, ...tokens]);
117
+ if (row === undefined || row.some((value) => value < 0 || value > 1)) {
118
+ return {
119
+ ok: false,
120
+ error: cubeParserError('cube-row-invalid', sourceKey, 'RGB row', line, lineNumber),
121
+ };
122
+ }
123
+ values.push(...row);
124
+ }
125
+
126
+ if (size === undefined)
127
+ return {
128
+ ok: false,
129
+ error: cubeParserError('cube-header-invalid', sourceKey, 'LUT_3D_SIZE', 'missing'),
130
+ };
131
+ if (domainMin === undefined || domainMax === undefined) {
132
+ return {
133
+ ok: false,
134
+ error: cubeParserError('cube-domain-invalid', sourceKey, 'DOMAIN_MIN/MAX', 'missing'),
135
+ };
136
+ }
137
+ if (
138
+ domainMin.some((value, index) => {
139
+ const maxValue = domainMax[index];
140
+ return maxValue !== undefined && value >= maxValue;
141
+ })
142
+ ) {
143
+ return {
144
+ ok: false,
145
+ error: cubeParserError('cube-domain-invalid', sourceKey, 'DOMAIN_MIN/MAX', {
146
+ domainMin,
147
+ domainMax,
148
+ }),
149
+ };
150
+ }
151
+ const expectedValues = size ** 3 * 3;
152
+ if (values.length !== expectedValues) {
153
+ return {
154
+ ok: false,
155
+ error: cubeParserError('cube-data-count-invalid', sourceKey, 'RGB rows', {
156
+ expected: expectedValues / 3,
157
+ actual: values.length / 3,
158
+ }),
159
+ };
160
+ }
161
+ return {
162
+ ok: true,
163
+ value: {
164
+ size: size as 16 | 32 | 64,
165
+ domainMin,
166
+ domainMax,
167
+ values: new Float32Array(values),
168
+ sourceKey,
169
+ },
170
+ };
171
+ }
172
+
173
+ export function cubeLutBytes(lut: CubeLut): Uint8Array {
174
+ const bytes = new Uint8Array(lut.size ** 3 * 4 * 2);
175
+ const view = new DataView(bytes.buffer);
176
+ for (let index = 0; index < lut.size ** 3; index += 1) {
177
+ const valueOffset = index * 3;
178
+ const byteOffset = index * 8;
179
+ view.setUint16(byteOffset, float32ToFloat16(lut.values[valueOffset] ?? 0), true);
180
+ view.setUint16(byteOffset + 2, float32ToFloat16(lut.values[valueOffset + 1] ?? 0), true);
181
+ view.setUint16(byteOffset + 4, float32ToFloat16(lut.values[valueOffset + 2] ?? 0), true);
182
+ view.setUint16(byteOffset + 6, 0x3c00, true);
183
+ }
184
+ return bytes;
185
+ }