@zakkster/lite-color-engine 1.0.2 → 1.1.0

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/CHANGELOG.md ADDED
@@ -0,0 +1,112 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-color-engine` are documented in this file.
4
+
5
+ The format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/).
6
+
7
+
8
+ ## [1.1.0] — Unreleased
9
+
10
+ ### Added — MINDE gamut mapping, ΔE-OK, and SoA remap kernels
11
+
12
+ Two new sub-exports (`/gamut`, `/remap`) plus one core function. All follow the buffer-and-offset API and the zero-allocation-on-hot-path convention from v1.0.
13
+
14
+ #### Core
15
+
16
+ - **`deltaEOK(bufA, oA, bufB, oB): number`** — ΔE-OK color difference (Euclidean distance in OKLab) between two buffered OKLCH colors. Ten-line pure function, zero allocations. Consumed internally by the `/gamut` and `/remap` sub-exports and available for downstream packages.
17
+
18
+ #### `/gamut` sub-export
19
+
20
+ ```js
21
+ import {
22
+ gamutMapToSrgbBuffer,
23
+ packOklchBufferToUint32MINDE,
24
+ } from '@zakkster/lite-color-engine/gamut';
25
+ ```
26
+
27
+ - **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — CSS Color 4 MINDE chroma reduction. Reduces chroma at fixed L and H until the color is in-gamut, using bisection with a JND (0.02) threshold. Source and destination may alias. All scratch is module-level; zero allocation on the hot path.
28
+ - **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the v1.0 packer. Eliminates the hue-shift artifacts of the hard channel clamp near the gamut boundary. Returns unsigned RGBA-LE Uint32 (matches v1.0 convention). Signature-compatible with the core packer, so it can be passed directly as the `packer` argument to `bakeGradientToUint32`.
29
+
30
+ #### `/remap` sub-export
31
+
32
+ ```js
33
+ import {
34
+ sRgba8ToOklabBuffer,
35
+ oklchToOklabBuffer,
36
+ oklabToOklchBuffer,
37
+ nearestPaletteIndexBuffer,
38
+ remapPixelsToPalette,
39
+ } from '@zakkster/lite-color-engine/remap';
40
+ ```
41
+
42
+ - **`sRgba8ToOklabBuffer(inU8, outLab, pixelCount)`** — batch RGBA8 → OKLab. Alpha discarded.
43
+ - **`oklchToOklabBuffer` / `oklabToOklchBuffer`** — batch polar/cartesian conversions between OKLCH and OKLab. Hue canonicalized to `[0, 360)`.
44
+ - **`nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts?)`** — core search kernel. For each pixel, writes the index of the nearest palette color by squared Euclidean distance in OKLab. Tie-break: lowest index wins. Accepts any typed-array index output (`Uint32Array`, `Uint16Array`, `Uint8Array`). Option `preserveLightness`: distance uses `(a, b)` only.
45
+ - **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — one-shot end-to-end: RGBA8 → nearest palette color → RGBA-LE Uint32. Alpha byte passes through unchanged. Palette OKLCH → OKLab conversion happens internally on module-level scratch (grows monotonically, never shrinks). Option `preserveLightness`: synthesizes output per pixel as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor. ~40% slower than the fast path.
46
+
47
+ ### Alignment with `V1.1-PLAN.md`
48
+
49
+ - **P0/#1** (MINDE gamut mapping) — shipped as `/gamut`.
50
+ - **P1/#5** (ΔE-OK) — shipped in core.
51
+ - **P0/#2** (alpha-aware LUT baker), **P0/#3** (ARGB byte-order variant), **P0/#4** (hue interpolation modes), **P1/#6** (gamma LUT packer), **P1/#7** (CSS Color 5 relative-color syntax) — not in this release, tracked for follow-up.
52
+
53
+ ### New in this release, not in the plan
54
+
55
+ - **`/remap` sub-export** — added as the engine underlayment for `lite-hueforge v1.3` `remapImageToPalette`. Standalone value: palette quantization for retro / palette-cycling render effects, dithering targets, and demoscene-style palette animations.
56
+
57
+ ### Design notes
58
+
59
+ - **Sub-exports are self-contained.** `Gamut.js` and `Remap.js` each carry their own local converters (`_oklchToLinearRgb`, `_linearRgbToOklch`, `_srgbGammaEncode`, `_linearizeSrgbByte`) rather than importing from core. Costs a small amount of duplicated math across the two files, buys loadability in isolation and independence from any future refactor of the internal converters.
60
+ - **Search in OKLab, not OKLCH.** Distance in OKLab is Euclidean; distance in OKLCH would need cos/sin per comparison. Palette is authored in OKLCH (human-natural), converted once at call entry.
61
+ - **Grow-only scratch.** `remapPixelsToPalette` grows its internal palette scratch when a larger palette is passed; it never shrinks. This matches the ecosystem's zero-GC contract — allocation happens at edge cases, not per frame.
62
+ - **Alpha semantics.** In `remapPixelsToPalette`, the palette is pre-packed opaque; input alpha is OR-composited into the output. This lets the palette live alongside a per-pixel alpha channel without double-clamping.
63
+
64
+ ### Performance
65
+
66
+ Measured on Node 22 in a conservative sandbox (no JIT warm-up budget); V8 in browsers is typically 2–3× faster. 5-color palette, noise input:
67
+
68
+ | Resolution | Fast path | preserveLightness |
69
+ | ---------- | --------- | ----------------- |
70
+ | 256 × 256 | 60+ fps | 40 fps |
71
+ | 512 × 512 | 16 fps | 10 fps |
72
+ | 1024 × 1024| 4 fps | 2.5 fps |
73
+
74
+ Interpretation: the fast path is real-time up to ~512² in browser conditions; `preserveLightness` is real-time up to ~256². For larger canvases (2048² live recolor), a WebGL shader path is the intended next-generation kernel — flagged as a v1.2 candidate. WASM SIMD is another option worth benching.
75
+
76
+ For the near-term demo, downscale the preview canvas to 512² and upscale for final render — a standard pattern in image-editing tools.
77
+
78
+ ### Tests
79
+
80
+ 50 new deterministic tests across three files. All 43 existing tests continue to pass; no behavioural changes to v1.0 exports.
81
+
82
+ | File | Tests | Coverage |
83
+ | ---- | ----- | -------- |
84
+ | `gamut.test.js` | 19 | MINDE identity on in-gamut / chroma reduction on out-of-gamut / L endpoint collapse / C=0 achromatic / aliasing safety / arbitrary offsets / determinism / hue sweep in-gamut invariant; packMINDE unsigned uint32 return / default-alpha behaviour / alpha clamping / endpoint byte encoding / no hue-shift on out-of-gamut / bit-for-bit parity with core packer on in-gamut input / drop-in usable with `bakeGradientToUint32` / hot-path smoothness |
85
+ | `remap.test.js` | 21 | OKLCH↔OKLab round-trip within 1e-5 / hue canonicalization; sRgba8ToOklab endpoint correctness / batch stride / plain `Uint8Array` acceptance; nearestPaletteIndex exact match / lowest-index tie-break / preserveLightness path separation / explicit `false` matches default / batch correctness / typed-array flexibility (`Uint32Array`, `Uint16Array`, `Uint8Array`); remapPixels solid-fill correctness / primary-color mapping / preserveLightness output darkness / alpha passthrough (both paths) / determinism / scratch-grow safety |
86
+ | `delta.test.js` | 10 | Identity / symmetry / non-negativity / large ΔE between visually distant colors / small ΔE between near-identical colors / offset correctness / hue wraparound (1° vs 359°) / monotonic growth with diverging chroma / determinism |
87
+
88
+ ### Non-breaking
89
+
90
+ Additive only. No existing API surface changed. v1.0 consumers continue to work identically at `1.1.0`.
91
+
92
+ ### Packaging
93
+
94
+ - Added sub-export entries for `./gamut` and `./remap` in `package.json` `exports`, with `types`/`node`/`import`/`default` conditions.
95
+ - Added `CHANGELOG.md` to the `files` list.
96
+ - Fixed a pre-existing typo: `files` listed `LICENSE.txt` where the actual file is `LICENSE.md`.
97
+
98
+ ### Downstream migrations enabled
99
+
100
+ - `@zakkster/lite-hueforge` can replace its local gamut mapper (if present) with an import from `@zakkster/lite-color-engine/gamut`.
101
+ - `@zakkster/lite-gradient-studio` same.
102
+ - `@zakkster/lite-color-lerp` can finally fulfil the README's "gamut-mapped variants … a future addition" promise via the engine.
103
+ - `@zakkster/lite-hueforge v1.3` `remapImageToPalette` sits directly on top of `remapPixelsToPalette`.
104
+
105
+
106
+ ## [1.0.4] — Prior
107
+
108
+ Baseline v1.0 surface: authoring (`parseCSSColor` and format-specific parsers), runtime (`lerpOklchBuffer`, `packOklchBufferToUint32`, `packOklchBufferToUint32Fast`, `sampleColorLUT`), LUT (`bakeGradientToUint32`), convert (`sRgbToOklchBuffer`).
109
+
110
+ ---
111
+
112
+ MIT © Zahary Shinikchiev.
package/README.md CHANGED
@@ -1,11 +1,13 @@
1
1
  # @zakkster/lite-color-engine
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-color-engine.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
4
+ [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
4
5
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-color-engine?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-color-engine)
5
6
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-color-engine?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
7
+ [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-color-engine?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-color-engine)
6
8
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational)
7
9
  ![Dependencies](https://img.shields.io/badge/dependencies-1-brightgreen)
8
- [![License](https://img.shields.io/badge/License-See_LICENSE-yellow.svg?style=for-the-badge)](./LICENSE.txt)
10
+ [![License](https://img.shields.io/badge/License-See_LICENSE-yellow.svg?style=for-the-badge)](./LICENSE.md)
9
11
 
10
12
  ## 🎨 What is lite-color-engine?
11
13
 
@@ -277,6 +279,50 @@ Bakes a multi-stop OKLCH gradient into a `Uint32Array` of packed RGBA-LE colors.
277
279
 
278
280
  **Throws** if `numStops < 2` or `resolution < 2`.
279
281
 
282
+ ### Difference
283
+
284
+ #### `deltaEOK(bufA, offsetA, bufB, offsetB): number`
285
+
286
+ ΔE-OK color difference — Euclidean distance in OKLab between two buffered OKLCH colors. Zero allocations, pure primitive math.
287
+
288
+ Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguously different. Use for palette dedupe, nearest-color lookup, and contrast checks.
289
+
290
+ ## 🧭 Sub-exports (v1.1)
291
+
292
+ The core surface stays sub-2 KB. Heavier optional kernels ship as separate entry points so you only pay for what you import.
293
+
294
+ ### `@zakkster/lite-color-engine/gamut` — MINDE gamut mapping
295
+
296
+ CSS Color 4 chroma-reduction gamut mapping. Reduces chroma at fixed L and H until the color is inside the sRGB gamut, using bisection with a JND threshold. Fixes the visible hue shifts that the hard channel clamp produces near the gamut boundary.
297
+
298
+ ```js
299
+ import {
300
+ gamutMapToSrgbBuffer,
301
+ packOklchBufferToUint32MINDE,
302
+ } from '@zakkster/lite-color-engine/gamut';
303
+ ```
304
+
305
+ - **`gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset)`** — writes a gamut-mapped `[L, C', H]` triplet. Source and destination may alias. Zero allocations on the hot path.
306
+ - **`packOklchBufferToUint32MINDE(buf, offset, alpha?)`** — accurate sibling of the core packer. `~30×` slower — belongs at LUT-build time, not per-frame. Signature-compatible with `packer` in `bakeGradientToUint32`.
307
+
308
+ ### `@zakkster/lite-color-engine/remap` — palette-remap kernels
309
+
310
+ Nearest-palette-index search in OKLab, plus a one-shot RGBA8-to-recolored-Uint32 pipeline. The engine underlayment for `lite-hueforge`'s `remapImageToPalette`, and standalone-useful for retro palette quantization and palette-cycling render effects.
311
+
312
+ ```js
313
+ import {
314
+ sRgba8ToOklabBuffer,
315
+ oklchToOklabBuffer,
316
+ oklabToOklchBuffer,
317
+ nearestPaletteIndexBuffer,
318
+ remapPixelsToPalette,
319
+ } from '@zakkster/lite-color-engine/remap';
320
+ ```
321
+
322
+ - **Batch converters** — `sRgba8ToOklabBuffer`, `oklchToOklabBuffer`, `oklabToOklchBuffer`. SoA, stride-3 in/out, hue canonicalized to `[0, 360)` where applicable.
323
+ - **`nearestPaletteIndexBuffer(...)`** — for each pixel in `pixelsLab`, write the index of the nearest palette color into `indicesOut` (accepts `Uint32Array`, `Uint16Array`, or `Uint8Array`). Tie-break: lowest index wins.
324
+ - **`remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts?)`** — end-to-end pipe. Alpha byte passes through unchanged. `opts.preserveLightness` searches by `(a, b)` only and synthesizes output as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor.
325
+
280
326
  ## ⚡ Performance characteristics
281
327
 
282
328
  | Operation | Cost |
@@ -334,4 +380,4 @@ See `llms.txt` for a structured reference designed for AI coding assistants. Pub
334
380
 
335
381
  ## License
336
382
 
337
- See [`LICENSE.txt`](./LICENSE.txt). © Zahary Shinikchiev
383
+ See [`LICENSE.md`](./LICENSE.md). © Zahary Shinikchiev
package/index.d.ts CHANGED
@@ -200,3 +200,23 @@ export function bakeGradientToUint32(
200
200
  easeFn?: (t: number) => number,
201
201
  packer?: OklchPackerFn
202
202
  ): Uint32Array;
203
+
204
+ // ============================================================================
205
+ // Difference (ΔE-OK)
206
+ // ============================================================================
207
+
208
+ /**
209
+ * ΔE-OK color difference: Euclidean distance in OKLab between two buffered
210
+ * OKLCH colors. Zero-allocation.
211
+ *
212
+ * Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguous.
213
+ *
214
+ * Use for palette dedupe, nearest-color lookup, contrast checks, and the
215
+ * CVD-audit workflow in `lite-hueforge/colorways`.
216
+ */
217
+ export function deltaEOK(
218
+ bufA: Float32Array,
219
+ offsetA: number,
220
+ bufB: Float32Array,
221
+ offsetB: number
222
+ ): number;
package/index.js CHANGED
@@ -17,3 +17,5 @@ export {
17
17
  } from './src/runtime.js';
18
18
 
19
19
  export { sRgbToOklchBuffer } from './src/convert.js';
20
+
21
+ export { deltaEOK } from './src/delta.js';
package/llms.txt CHANGED
@@ -86,6 +86,76 @@ packOklchBufferToUint32Fast(buf: Float32Array, offset: number, alpha?: number):
86
86
  sampleColorLUT(lut: Uint32Array, t: number): number;
87
87
  ```
88
88
 
89
+ ### Difference (ΔE-OK)
90
+
91
+ ```js
92
+ import { deltaEOK } from '@zakkster/lite-color-engine';
93
+
94
+ deltaEOK(bufA: Float32Array, offsetA: number,
95
+ bufB: Float32Array, offsetB: number): number;
96
+ ```
97
+
98
+ Euclidean distance in OKLab between two buffered OKLCH colors. Zero allocations. Typical scale: `0.02` indistinguishable, `0.05` subtle, `0.15+` unambiguously different.
99
+
100
+ ### Gamut sub-export (`/gamut`)
101
+
102
+ ```js
103
+ import {
104
+ gamutMapToSrgbBuffer,
105
+ packOklchBufferToUint32MINDE,
106
+ } from '@zakkster/lite-color-engine/gamut';
107
+
108
+ gamutMapToSrgbBuffer(
109
+ inBuf: Float32Array, inOffset: number,
110
+ outBuf: Float32Array, outOffset: number
111
+ ): void;
112
+
113
+ packOklchBufferToUint32MINDE(
114
+ buf: Float32Array, offset: number, alpha?: number
115
+ ): number;
116
+ ```
117
+
118
+ CSS Color 4 MINDE chroma-reduction gamut mapping. Bisection on chroma at fixed L and H with a JND threshold. `packOklchBufferToUint32MINDE` is signature-compatible with the core packer, so you can pass it directly as the `packer` argument to `bakeGradientToUint32` to bake gamut-accurate LUTs.
119
+
120
+ ~30× slower than the core packer. Belongs at LUT-build or authoring time, not per-frame.
121
+
122
+ ### Remap sub-export (`/remap`)
123
+
124
+ ```js
125
+ import {
126
+ sRgba8ToOklabBuffer,
127
+ oklchToOklabBuffer,
128
+ oklabToOklchBuffer,
129
+ nearestPaletteIndexBuffer,
130
+ remapPixelsToPalette,
131
+ } from '@zakkster/lite-color-engine/remap';
132
+
133
+ sRgba8ToOklabBuffer(
134
+ inU8: Uint8Array | Uint8ClampedArray,
135
+ outLab: Float32Array, pixelCount: number
136
+ ): void;
137
+
138
+ oklchToOklabBuffer(inLch: Float32Array, outLab: Float32Array, pixelCount: number): void;
139
+ oklabToOklchBuffer(inLab: Float32Array, outLch: Float32Array, pixelCount: number): void;
140
+
141
+ nearestPaletteIndexBuffer(
142
+ pixelsLab: Float32Array, paletteLab: Float32Array,
143
+ indicesOut: Uint32Array | Uint16Array | Uint8Array,
144
+ pixelCount: number, paletteCount: number,
145
+ opts?: { preserveLightness?: boolean }
146
+ ): void;
147
+
148
+ remapPixelsToPalette(
149
+ inU8: Uint8Array | Uint8ClampedArray,
150
+ paletteLch: Float32Array,
151
+ outU32: Uint32Array,
152
+ pixelCount: number, paletteCount: number,
153
+ opts?: { preserveLightness?: boolean }
154
+ ): void;
155
+ ```
156
+
157
+ SoA palette-remap kernels. Search happens in OKLab (Euclidean distance). Palette is authored in OKLCH and converted to OKLab once per call. `remapPixelsToPalette` uses grow-only module-level scratch for the palette conversion — no per-frame allocation. Alpha byte from the input passes through unchanged in the output. `preserveLightness` searches by `(a, b)` only and synthesizes output as `(pixel.L, palette.a, palette.b)` — retains shading structure under recolor.
158
+
89
159
  ### LUT (gradient baking)
90
160
 
91
161
  ```js
@@ -177,7 +247,7 @@ ctx.putImageData(imgData, 0, 0);
177
247
  ## What this library is NOT
178
248
 
179
249
  - It is not a general-purpose color manipulation library. No `darken`, `lighten`, `mix`, `saturate`, `complement`, etc. methods. Compose your own from the buffer math primitives.
180
- - It is not a gamut mapper. Pack uses a hard clamp; out-of-gamut OKLCH values are clipped per-channel, not mapped via MINDE chroma reduction. Proper gamut mapping is on the v1.1 roadmap.
250
+ - The core `packOklchBufferToUint32` uses a hard channel clamp for its runtime fast path. Accurate CSS Color 4 MINDE chroma-reduction gamut mapping ships in the `/gamut` sub-export (see below) use that at LUT-build or authoring time when the hue-shift artifacts of the hard clamp matter.
181
251
  - It is not a CSS parser for everything. It does CSS Color Level 4 colors. It does not handle `color()`, `color-mix()`, relative color syntax, or system colors.
182
252
  - It is not async. There are no Promises. Every function is synchronous and side-effect-free (writes only to its `outBuf` argument).
183
253
 
@@ -196,6 +266,10 @@ Absolute numbers depend on hardware. Relative ratios are stable.
196
266
 
197
267
  ## Versioning
198
268
 
199
- `1.0.x` — the locked public API surface above. No breaking changes within the major. Patch releases for bug fixes; minor releases may **add** functions but not remove or rename.
269
+ `1.0.x` — the locked core public API surface. No breaking changes within the major. Patch releases for bug fixes; minor releases may **add** functions but not remove or rename.
270
+
271
+ `1.1.0` — adds `deltaEOK` in core, plus two additive sub-exports:
272
+ - `@zakkster/lite-color-engine/gamut` — CSS Color 4 MINDE gamut mapping and an accurate packer.
273
+ - `@zakkster/lite-color-engine/remap` — SoA batch converters and palette-remap kernels.
200
274
 
201
- `1.1` (planned) see `ROADMAP.md` if present, or the Pairs-well-with section of the README.
275
+ No breaking changes; the v1.0 surface is untouched. See `CHANGELOG.md` for full details.
package/package.json CHANGED
@@ -1,18 +1,31 @@
1
1
  {
2
2
  "name": "@zakkster/lite-color-engine",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
5
- "description": "Zero-GC, data-oriented OKLCH color engine and WebGL/Canvas buffer pipeline.",
5
+ "description": "Zero-GC, data-oriented OKLCH color engine with MINDE gamut mapping and palette-remap kernels for WebGL/Canvas pipelines.",
6
6
  "type": "module",
7
7
  "sideEffects": false,
8
- "main": "index.js",
9
- "module": "index.js",
10
- "types": "index.d.ts",
8
+ "main": "./index.js",
9
+ "module": "./index.js",
10
+ "types": "./index.d.ts",
11
11
  "exports": {
12
12
  ".": {
13
- "types": "index.d.ts",
14
- "import": "index.js",
15
- "default": "index.js"
13
+ "types": "./index.d.ts",
14
+ "node": "./index.js",
15
+ "import": "./index.js",
16
+ "default": "./index.js"
17
+ },
18
+ "./gamut": {
19
+ "types": "./src/Gamut.d.ts",
20
+ "node": "./src/Gamut.js",
21
+ "import": "./src/Gamut.js",
22
+ "default": "./src/Gamut.js"
23
+ },
24
+ "./remap": {
25
+ "types": "./src/Remap.d.ts",
26
+ "node": "./src/Remap.js",
27
+ "import": "./src/Remap.js",
28
+ "default": "./src/Remap.js"
16
29
  }
17
30
  },
18
31
  "files": [
@@ -20,13 +33,18 @@
20
33
  "index.js",
21
34
  "index.d.ts",
22
35
  "README.md",
36
+ "CHANGELOG.md",
23
37
  "llms.txt",
24
- "LICENSE.txt"
38
+ "LICENSE.md"
25
39
  ],
26
40
  "keywords": [
27
41
  "color",
28
42
  "oklch",
29
43
  "oklab",
44
+ "gamut-mapping",
45
+ "minde",
46
+ "palette",
47
+ "remap",
30
48
  "zero-gc",
31
49
  "webgl",
32
50
  "canvas",
@@ -34,6 +52,7 @@
34
52
  "interpolation",
35
53
  "gradient",
36
54
  "color-space",
55
+ "delta-e",
37
56
  "dod",
38
57
  "data-oriented",
39
58
  "high-performance",
@@ -58,7 +77,11 @@
58
77
  "url": "https://github.com/PeshoVurtoleta/lite-color-engine/issues",
59
78
  "email": "shinikchiev@yahoo.com"
60
79
  },
80
+ "funding": {
81
+ "type": "github",
82
+ "url": "https://github.com/sponsors/PeshoVurtoleta"
83
+ },
61
84
  "engines": {
62
85
  "node": ">=18"
63
86
  }
64
- }
87
+ }
package/src/Gamut.d.ts ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @zakkster/lite-color-engine/gamut
3
+ *
4
+ * CSS Color 4 MINDE chroma-reduction gamut mapping, plus an accurate packer.
5
+ * Sub-export so the bisection loop does not bloat the core bundle.
6
+ */
7
+
8
+ /**
9
+ * CSS Color 4 MINDE chroma reduction. Reduces chroma at fixed L and H until
10
+ * the color is inside the sRGB gamut, using bisection with a JND threshold.
11
+ * Zero allocations on the hot path. Source and destination may alias.
12
+ *
13
+ * Writes `[L, C', H]` at `outBuf[outOffset..outOffset+2]`.
14
+ */
15
+ export function gamutMapToSrgbBuffer(
16
+ inBuf: Float32Array,
17
+ inOffset: number,
18
+ outBuf: Float32Array,
19
+ outOffset: number
20
+ ): void;
21
+
22
+ /**
23
+ * Accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut mapping
24
+ * before packing, eliminating the hue-shift artifacts of the hard channel
25
+ * clamp near the sRGB gamut boundary.
26
+ *
27
+ * ~30x slower than the core packer — use at LUT build time or authoring
28
+ * time, not per-frame. Returns an unsigned RGBA-LE Uint32.
29
+ *
30
+ * Signature-compatible with `bakeGradientToUint32`'s `packer` argument, so
31
+ * you can bake gamut-accurate LUTs by passing this as the packer.
32
+ */
33
+ export function packOklchBufferToUint32MINDE(
34
+ buf: Float32Array,
35
+ offset: number,
36
+ alpha?: number
37
+ ): number;
package/src/Gamut.js ADDED
@@ -0,0 +1,228 @@
1
+ // -----------------------------------------------------------------------------
2
+ // @zakkster/lite-color-engine/gamut
3
+ //
4
+ // CSS Color 4 MINDE gamut mapping (chroma reduction in OKLab).
5
+ //
6
+ // Reference: https://www.w3.org/TR/css-color-4/#binsearch-gamut-mapping
7
+ // V1.1-PLAN P0/#1.
8
+ //
9
+ // Self-contained module: local internal converters, no cross-file imports.
10
+ // This costs ~40 lines of duplicated math but keeps the sub-export loadable
11
+ // in isolation and independent of any future refactor of src/converters.js.
12
+ // Fits the ecosystem's "duplicate small math, share only what earns it" rule.
13
+ //
14
+ // Trust model matches v1.0: buffers assumed well-shaped; L, C, H bounds
15
+ // enforced at write time (canonical hue in [0, 360), L clamped to [0, 1]).
16
+ // -----------------------------------------------------------------------------
17
+
18
+ const _RAD = Math.PI / 180;
19
+ const _DEG = 180 / Math.PI;
20
+
21
+ // Module-level scratch. Never allocated during a call.
22
+ const _rgb = new Float32Array(3);
23
+ const _clipLch = new Float32Array(3);
24
+
25
+ // CSS Color 4 constants.
26
+ const JND = 0.02;
27
+ const EPSILON = 0.0001;
28
+ const MAX_ITER = 8;
29
+
30
+ // OKLCH → linear sRGB, writes 3 floats to `out`.
31
+ function _oklchToLinearRgb(L, C, H, out) {
32
+ const hRad = H * _RAD;
33
+ const a = C * Math.cos(hRad);
34
+ const b = C * Math.sin(hRad);
35
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
36
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
37
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
38
+ const l = l_ * l_ * l_;
39
+ const m = m_ * m_ * m_;
40
+ const s = s_ * s_ * s_;
41
+ out[0] = 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s;
42
+ out[1] = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s;
43
+ out[2] = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s;
44
+ }
45
+
46
+ // Linear sRGB → OKLCH, writes 3 floats [L, C, H] with H in [0, 360).
47
+ function _linearRgbToOklch(r, g, b, out, off) {
48
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
49
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
50
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
51
+ const l_ = Math.cbrt(l);
52
+ const m_ = Math.cbrt(m);
53
+ const s_ = Math.cbrt(s);
54
+ const L = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
55
+ const A = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
56
+ const B = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
57
+ const C = Math.sqrt(A * A + B * B);
58
+ let H = Math.atan2(B, A) * _DEG;
59
+ if (H < 0) H += 360;
60
+ out[off] = L;
61
+ out[off + 1] = C;
62
+ out[off + 2] = H;
63
+ }
64
+
65
+ // sRGB gamma encode a linear channel to gamma-encoded [0, 1].
66
+ function _srgbGammaEncode(v) {
67
+ if (v <= 0) return 0;
68
+ if (v >= 1) return 1;
69
+ return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
70
+ }
71
+
72
+ // Pre-declared bindings for tight ΔE-OK loops.
73
+ function _deltaOKFromLchLinearRgb(L, C, H, cR, cG, cB) {
74
+ // Original OKLab (a, b) from OKLCH
75
+ const hRad = H * _RAD;
76
+ const oA = C * Math.cos(hRad);
77
+ const oB = C * Math.sin(hRad);
78
+ // Clipped in-gamut linear RGB → OKLab
79
+ const l = 0.4122214708 * cR + 0.5363325363 * cG + 0.0514459929 * cB;
80
+ const m = 0.2119034982 * cR + 0.6806995451 * cG + 0.1073969566 * cB;
81
+ const s = 0.0883024619 * cR + 0.2817188376 * cG + 0.6299787005 * cB;
82
+ const l_ = Math.cbrt(l);
83
+ const m_ = Math.cbrt(m);
84
+ const s_ = Math.cbrt(s);
85
+ const cL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
86
+ const cA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
87
+ const cBB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
88
+ const dL = L - cL;
89
+ const dA = oA - cA;
90
+ const dB = oB - cBB;
91
+ return Math.sqrt(dL * dL + dA * dA + dB * dB);
92
+ }
93
+
94
+
95
+ // -----------------------------------------------------------------------------
96
+ // gamutMapToSrgbBuffer
97
+ // -----------------------------------------------------------------------------
98
+
99
+ /**
100
+ * CSS Color 4 chroma-reduction gamut mapping. Reduces chroma at fixed L and H
101
+ * until the color is inside the sRGB gamut, using bisection with a JND
102
+ * threshold. Zero allocations on the hot path.
103
+ *
104
+ * Writes gamut-mapped [L, C', H] at `outBuf[outOffset..outOffset+2]`.
105
+ * Source and destination may alias.
106
+ *
107
+ * @param {Float32Array} inBuf
108
+ * @param {number} inOffset
109
+ * @param {Float32Array} outBuf
110
+ * @param {number} outOffset
111
+ */
112
+ export function gamutMapToSrgbBuffer(inBuf, inOffset, outBuf, outOffset) {
113
+ const L = inBuf[inOffset];
114
+ const C = inBuf[inOffset + 1];
115
+ const H = inBuf[inOffset + 2];
116
+
117
+ // Endpoints: L outside [0, 1] collapses to black or white in destination.
118
+ if (L >= 1) {
119
+ outBuf[outOffset] = 1;
120
+ outBuf[outOffset + 1] = 0;
121
+ outBuf[outOffset + 2] = H;
122
+ return;
123
+ }
124
+ if (L <= 0) {
125
+ outBuf[outOffset] = 0;
126
+ outBuf[outOffset + 1] = 0;
127
+ outBuf[outOffset + 2] = H;
128
+ return;
129
+ }
130
+ if (C <= 0) {
131
+ outBuf[outOffset] = L;
132
+ outBuf[outOffset + 1] = 0;
133
+ outBuf[outOffset + 2] = H;
134
+ return;
135
+ }
136
+
137
+ // In-gamut? Emit unchanged.
138
+ _oklchToLinearRgb(L, C, H, _rgb);
139
+ if (
140
+ _rgb[0] >= -EPSILON && _rgb[0] <= 1 + EPSILON &&
141
+ _rgb[1] >= -EPSILON && _rgb[1] <= 1 + EPSILON &&
142
+ _rgb[2] >= -EPSILON && _rgb[2] <= 1 + EPSILON
143
+ ) {
144
+ outBuf[outOffset] = L;
145
+ outBuf[outOffset + 1] = C;
146
+ outBuf[outOffset + 2] = H;
147
+ return;
148
+ }
149
+
150
+ // Full-chroma clip. If ΔE < JND, we're done — return the OKLCH of the clip.
151
+ let cR = _rgb[0] < 0 ? 0 : _rgb[0] > 1 ? 1 : _rgb[0];
152
+ let cG = _rgb[1] < 0 ? 0 : _rgb[1] > 1 ? 1 : _rgb[1];
153
+ let cB = _rgb[2] < 0 ? 0 : _rgb[2] > 1 ? 1 : _rgb[2];
154
+ if (_deltaOKFromLchLinearRgb(L, C, H, cR, cG, cB) < JND) {
155
+ _linearRgbToOklch(cR, cG, cB, outBuf, outOffset);
156
+ return;
157
+ }
158
+
159
+ // Bisection on chroma at fixed L, H.
160
+ let low = 0;
161
+ let high = C;
162
+ for (let iter = 0; iter < MAX_ITER; iter++) {
163
+ if (high - low < EPSILON) break;
164
+ const mid = (low + high) * 0.5;
165
+ _oklchToLinearRgb(L, mid, H, _rgb);
166
+ const inGamut = (
167
+ _rgb[0] >= -EPSILON && _rgb[0] <= 1 + EPSILON &&
168
+ _rgb[1] >= -EPSILON && _rgb[1] <= 1 + EPSILON &&
169
+ _rgb[2] >= -EPSILON && _rgb[2] <= 1 + EPSILON
170
+ );
171
+ if (inGamut) {
172
+ low = mid;
173
+ } else {
174
+ cR = _rgb[0] < 0 ? 0 : _rgb[0] > 1 ? 1 : _rgb[0];
175
+ cG = _rgb[1] < 0 ? 0 : _rgb[1] > 1 ? 1 : _rgb[1];
176
+ cB = _rgb[2] < 0 ? 0 : _rgb[2] > 1 ? 1 : _rgb[2];
177
+ if (_deltaOKFromLchLinearRgb(L, mid, H, cR, cG, cB) < JND) {
178
+ _linearRgbToOklch(cR, cG, cB, outBuf, outOffset);
179
+ return;
180
+ }
181
+ high = mid;
182
+ }
183
+ }
184
+
185
+ // Converged: `low` is the largest in-gamut chroma at this (L, H).
186
+ outBuf[outOffset] = L;
187
+ outBuf[outOffset + 1] = low;
188
+ outBuf[outOffset + 2] = H;
189
+ }
190
+
191
+
192
+ // -----------------------------------------------------------------------------
193
+ // packOklchBufferToUint32MINDE
194
+ // -----------------------------------------------------------------------------
195
+
196
+ /**
197
+ * Drop-in accurate sibling of `packOklchBufferToUint32`. Runs MINDE gamut
198
+ * mapping before packing, eliminating the visible hue shifts that the
199
+ * hard channel-clamp produces near the sRGB gamut boundary.
200
+ *
201
+ * ~30x slower than the core packer — belongs at LUT build time or authoring
202
+ * time, not in per-frame interpolation. Zero allocations.
203
+ *
204
+ * @param {Float32Array} buf
205
+ * @param {number} offset
206
+ * @param {number} [alpha=1] alpha in [0, 1]; packed into the high byte
207
+ * @returns {number} RGBA-LE Uint32
208
+ */
209
+ export function packOklchBufferToUint32MINDE(buf, offset, alpha) {
210
+ gamutMapToSrgbBuffer(buf, offset, _clipLch, 0);
211
+ const L = _clipLch[0];
212
+ const C = _clipLch[1];
213
+ const H = _clipLch[2];
214
+ _oklchToLinearRgb(L, C, H, _rgb);
215
+ // Guard-clamp: post-MINDE the values should be in [0, 1] modulo epsilon,
216
+ // but the raw matrix output can still be a hair outside due to rounding.
217
+ let r = _srgbGammaEncode(_rgb[0]);
218
+ let g = _srgbGammaEncode(_rgb[1]);
219
+ let b = _srgbGammaEncode(_rgb[2]);
220
+ const R = (r * 255 + 0.5) | 0;
221
+ const G = (g * 255 + 0.5) | 0;
222
+ const B = (b * 255 + 0.5) | 0;
223
+ const a = alpha == null ? 1 : (alpha < 0 ? 0 : alpha > 1 ? 1 : alpha);
224
+ const A = (a * 255 + 0.5) | 0;
225
+ // RGBA-LE: byte order R, G, B, A. `>>> 0` normalizes to unsigned uint32
226
+ // so the value is comparable and matches v1.0 packer conventions.
227
+ return (((A & 0xFF) << 24) | ((B & 0xFF) << 16) | ((G & 0xFF) << 8) | (R & 0xFF)) >>> 0;
228
+ }
package/src/Remap.d.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @zakkster/lite-color-engine/remap
3
+ *
4
+ * SoA remap kernels: nearest-palette-index search in OKLab, plus a one-shot
5
+ * pixel-remap function for image recoloring. The engine layer that
6
+ * `lite-hueforge v1.3 remapImageToPalette` sits on top of.
7
+ *
8
+ * Search happens in OKLab (Euclidean distance). Palette is authored in OKLCH
9
+ * and converted to OKLab once at call entry.
10
+ */
11
+
12
+ /**
13
+ * Batch RGBA8 → OKLab. Reads 4 bytes per pixel, writes 3 floats per pixel.
14
+ * Alpha is discarded; carry it separately if needed.
15
+ */
16
+ export function sRgba8ToOklabBuffer(
17
+ inU8: Uint8Array | Uint8ClampedArray,
18
+ outLab: Float32Array,
19
+ pixelCount: number
20
+ ): void;
21
+
22
+ /** Batch OKLCH → OKLab. Both buffers stride 3. */
23
+ export function oklchToOklabBuffer(
24
+ inLch: Float32Array,
25
+ outLab: Float32Array,
26
+ pixelCount: number
27
+ ): void;
28
+
29
+ /** Batch OKLab → OKLCH. Hue canonicalized to `[0, 360)`. Both buffers stride 3. */
30
+ export function oklabToOklchBuffer(
31
+ inLab: Float32Array,
32
+ outLch: Float32Array,
33
+ pixelCount: number
34
+ ): void;
35
+
36
+ export interface NearestPaletteOptions {
37
+ /**
38
+ * If true, distance uses (a, b) only — matches by chroma coords and
39
+ * ignores lightness. Enables the shading-preserve remap trick.
40
+ * Default: false.
41
+ */
42
+ preserveLightness?: boolean;
43
+ }
44
+
45
+ /**
46
+ * For each pixel in `pixelsLab`, write the index of the nearest palette
47
+ * color in `paletteLab` (squared Euclidean distance in OKLab) into
48
+ * `indicesOut`. Tie-break: lowest index wins. Deterministic.
49
+ */
50
+ export function nearestPaletteIndexBuffer(
51
+ pixelsLab: Float32Array,
52
+ paletteLab: Float32Array,
53
+ indicesOut: Uint32Array | Uint16Array | Uint8Array,
54
+ pixelCount: number,
55
+ paletteCount: number,
56
+ opts?: NearestPaletteOptions
57
+ ): void;
58
+
59
+ export interface RemapPixelsOptions {
60
+ /**
61
+ * If true, search by (a, b) only and synthesize output per pixel using
62
+ * the pixel's original L and the palette color's (a, b). Preserves
63
+ * shading structure under recolor — the AI-motif recolor look.
64
+ * ~40% slower than the fast path. Default: false.
65
+ */
66
+ preserveLightness?: boolean;
67
+ }
68
+
69
+ /**
70
+ * One-shot end-to-end: read RGBA8 pixels, find nearest palette color per
71
+ * pixel by ΔE-OK in OKLab, write RGBA-LE Uint32 result. Handles palette
72
+ * OKLCH→OKLab conversion internally. Alpha byte passes through unchanged.
73
+ *
74
+ * Fast path (`preserveLightness=false`): pre-packs palette to Uint32 once,
75
+ * per-pixel is convert-search-gather.
76
+ *
77
+ * Preserve path (`preserveLightness=true`): synthesizes each output pixel
78
+ * from `(pixel.L, palette.a, palette.b)` — shading is retained.
79
+ */
80
+ export function remapPixelsToPalette(
81
+ inU8: Uint8Array | Uint8ClampedArray,
82
+ paletteLch: Float32Array,
83
+ outU32: Uint32Array,
84
+ pixelCount: number,
85
+ paletteCount: number,
86
+ opts?: RemapPixelsOptions
87
+ ): void;
package/src/Remap.js ADDED
@@ -0,0 +1,306 @@
1
+ // -----------------------------------------------------------------------------
2
+ // @zakkster/lite-color-engine/remap
3
+ //
4
+ // SoA remap kernels: nearest-palette-index search in OKLab, and a one-shot
5
+ // pixel-remap function for image recoloring. The engine layer that
6
+ // `lite-hueforge v1.3 remapImageToPalette` sits on top of.
7
+ //
8
+ // Design opinions:
9
+ // 1. Search happens in OKLab, not OKLCH. Distance in OKLab is Euclidean;
10
+ // distance in OKLCH needs a cos/sin per comparison. Palette is authored
11
+ // in OKLCH (what humans think in), converted to OKLab once at call
12
+ // entry, searched forever.
13
+ // 2. Palette is limited to whatever fits typical use (hundreds of colors).
14
+ // Scratch buffers grow monotonically inside the module — never shrink.
15
+ // Zero allocations on the hot per-pixel loop.
16
+ // 3. `preserveLightness` is the shading-preservation trick: search by (a, b)
17
+ // only, keep the original pixel's L, and synthesize the output color
18
+ // from (pixel.L, palette.a, palette.b). This is what makes recolored
19
+ // AI motifs still look *shaded*, not posterized.
20
+ // 4. Alpha byte from the input passes through unchanged in the output.
21
+ //
22
+ // Trust model: hot path trusts input shapes. Buffer sizes assumed correct.
23
+ // -----------------------------------------------------------------------------
24
+
25
+ const _RAD = Math.PI / 180;
26
+ const _DEG = 180 / Math.PI;
27
+
28
+ // Grow-only module-level scratch for remapPixelsToPalette.
29
+ let _paletteLab = new Float32Array(0);
30
+ let _paletteU32 = new Uint32Array(0);
31
+
32
+
33
+ // -----------------------------------------------------------------------------
34
+ // Batch converters
35
+ // -----------------------------------------------------------------------------
36
+
37
+ function _linearizeSrgbByte(b) {
38
+ const v = b / 255;
39
+ return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4);
40
+ }
41
+
42
+ function _srgbGammaEncode(v) {
43
+ if (v <= 0) return 0;
44
+ if (v >= 1) return 1;
45
+ return v <= 0.0031308 ? 12.92 * v : 1.055 * Math.pow(v, 1 / 2.4) - 0.055;
46
+ }
47
+
48
+ /**
49
+ * Batch RGBA8 → OKLab conversion. Reads 4 bytes per pixel from `inU8`
50
+ * (Uint8ClampedArray or Uint8Array), writes 3 floats per pixel to `outLab`.
51
+ * Alpha is discarded; carry it separately if needed.
52
+ */
53
+ export function sRgba8ToOklabBuffer(inU8, outLab, pixelCount) {
54
+ for (let i = 0; i < pixelCount; i++) {
55
+ const inOff = i * 4;
56
+ const outOff = i * 3;
57
+ const r = _linearizeSrgbByte(inU8[inOff]);
58
+ const g = _linearizeSrgbByte(inU8[inOff + 1]);
59
+ const b = _linearizeSrgbByte(inU8[inOff + 2]);
60
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
61
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
62
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
63
+ const l_ = Math.cbrt(l);
64
+ const m_ = Math.cbrt(m);
65
+ const s_ = Math.cbrt(s);
66
+ outLab[outOff] = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
67
+ outLab[outOff + 1] = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
68
+ outLab[outOff + 2] = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Batch OKLCH → OKLab conversion. Both buffers stride 3.
74
+ */
75
+ export function oklchToOklabBuffer(inLch, outLab, pixelCount) {
76
+ for (let i = 0; i < pixelCount; i++) {
77
+ const off = i * 3;
78
+ const L = inLch[off];
79
+ const C = inLch[off + 1];
80
+ const H = inLch[off + 2];
81
+ const hRad = H * _RAD;
82
+ outLab[off] = L;
83
+ outLab[off + 1] = C * Math.cos(hRad);
84
+ outLab[off + 2] = C * Math.sin(hRad);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Batch OKLab → OKLCH conversion. Both buffers stride 3.
90
+ * Hue canonicalized to [0, 360).
91
+ */
92
+ export function oklabToOklchBuffer(inLab, outLch, pixelCount) {
93
+ for (let i = 0; i < pixelCount; i++) {
94
+ const off = i * 3;
95
+ const L = inLab[off];
96
+ const a = inLab[off + 1];
97
+ const b = inLab[off + 2];
98
+ const C = Math.sqrt(a * a + b * b);
99
+ let H = Math.atan2(b, a) * _DEG;
100
+ if (H < 0) H += 360;
101
+ outLch[off] = L;
102
+ outLch[off + 1] = C;
103
+ outLch[off + 2] = H;
104
+ }
105
+ }
106
+
107
+
108
+ // -----------------------------------------------------------------------------
109
+ // nearestPaletteIndexBuffer
110
+ // -----------------------------------------------------------------------------
111
+
112
+ /**
113
+ * For each pixel in `pixelsLab`, write the index of the nearest palette
114
+ * color in `paletteLab` (by squared Euclidean distance in OKLab) into
115
+ * `indicesOut`.
116
+ *
117
+ * Tie-break: lowest index wins. Deterministic across runs.
118
+ *
119
+ * @param {Float32Array} pixelsLab stride 3 [L, a, b]
120
+ * @param {Float32Array} paletteLab stride 3 [L, a, b]
121
+ * @param {Uint32Array|Uint16Array|Uint8Array} indicesOut length ≥ pixelCount
122
+ * @param {number} pixelCount
123
+ * @param {number} paletteCount
124
+ * @param {object} [opts]
125
+ * @param {boolean} [opts.preserveLightness=false] if true, distance uses (a, b) only
126
+ */
127
+ export function nearestPaletteIndexBuffer(pixelsLab, paletteLab, indicesOut, pixelCount, paletteCount, opts) {
128
+ const preserveLightness = opts != null && opts.preserveLightness === true;
129
+ for (let i = 0; i < pixelCount; i++) {
130
+ const pOff = i * 3;
131
+ const pL = pixelsLab[pOff];
132
+ const pA = pixelsLab[pOff + 1];
133
+ const pB = pixelsLab[pOff + 2];
134
+ let bestIdx = 0;
135
+ let bestD = Infinity;
136
+ for (let k = 0; k < paletteCount; k++) {
137
+ const kOff = k * 3;
138
+ const dA = pA - paletteLab[kOff + 1];
139
+ const dB = pB - paletteLab[kOff + 2];
140
+ let d = dA * dA + dB * dB;
141
+ if (!preserveLightness) {
142
+ const dL = pL - paletteLab[kOff];
143
+ d += dL * dL;
144
+ }
145
+ if (d < bestD) {
146
+ bestD = d;
147
+ bestIdx = k;
148
+ }
149
+ }
150
+ indicesOut[i] = bestIdx;
151
+ }
152
+ }
153
+
154
+
155
+ // -----------------------------------------------------------------------------
156
+ // remapPixelsToPalette — one-shot end-to-end
157
+ // -----------------------------------------------------------------------------
158
+
159
+ /**
160
+ * One-shot: read RGBA8 pixels, find nearest palette color per pixel, write
161
+ * RGBA-LE Uint32 result. Handles palette OKLCH→OKLab conversion internally.
162
+ * Alpha byte is passed through unchanged from input.
163
+ *
164
+ * Two code paths:
165
+ * - preserveLightness=false: pre-packs palette to U32 once, per-pixel is
166
+ * convert-search-gather. Fast enough for live drag at moderate image
167
+ * sizes (~500x500 comfortably at 60fps on modern V8).
168
+ * - preserveLightness=true: per-pixel synthesizes (pixel.L, palette.a,
169
+ * palette.b), converts back through OKLab → linear sRGB → gamma → pack.
170
+ * ~3x slower but produces shading-preserved output — the AI motif
171
+ * recolor look.
172
+ *
173
+ * @param {Uint8Array|Uint8ClampedArray} inU8 RGBA8 pixel buffer
174
+ * @param {Float32Array} paletteLch palette OKLCH, stride 3
175
+ * @param {Uint32Array} outU32 output Uint32 pixels, length ≥ pixelCount
176
+ * @param {number} pixelCount
177
+ * @param {number} paletteCount
178
+ * @param {object} [opts]
179
+ * @param {boolean} [opts.preserveLightness=false]
180
+ */
181
+ export function remapPixelsToPalette(inU8, paletteLch, outU32, pixelCount, paletteCount, opts) {
182
+ const preserveLightness = opts != null && opts.preserveLightness === true;
183
+
184
+ // Grow scratch if needed. Monotonic — never shrinks.
185
+ const needLab = paletteCount * 3;
186
+ if (_paletteLab.length < needLab) {
187
+ _paletteLab = new Float32Array(needLab);
188
+ }
189
+ if (_paletteU32.length < paletteCount) {
190
+ _paletteU32 = new Uint32Array(paletteCount);
191
+ }
192
+
193
+ // Palette OKLCH → OKLab + pre-pack to opaque U32 (alpha comes from input pixels).
194
+ for (let k = 0; k < paletteCount; k++) {
195
+ const kOff = k * 3;
196
+ const L = paletteLch[kOff];
197
+ const C = paletteLch[kOff + 1];
198
+ const H = paletteLch[kOff + 2];
199
+ const hRad = H * _RAD;
200
+ const a = C * Math.cos(hRad);
201
+ const b = C * Math.sin(hRad);
202
+ _paletteLab[kOff] = L;
203
+ _paletteLab[kOff + 1] = a;
204
+ _paletteLab[kOff + 2] = b;
205
+
206
+ // Pack once (opaque; alpha comes from input pixels).
207
+ const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
208
+ const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
209
+ const s_ = L - 0.0894841775 * a - 1.2914855480 * b;
210
+ const l3 = l_ * l_ * l_;
211
+ const m3 = m_ * m_ * m_;
212
+ const s3 = s_ * s_ * s_;
213
+ const rL = 4.0767416621 * l3 - 3.3077115913 * m3 + 0.2309699292 * s3;
214
+ const gL = -1.2684380046 * l3 + 2.6097574011 * m3 - 0.3413193965 * s3;
215
+ const bL = -0.0041960863 * l3 - 0.7034186147 * m3 + 1.7076147010 * s3;
216
+ const gR = _srgbGammaEncode(rL);
217
+ const gG = _srgbGammaEncode(gL);
218
+ const gB = _srgbGammaEncode(bL);
219
+ const R = (gR * 255 + 0.5) | 0;
220
+ const G = (gG * 255 + 0.5) | 0;
221
+ const B = (gB * 255 + 0.5) | 0;
222
+ _paletteU32[k] = (B << 16) | (G << 8) | R;
223
+ }
224
+
225
+ if (!preserveLightness) {
226
+ // Fast path: pre-packed gather.
227
+ for (let i = 0; i < pixelCount; i++) {
228
+ const inOff = i * 4;
229
+ const r = _linearizeSrgbByte(inU8[inOff]);
230
+ const g = _linearizeSrgbByte(inU8[inOff + 1]);
231
+ const b = _linearizeSrgbByte(inU8[inOff + 2]);
232
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
233
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
234
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
235
+ const l_ = Math.cbrt(l);
236
+ const m_ = Math.cbrt(m);
237
+ const s_ = Math.cbrt(s);
238
+ const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
239
+ const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
240
+ const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
241
+
242
+ let bestIdx = 0;
243
+ let bestD = Infinity;
244
+ for (let k = 0; k < paletteCount; k++) {
245
+ const kOff = k * 3;
246
+ const dL = pL - _paletteLab[kOff];
247
+ const dA = pA - _paletteLab[kOff + 1];
248
+ const dB = pB - _paletteLab[kOff + 2];
249
+ const d = dL * dL + dA * dA + dB * dB;
250
+ if (d < bestD) { bestD = d; bestIdx = k; }
251
+ }
252
+ const A = inU8[inOff + 3];
253
+ outU32[i] = (A << 24) | _paletteU32[bestIdx];
254
+ }
255
+ return;
256
+ }
257
+
258
+ // Preserve-L path: search by (a, b), synthesize per pixel.
259
+ for (let i = 0; i < pixelCount; i++) {
260
+ const inOff = i * 4;
261
+ const r = _linearizeSrgbByte(inU8[inOff]);
262
+ const g = _linearizeSrgbByte(inU8[inOff + 1]);
263
+ const b = _linearizeSrgbByte(inU8[inOff + 2]);
264
+ const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
265
+ const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
266
+ const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
267
+ const l_ = Math.cbrt(l);
268
+ const m_ = Math.cbrt(m);
269
+ const s_ = Math.cbrt(s);
270
+ const pL = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_;
271
+ const pA = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_;
272
+ const pB = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_;
273
+
274
+ let bestIdx = 0;
275
+ let bestD = Infinity;
276
+ for (let k = 0; k < paletteCount; k++) {
277
+ const kOff = k * 3;
278
+ const dA = pA - _paletteLab[kOff + 1];
279
+ const dB = pB - _paletteLab[kOff + 2];
280
+ const d = dA * dA + dB * dB;
281
+ if (d < bestD) { bestD = d; bestIdx = k; }
282
+ }
283
+
284
+ // Synthesize (pixel L, palette a, palette b) → linear sRGB → gamma → pack.
285
+ const kOff = bestIdx * 3;
286
+ const nA = _paletteLab[kOff + 1];
287
+ const nB = _paletteLab[kOff + 2];
288
+ const nl_ = pL + 0.3963377774 * nA + 0.2158037573 * nB;
289
+ const nm_ = pL - 0.1055613458 * nA - 0.0638541728 * nB;
290
+ const ns_ = pL - 0.0894841775 * nA - 1.2914855480 * nB;
291
+ const nl = nl_ * nl_ * nl_;
292
+ const nm = nm_ * nm_ * nm_;
293
+ const ns = ns_ * ns_ * ns_;
294
+ const nr = 4.0767416621 * nl - 3.3077115913 * nm + 0.2309699292 * ns;
295
+ const ng = -1.2684380046 * nl + 2.6097574011 * nm - 0.3413193965 * ns;
296
+ const nb = -0.0041960863 * nl - 0.7034186147 * nm + 1.7076147010 * ns;
297
+ const gR = _srgbGammaEncode(nr);
298
+ const gG = _srgbGammaEncode(ng);
299
+ const gB = _srgbGammaEncode(nb);
300
+ const R = (gR * 255 + 0.5) | 0;
301
+ const G = (gG * 255 + 0.5) | 0;
302
+ const B = (gB * 255 + 0.5) | 0;
303
+ const A = inU8[inOff + 3];
304
+ outU32[i] = (A << 24) | (B << 16) | (G << 8) | R;
305
+ }
306
+ }
package/src/delta.js ADDED
@@ -0,0 +1,37 @@
1
+ const _RAD = Math.PI / 180;
2
+
3
+ /**
4
+ * ΔE-OK color difference: Euclidean distance in OKLab between two buffered
5
+ * OKLCH colors. Zero-alloc, primitive math only.
6
+ *
7
+ * Typical scale: 0.00–0.02 indistinguishable, 0.02–0.05 subtle,
8
+ * 0.05–0.15 clearly different, 0.15+ unambiguously different.
9
+ *
10
+ * Use for: palette dedupe, contrast checks, "is this close enough"
11
+ * assertions, nearest-color lookup, and the CVD-audit workflow in
12
+ * lite-hueforge/colorways.
13
+ *
14
+ * @param {Float32Array} bufA
15
+ * @param {number} offsetA
16
+ * @param {Float32Array} bufB
17
+ * @param {number} offsetB
18
+ * @returns {number}
19
+ */
20
+ export const deltaEOK = (bufA, offsetA, bufB, offsetB) => {
21
+ const La = bufA[offsetA];
22
+ const Ca = bufA[offsetA + 1];
23
+ const Ha = bufA[offsetA + 2];
24
+ const Lb = bufB[offsetB];
25
+ const Cb = bufB[offsetB + 1];
26
+ const Hb = bufB[offsetB + 2];
27
+ const hARad = Ha * _RAD;
28
+ const hBRad = Hb * _RAD;
29
+ const aA = Ca * Math.cos(hARad);
30
+ const bA = Ca * Math.sin(hARad);
31
+ const aB = Cb * Math.cos(hBRad);
32
+ const bB = Cb * Math.sin(hBRad);
33
+ const dL = La - Lb;
34
+ const dA = aA - aB;
35
+ const dB = bA - bB;
36
+ return Math.sqrt(dL * dL + dA * dA + dB * dB);
37
+ };