@zakkster/lite-color-engine 1.0.4 → 1.2.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/llms.txt CHANGED
@@ -1,201 +1,38 @@
1
- # @zakkster/lite-color-engine — LLM Reference
1
+ # lite-color-engine — LLM Reference (v1.2)
2
2
 
3
- > Zero-GC, data-oriented OKLCH color engine for WebGL/Canvas rendering pipelines.
4
- > Buffer-and-offset API. Three layers: authoring (CSS parsing), LUT (gradient
5
- > baking), runtime (zero-allocation hot path).
3
+ ## Core Exports
6
4
 
7
- ## Identity
5
+ ### Parsing
6
+ - `parseCSSColor(str, outBuf, offset)` — Universal parser. Now supports `color(display-p3 r g b / alpha)`
7
+ - `parseDisplayP3ToBuffer(str, outBuf, offset)` — Direct P3 parser
8
8
 
9
- - **Package name:** `@zakkster/lite-color-engine`
10
- - **Module type:** ESM only (`"type": "module"`)
11
- - **Peer dependency:** `@zakkster/lite-lerp` (provides `lerp`, `lerpAngle`)
12
- - **Bundle:** sub-2KB min+gzip
13
- - **Side effects:** none (`"sideEffects": false`)
14
- - **Node:** `>=18`
9
+ ### Conversion
10
+ - `sRgbToOklchBuffer(r, g, b, out, off)`
11
+ - `displayP3ToOklchBuffer(r, g, b, out, off)` — New in v1.2
12
+ - `oklchToLinearP3(L, C, H, out)` — Inverse for P3 packing
15
13
 
16
- ## Mental model
14
+ ### Runtime (Hot Path — Zero GC)
15
+ - `lerpOklchBuffer(...)`
16
+ - `packOklchBufferToUint32(buf, off, alpha?)` — Accurate sRGB
17
+ - `packOklchBufferToUint32Fast(...)` — Fast approx
18
+ - `packOklchBufferToUint32P3(buf, off, alpha?)` — Accurate P3 output
19
+ - `packOklchBufferToUint32P3Fast(...)` — Fast P3 output
17
20
 
18
- Every color in this library is exactly **3 contiguous Float32 entries** in a `Float32Array`: `[L, C, H]`.
21
+ ### Gamut mapping (subpath: `@zakkster/lite-color-engine/gamut`, NOT hot-path)
22
+ - `packOklchBufferToUint32MINDE(buf, off, alpha?)` — MINDE chroma-reduction gamut map. ~30x slower than the core packer; use at LUT-build/authoring time.
19
23
 
20
- - `L` (Lightness): `[0, 1]`, perceptually uniform.
21
- - `C` (Chroma): `[0, ~0.4]` in practice, mathematically unbounded but only a small chroma range maps to displayable sRGB.
22
- - `H` (Hue): `[0, 360)` degrees. Always canonicalized after every write.
24
+ ### LUT
25
+ - `bakeGradientToUint32(..., packer?)` Accepts any of the above packers
23
26
 
24
- Alpha is **not** stored in the OKLCH buffer. Parsers return alpha as a `number`. If you need alpha alongside color, store it in a sibling `Float32Array` or add a fourth slot yourself (the library only reads the first three).
27
+ ## Key Principles
28
+ - All hot-path functions are allocation-free
29
+ - P3 is strictly opt-in
30
+ - Use `Float32Array` buffers for everything
31
+ - Prefer `parse*ToBuffer` at init time
25
32
 
26
- The output of the pack functions is a 32-bit unsigned integer in **little-endian RGBA byte order** — exactly what `new Uint32Array(imageData.data.buffer)` consumes. Browsers are universally little-endian; do not byte-swap.
33
+ ## Accuracy Tiers (v1.2)
34
+ 1. Fast
35
+ 2. Accurate-Clamp (default)
36
+ 3. Gamut-Mapped (MINDE)
27
37
 
28
- ## Public API surface
29
-
30
- ### Authoring (init-time, allocations OK)
31
-
32
- ```js
33
- import {
34
- parseCSSColor,
35
- parseHexToBuffer,
36
- parseRgbToBuffer,
37
- parseHslToBuffer,
38
- parseOklchToBuffer,
39
- parseOklabToBuffer,
40
- } from '@zakkster/lite-color-engine';
41
- ```
42
-
43
- All five format-specific parsers and `parseCSSColor` have the same signature:
44
-
45
- ```ts
46
- (str: string, outBuf: Float32Array, offset: number) => number /* alpha [0,1] */
47
- ```
48
-
49
- `parseCSSColor` dispatches by string prefix. Use it unless you know the format and care about shaving the dispatch.
50
-
51
- Throws on invalid input.
52
-
53
- ### Convert (raw math)
54
-
55
- ```js
56
- import { sRgbToOklchBuffer } from '@zakkster/lite-color-engine';
57
-
58
- sRgbToOklchBuffer(r, g, b, outBuf, outOffset);
59
- // r, g, b are 0-255 bytes. Writes 3 floats to outBuf.
60
- ```
61
-
62
- ### Runtime (zero allocations)
63
-
64
- ```js
65
- import {
66
- lerpOklchBuffer,
67
- packOklchBufferToUint32,
68
- packOklchBufferToUint32Fast,
69
- sampleColorLUT,
70
- } from '@zakkster/lite-color-engine';
71
- ```
72
-
73
- Signatures:
74
-
75
- ```ts
76
- lerpOklchBuffer(
77
- bufA: Float32Array, offsetA: number,
78
- bufB: Float32Array, offsetB: number,
79
- t: number,
80
- outBuf: Float32Array, outOffset: number
81
- ): void;
82
-
83
- packOklchBufferToUint32(buf: Float32Array, offset: number, alpha?: number): number;
84
- packOklchBufferToUint32Fast(buf: Float32Array, offset: number, alpha?: number): number;
85
-
86
- sampleColorLUT(lut: Uint32Array, t: number): number;
87
- ```
88
-
89
- ### LUT (gradient baking)
90
-
91
- ```js
92
- import { bakeGradientToUint32 } from '@zakkster/lite-color-engine';
93
-
94
- bakeGradientToUint32(
95
- keyframesBuf: Float32Array, // [L0,C0,H0, L1,C1,H1, ...]
96
- numStops: number, // >= 2
97
- resolution?: number, // default 256, >= 2
98
- easeFn?: (t: number) => number,
99
- packer?: (buf: Float32Array, offset: number, alpha?: number) => number
100
- ): Uint32Array;
101
- ```
102
-
103
- ## Integration patterns
104
-
105
- ### Pattern 1 — palette load
106
-
107
- ```js
108
- const palette = new Float32Array(3 * COLOR_COUNT);
109
- const alphas = new Float32Array(COLOR_COUNT);
110
- for (let i = 0; i < cssStrings.length; i++) {
111
- alphas[i] = parseCSSColor(cssStrings[i], palette, i * 3);
112
- }
113
- // palette is now your color storage for the program lifetime.
114
- ```
115
-
116
- ### Pattern 2 — per-frame tween
117
-
118
- ```js
119
- const scratch = new Float32Array(3); // module-level, allocated ONCE
120
-
121
- function frame(t) {
122
- lerpOklchBuffer(palette, 0, palette, 3, t, scratch, 0);
123
- return packOklchBufferToUint32(scratch, 0, 1.0);
124
- }
125
- ```
126
-
127
- ### Pattern 3 — gradient LUT for a particle system
128
-
129
- ```js
130
- const stops = new Float32Array(9);
131
- parseCSSColor('#ff0000', stops, 0);
132
- parseCSSColor('#ffd700', stops, 3);
133
- parseCSSColor('#00aaff', stops, 6);
134
-
135
- const lut = bakeGradientToUint32(stops, 3, 256);
136
-
137
- // In the render loop:
138
- for (let i = 0; i < particles.count; i++) {
139
- const lifeT = particles.life[i];
140
- pixels32[particles.pixelIdx[i]] = sampleColorLUT(lut, lifeT);
141
- }
142
- ```
143
-
144
- ### Pattern 4 — direct ImageData write
145
-
146
- ```js
147
- const imgData = ctx.createImageData(width, height);
148
- const pixels = new Uint32Array(imgData.data.buffer); // RGBA-LE view
149
-
150
- for (let i = 0; i < pixels.length; i++) {
151
- pixels[i] = sampleColorLUT(lut, i / pixels.length);
152
- }
153
- ctx.putImageData(imgData, 0, 0);
154
- ```
155
-
156
- ## Invariants the library guarantees
157
-
158
- - After any successful write, `outBuf[offset + 2]` (hue) is in `[0, 360)`.
159
- - After any successful write, `outBuf[offset]` (lightness) is in `[0, 1]`.
160
- - After any successful write, `outBuf[offset + 1]` (chroma) is in `[0, +∞)`.
161
- - `packOklchBufferToUint32` and `packOklchBufferToUint32Fast` return values that are **always** non-negative integers (they apply `>>> 0`).
162
- - Hue interpolation is **always** shortest-path (uses `lerpAngle` from `@zakkster/lite-lerp`).
163
- - Bake output bytes are **always** little-endian RGBA. Same on every browser.
164
-
165
- ## Common pitfalls
166
-
167
- - **Mistaking the byte order.** The pack functions return little-endian RGBA, so on x86 the low byte is R. If you mask with `(packed >> 24) & 0xff` expecting red, you'll get alpha. Use `packed & 0xff` for red.
168
-
169
- - **Storing per-color objects anyway.** The library is explicitly buffer-first. If you find yourself writing `{ l, c, h }` wrappers, you've reintroduced GC pressure; just use the offset-based API.
170
-
171
- - **Choosing the Fast packer for UI.** `packOklchBufferToUint32Fast` darkens mid-tones by ~10/255 and shifts warm midtones toward black. Use it for transient pixels (particle trails, alpha-blended sprites). Use the accurate variant for anything a designer or QA might compare against the source color.
172
-
173
- - **Using non-monotonic eases with the LUT.** `bakeGradientToUint32` clamps eased `t` to `[0, 1]`. Overshoot easings (`easeOutBack`, etc.) get silently capped because a fixed-resolution LUT cannot represent overshoot. If you need overshoot, drive the eased `t` at sample time with `sampleColorLUT(lut, easeFn(t))` instead.
174
-
175
- - **Forgetting alpha is returned, not written.** Parsers return alpha. They do not write it to the buffer. If you mix parsers and hand-written buffers, decide on an alpha layout up front (sibling array, stride-4, or per-instance) and stick with it.
176
-
177
- ## What this library is NOT
178
-
179
- - 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.
181
- - 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
- - It is not async. There are no Promises. Every function is synchronous and side-effect-free (writes only to its `outBuf` argument).
183
-
184
- ## Performance budget
185
-
186
- | Operation | Wall-clock estimate (modern V8, 2024 laptop) |
187
- |---|---|
188
- | `parseCSSColor` | ~1µs per call (not the hot path; numbers vary by format) |
189
- | `lerpOklchBuffer` | ~50ns per call |
190
- | `packOklchBufferToUint32` | ~150ns per call |
191
- | `packOklchBufferToUint32Fast` | ~80ns per call |
192
- | `sampleColorLUT` | ~10ns per call |
193
- | `bakeGradientToUint32(..., 256)` | ~50µs total |
194
-
195
- Absolute numbers depend on hardware. Relative ratios are stable.
196
-
197
- ## Versioning
198
-
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.
200
-
201
- `1.1` (planned) — see `ROADMAP.md` if present, or the Pairs-well-with section of the README.
38
+ Use P3 variants when targeting `canvas.getContext('2d', { colorSpace: 'display-p3' })`.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@zakkster/lite-color-engine",
3
- "version": "1.0.4",
3
+ "version": "1.2.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
8
  "main": "./index.js",
@@ -10,10 +10,22 @@
10
10
  "types": "./index.d.ts",
11
11
  "exports": {
12
12
  ".": {
13
- "node": "./index.js",
14
13
  "types": "./index.d.ts",
14
+ "node": "./index.js",
15
15
  "import": "./index.js",
16
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"
17
29
  }
18
30
  },
19
31
  "files": [
@@ -21,13 +33,18 @@
21
33
  "index.js",
22
34
  "index.d.ts",
23
35
  "README.md",
36
+ "CHANGELOG.md",
24
37
  "llms.txt",
25
- "LICENSE.txt"
38
+ "LICENSE.md"
26
39
  ],
27
40
  "keywords": [
28
41
  "color",
29
42
  "oklch",
30
43
  "oklab",
44
+ "gamut-mapping",
45
+ "minde",
46
+ "palette",
47
+ "remap",
31
48
  "zero-gc",
32
49
  "webgl",
33
50
  "canvas",
@@ -35,6 +52,7 @@
35
52
  "interpolation",
36
53
  "gradient",
37
54
  "color-space",
55
+ "delta-e",
38
56
  "dod",
39
57
  "data-oriented",
40
58
  "high-performance",
@@ -66,4 +84,4 @@
66
84
  "engines": {
67
85
  "node": ">=18"
68
86
  }
69
- }
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;