@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/CHANGELOG.md +32 -0
- package/README.md +37 -312
- package/index.d.ts +101 -9
- package/index.js +11 -2
- package/llms.txt +29 -192
- package/package.json +23 -5
- package/src/Gamut.d.ts +37 -0
- package/src/Gamut.js +228 -0
- package/src/Remap.d.ts +87 -0
- package/src/Remap.js +306 -0
- package/src/authoring.js +47 -6
- package/src/convert.js +127 -5
- package/src/delta.js +37 -0
- package/src/runtime.js +64 -4
package/llms.txt
CHANGED
|
@@ -1,201 +1,38 @@
|
|
|
1
|
-
#
|
|
1
|
+
# lite-color-engine — LLM Reference (v1.2)
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
- `
|
|
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
|
-
|
|
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
|
-
|
|
33
|
+
## Accuracy Tiers (v1.2)
|
|
34
|
+
1. Fast
|
|
35
|
+
2. Accurate-Clamp (default)
|
|
36
|
+
3. Gamut-Mapped (MINDE)
|
|
27
37
|
|
|
28
|
-
|
|
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
|
|
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
|
|
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.
|
|
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;
|