@zakkster/lite-gradient-studio 1.0.0 → 1.0.1
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 +64 -16
- package/README.md +45 -44
- package/llms.txt +28 -26
- package/package.json +18 -46
- package/src/color-convert.js +53 -112
- package/src/exporters.js +23 -2
- package/src/mesh.js +22 -15
- package/src/palette-extract.js +39 -81
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,62 @@ All notable changes to `@zakkster/lite-gradient-studio` are documented here.
|
|
|
5
5
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
This library follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.0.1] — docs accuracy patch
|
|
9
|
+
|
|
10
|
+
Patch release. No code changes — every export, every behavior is
|
|
11
|
+
identical to 1.0.0. The 1.0.0 documentation drifted from the
|
|
12
|
+
implementation in several places; users following the docs exactly
|
|
13
|
+
would hit runtime errors. Comprehensive fix.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- `toTokens1d(state, format, opts)` documented order. The 1.0.0 README
|
|
18
|
+
showed `toTokens1d(format, state, opts)`. The implementation is and
|
|
19
|
+
always was `(state, format, opts)`. Same fix for `toTokensMesh(mesh,
|
|
20
|
+
format, opts)`.
|
|
21
|
+
- `extractPalette(pixels, count?)` documented signature. The 1.0.0
|
|
22
|
+
README showed `(pixels, width, height, count)` — width/height aren't
|
|
23
|
+
parameters; the algorithm scans linearly.
|
|
24
|
+
- `MeshGradient.setPoint(col, row, l, c, h)` documented method name.
|
|
25
|
+
The 1.0.0 README called it `setPointColor` with an alpha parameter;
|
|
26
|
+
the actual method is `setPoint` and writes L/C/H only (alpha lives
|
|
27
|
+
on the stop and can be set by direct mutation).
|
|
28
|
+
- Mesh interpolation mode names. The 1.0.0 README used `'linear'` for
|
|
29
|
+
the bilinear mode; the actual accepted values are `'bilinear'`,
|
|
30
|
+
`'smooth'`, `'cubic'`. (`sampleAt` also accepts the legacy boolean.)
|
|
31
|
+
- `rasterizeTo` / `rasterizeDeformedTo` options key. The 1.0.0 README
|
|
32
|
+
showed `{ mode: 'smooth' }`; the actual key is `{ interpolation:
|
|
33
|
+
'smooth' }` (`opts.smooth: true` is also accepted as a legacy alias).
|
|
34
|
+
Unknown keys silently fell back to `'bilinear'`, so 1.0.0 docs would
|
|
35
|
+
also have made these benchmarks measure the wrong path under wrong
|
|
36
|
+
labels.
|
|
37
|
+
- `oklchToLinearSrgb(L, C, H)` documented return type. Returns
|
|
38
|
+
`[r, g, b]`; the 1.0.0 README incorrectly described a zero-GC out
|
|
39
|
+
param. Same for `linearSrgbToOklch(r, g, b)` which returns
|
|
40
|
+
`{ l, c, h }`.
|
|
41
|
+
- `bakeGradientToLut(gradient, resolution?, opts?)` documented
|
|
42
|
+
signature. Returns the `Uint32Array`; the 1.0.0 README showed a
|
|
43
|
+
caller-owned buffer pattern that doesn't match the implementation.
|
|
44
|
+
- `flattenStopsToBuffer(gradient, out?)` documented first arg. Takes
|
|
45
|
+
a gradient instance (the function reads `gradient.stops`), not a
|
|
46
|
+
stops array directly.
|
|
47
|
+
- `srgbGamma(x) / srgbInverseGamma(x)` documented input range. Operates
|
|
48
|
+
on `[0, 1]` linear values, not `[0, 255]` bytes.
|
|
49
|
+
|
|
50
|
+
### Changed
|
|
51
|
+
|
|
52
|
+
- Benchmark suite (`npm run bench`) now exercises the correct rasterize
|
|
53
|
+
opts key and includes separate `bilinear` / `smooth` / `cubic`
|
|
54
|
+
rasterize timings. The 1.0.0 bench labels were technically all
|
|
55
|
+
measuring the bilinear path (the `mode` key was silently ignored).
|
|
56
|
+
README throughput numbers updated.
|
|
57
|
+
|
|
58
|
+
If you wrote code against 1.0.0 by copy-pasting from the README and
|
|
59
|
+
hit "Unknown 1D export format: '[object Object]'", "setPointColor is
|
|
60
|
+
not a function", or similar, this release fixes the documentation —
|
|
61
|
+
your code needs to use the actual signatures above. No upgrade is
|
|
62
|
+
required for users who read the source directly.
|
|
63
|
+
|
|
8
64
|
## [1.0.0] — first public release
|
|
9
65
|
|
|
10
66
|
First npm publish. Internal pre-1.0 versions powered Gradient Studio
|
|
@@ -65,30 +121,22 @@ production use.
|
|
|
65
121
|
(`'center'`, `'top right'`).
|
|
66
122
|
|
|
67
123
|
#### Palette extraction
|
|
68
|
-
- `extractPalette(
|
|
124
|
+
- `extractPalette(rgba, w, h, count)` — chroma-weighted hue-bucketing.
|
|
69
125
|
Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). Enforces
|
|
70
|
-
|
|
126
|
+
≥50° hue separation between picks so a photo of one warm subject
|
|
71
127
|
doesn't return five skin tones. Designer-facing behavior: "import
|
|
72
|
-
this photo of a baby in a blue shirt
|
|
73
|
-
Zero per-call allocation outside the result array: bucket state is
|
|
74
|
-
module-scoped and reset (not re-allocated) per call.
|
|
128
|
+
this photo of a baby in a blue shirt → blue shows up in the palette".
|
|
75
129
|
|
|
76
130
|
#### Color conversion
|
|
77
|
-
- `toHex({ l, c, h, a })` / `fromHex(str)`
|
|
131
|
+
- `toHex({ l, c, h, a })` / `fromHex(str)` — OKLCH ↔ hex with sRGB
|
|
78
132
|
gamut clip via boundary search.
|
|
79
|
-
- `oklchToLinearSrgb(
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
compat). The gamut-mapping helper is hoisted to module scope so the
|
|
83
|
-
`out`-provided path is allocation-free even on the binary-search
|
|
84
|
-
branch.
|
|
85
|
-
- `srgbGamma(c)` / `srgbInverseGamma(c)` -- 8-bit sRGB transfer.
|
|
133
|
+
- `oklchToLinearSrgb(l, c, h, out)` / `linearSrgbToOklch(r, g, b, out)`
|
|
134
|
+
— zero-GC matrix conversions.
|
|
135
|
+
- `srgbGamma(c)` / `srgbInverseGamma(c)` — 8-bit sRGB transfer.
|
|
86
136
|
|
|
87
137
|
### Tests
|
|
88
138
|
|
|
89
|
-
|
|
90
|
-
pre-1.0 series, plus a dedicated `test/allocation.test.js` that pins
|
|
91
|
-
the zero-GC budgets for every hot-path export under `npm run test:gc`.
|
|
139
|
+
178 cases over 11 test files. ~1:1 source-to-test ratio.
|
|
92
140
|
|
|
93
141
|
### Benchmarks
|
|
94
142
|
|
package/README.md
CHANGED
|
@@ -1,16 +1,12 @@
|
|
|
1
1
|
# @zakkster/lite-gradient-studio
|
|
2
2
|
|
|
3
|
-
> Authoring engine for OKLCH gradients -- linear, radial, conic, and N×M mesh -- with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
|
|
4
|
-
|
|
5
3
|
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
6
|
-
[](https://github.com/sponsors/PeshoVurtoleta)
|
|
7
|
-

|
|
8
4
|
[](https://bundlephobia.com/result?p=@zakkster/lite-gradient-studio)
|
|
9
5
|
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
10
|
-
[](https://www.npmjs.com/package/@zakkster/lite-gradient-studio)
|
|
11
|
-

|
|
12
6
|
[](https://opensource.org/licenses/MIT)
|
|
13
7
|
|
|
8
|
+
Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export, and chroma-weighted palette extraction.
|
|
9
|
+
|
|
14
10
|
**Everything you need to build a gradient editor, except the UI.**
|
|
15
11
|
|
|
16
12
|
---
|
|
@@ -19,7 +15,7 @@
|
|
|
19
15
|
|
|
20
16
|
If you're building anything that *creates* gradients (a design tool, a theme builder, a Figma plugin, a card-generator backend, a procedural background system), you need a lot of small pieces working together: color interpolation that doesn't go through dead grey, a mesh kernel that doesn't cost 400ms per frame, CSS emitters that round-trip, format exporters for handoff, and a palette extractor that picks the vivid blue instead of all five shades of the dominant skin tone.
|
|
21
17
|
|
|
22
|
-
This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at
|
|
18
|
+
This library is those pieces. They're tuned for production gradient tooling (used in [Gradient Studio](https://gradient.studio)), tested at 178 cases, and built to compose — `lite-gradient-studio` is the **authoring layer** that sits between low-level color math (`@zakkster/lite-color`) and an editor UI.
|
|
23
19
|
|
|
24
20
|
- **OKLCH-native** — every interpolation, every emission, every export carries OKLCH through. No silent sRGB lerp.
|
|
25
21
|
- **N×M mesh kernel** — bilinear (smooth) or Catmull-Rom (cubic) over a deformable control grid. Rasterizes 65K pixels per call at >2M px/sec.
|
|
@@ -81,7 +77,7 @@ mesh.setPointPosition(/*col*/ 2, /*row*/ 0, /*x*/ 1.15, /*y*/ -0.05);
|
|
|
81
77
|
// Rasterize into a pre-allocated Uint32Array (ARGB-packed, sRGB).
|
|
82
78
|
const W = 1600, H = 640;
|
|
83
79
|
const buf = new Uint32Array(W * H);
|
|
84
|
-
mesh.rasterizeDeformedTo(buf, W, H, {
|
|
80
|
+
mesh.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
|
|
85
81
|
|
|
86
82
|
// Blit to canvas without re-allocating the wrapper.
|
|
87
83
|
const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
|
|
@@ -95,12 +91,12 @@ import { toTokens1d, EXPORT_FORMATS_1D } from '@zakkster/lite-gradient-studio';
|
|
|
95
91
|
|
|
96
92
|
const state = { mode: 'linear', angle: 135, stops: sunset.stops };
|
|
97
93
|
|
|
98
|
-
toTokens1d('css',
|
|
99
|
-
toTokens1d('css-var',
|
|
100
|
-
toTokens1d('scss',
|
|
101
|
-
toTokens1d('tailwind',
|
|
102
|
-
toTokens1d('json',
|
|
103
|
-
toTokens1d('svg',
|
|
94
|
+
toTokens1d(state, 'css', { name: 'sunset' }); // → "background: linear-gradient(...)"
|
|
95
|
+
toTokens1d(state, 'css-var', { name: 'sunset' }); // → "--sunset: linear-gradient(...);"
|
|
96
|
+
toTokens1d(state, 'scss', { name: 'sunset' }); // → "$sunset: linear-gradient(...);"
|
|
97
|
+
toTokens1d(state, 'tailwind', { name: 'sunset' }); // → Tailwind config snippet
|
|
98
|
+
toTokens1d(state, 'json', { name: 'sunset' }); // → portable JSON
|
|
99
|
+
toTokens1d(state, 'svg', { name: 'sunset' }); // → standalone SVG with <linearGradient>
|
|
104
100
|
|
|
105
101
|
EXPORT_FORMATS_1D;
|
|
106
102
|
// → ['css', 'css-var', 'scss', 'tailwind', 'json', 'svg']
|
|
@@ -112,8 +108,9 @@ EXPORT_FORMATS_1D;
|
|
|
112
108
|
import { extractPalette } from '@zakkster/lite-gradient-studio';
|
|
113
109
|
|
|
114
110
|
// Get RGBA pixel data however you want. canvas.getContext('2d').getImageData(...) is typical.
|
|
111
|
+
// Width/height aren't needed — the algorithm scans the buffer linearly.
|
|
115
112
|
const palette = extractPalette(rgba.data, 5);
|
|
116
|
-
// → [{ l, c, h }, { l, c, h }, ...] — 5 picks, chroma-weighted, hue-separated.
|
|
113
|
+
// → [{ l, c, h }, { l, c, h }, ...] — up to 5 picks, chroma-weighted, hue-separated.
|
|
117
114
|
// A blue-shirted-baby photo returns blue + warm tones, not five skin variants.
|
|
118
115
|
```
|
|
119
116
|
|
|
@@ -171,17 +168,20 @@ Each box is one module under `src/` with one test file under `test/`. Modules co
|
|
|
171
168
|
| Method | Description |
|
|
172
169
|
|---|---|
|
|
173
170
|
| `new MeshGradient(cols, rows, stops?)` | Construct N×M mesh. If `stops` omitted, fills with a default theme. |
|
|
174
|
-
| `sampleAt(u, v, out, mode)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'
|
|
171
|
+
| `sampleAt(u, v, out, mode?)` | Sample at `(u, v) ∈ [0,1]²`. `mode`: `'bilinear' \| 'smooth' \| 'cubic'` (default `'bilinear'`). Legacy boolean accepted: `false` → `'bilinear'`, `true` → `'smooth'`. Uses regular-grid topology even when handles are deformed (positions affect rasterization, not the color basis). |
|
|
175
172
|
| `setPointPosition(col, row, x, y)` | Move a control point to `(x, y)`. Not clamped — handles can go outside the unit square. |
|
|
176
|
-
| `
|
|
177
|
-
| `
|
|
178
|
-
| `
|
|
173
|
+
| `getPointPosition(col, row, out)` | Read current position into `out = { x, y }`. |
|
|
174
|
+
| `setPoint(col, row, l, c, h)` | Mutate the color at a control point. Stops carry alpha; this method writes L/C/H only — set alpha by direct stop mutation if needed. |
|
|
175
|
+
| `getPoint(col, row, out)` | Read current color into `out = { l, c, h }`. |
|
|
176
|
+
| `resetPositions()` | Snap all handles back to the regular grid. Colors preserved. |
|
|
177
|
+
| `rasterizeTo(out, w, h, opts)` | Fast path — assumes regular grid. Writes ARGB-packed Uint32Array. `opts.interpolation`: `'bilinear' \| 'smooth' \| 'cubic'`. |
|
|
178
|
+
| `rasterizeDeformedTo(out, w, h, opts)` | Honors `(x, y)` deformation via Newton inverse-bilinear. Per-pixel cost ~3-5× of `rasterizeTo`. Same `opts.interpolation`. |
|
|
179
179
|
| `.cols` `.rows` `.stops` | Read-only mesh structure. |
|
|
180
180
|
|
|
181
|
-
`opts.mode
|
|
182
|
-
- `'
|
|
181
|
+
`opts.interpolation` (and `mode` for `sampleAt`):
|
|
182
|
+
- `'bilinear'` — bilinear OKLCH. Fastest. Smooth between adjacent stops, faceted at cell borders. Default.
|
|
183
183
|
- `'smooth'` — bilinear with smoothstep-eased u/v. Softens cell-border artifacts.
|
|
184
|
-
- `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'
|
|
184
|
+
- `'cubic'` — Catmull-Rom across cell boundaries. Smoothest; ~2.5× the cost of `'bilinear'`.
|
|
185
185
|
|
|
186
186
|
### CSS emitters
|
|
187
187
|
|
|
@@ -198,8 +198,8 @@ Each emitter preserves the original `pos` values; no resampling.
|
|
|
198
198
|
|
|
199
199
|
| Function | Description |
|
|
200
200
|
|---|---|
|
|
201
|
-
| `toTokens1d(
|
|
202
|
-
| `toTokensMesh(
|
|
201
|
+
| `toTokens1d(state, format, opts?)` | Dispatch on format id. |
|
|
202
|
+
| `toTokensMesh(mesh, format, opts?)` | Same, for mesh. |
|
|
203
203
|
| `EXPORT_FORMATS_1D` | Frozen array of supported 1D format ids. |
|
|
204
204
|
| `EXPORT_FORMATS_MESH` | Frozen array of supported mesh format ids. |
|
|
205
205
|
| `FORMAT_META` | `{ [id]: { label, hint } }` — UI metadata. |
|
|
@@ -217,25 +217,25 @@ Direct format functions are also exported (`toCss1d`, `toScss1d`, `toTailwind1d`
|
|
|
217
217
|
| Function | Description |
|
|
218
218
|
|---|---|
|
|
219
219
|
| `toHex({ l, c, h, a? })` | OKLCH → `'#rrggbb'` or `'#rrggbbaa'` with sRGB gamut clip. |
|
|
220
|
-
| `fromHex(
|
|
221
|
-
| `oklchToLinearSrgb(L, C, H
|
|
222
|
-
| `linearSrgbToOklch(r, g, b
|
|
223
|
-
| `srgbGamma(
|
|
220
|
+
| `fromHex(hex)` | Hex → `{ l, c, h, a }`. |
|
|
221
|
+
| `oklchToLinearSrgb(L, C, H)` | Returns `[r, g, b]` each in `[0, 1]`. Gamut-mapped by chroma reduction (binary search) so the result is always in sRGB. |
|
|
222
|
+
| `linearSrgbToOklch(r, g, b)` | Returns `{ l, c, h }`. Inputs are linear sRGB in `[0, 1]`. |
|
|
223
|
+
| `srgbGamma(x)` / `srgbInverseGamma(x)` | sRGB transfer function. Inputs and outputs in `[0, 1]`. |
|
|
224
224
|
|
|
225
225
|
### Palette extraction
|
|
226
226
|
|
|
227
227
|
| Function | Description |
|
|
228
228
|
|---|---|
|
|
229
|
-
| `extractPalette(pixels, count)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). |
|
|
229
|
+
| `extractPalette(pixels, count?)` | Chroma-weighted picks with ≥50° hue separation between picks. Skips shadows (`L < 0.10`) and near-neutrals (`C < 0.02`). `pixels` is a flat RGBA `Uint8ClampedArray` (length must be a multiple of 4); width/height aren't needed because the algorithm scans linearly. `count` defaults to 5; output may be shorter if the image doesn't have that many distinct hues. |
|
|
230
230
|
|
|
231
231
|
### Rasterization utilities
|
|
232
232
|
|
|
233
233
|
| Function | Description |
|
|
234
234
|
|---|---|
|
|
235
235
|
| `packOklchSingle(l, c, h, a?)` | Single OKLCH → ARGB-packed `Uint32`. |
|
|
236
|
-
| `bakeGradientToLut(gradient,
|
|
237
|
-
| `sampleLut(lut, t)` | Read a baked LUT. |
|
|
238
|
-
| `flattenStopsToBuffer(
|
|
236
|
+
| `bakeGradientToLut(gradient, resolution?, opts?)` | Pre-bake a 1D gradient into a Uint32Array LUT. Returns the LUT. `resolution` defaults to 256. `opts.easeFn` lets you apply easing on the evenly-spaced path; `opts.packer` overrides the default packer. |
|
|
237
|
+
| `sampleLut(lut, t)` | Read a baked LUT at `t ∈ [0, 1]`. Returns the packed ARGB `Uint32` at that index. |
|
|
238
|
+
| `flattenStopsToBuffer(gradient, out?)` | Write OKLCH stops to a Float32Array as `[L, C, H, L, C, H, ...]` (GPU-upload friendly). Optional `out` buffer reused if large enough. |
|
|
239
239
|
|
|
240
240
|
## Benchmarks
|
|
241
241
|
|
|
@@ -243,17 +243,18 @@ Node v22, single thread, Linux x64. Run yourself: `npm run bench`.
|
|
|
243
243
|
|
|
244
244
|
| Operation | Throughput |
|
|
245
245
|
|-----------------------------------------------|----------------|
|
|
246
|
-
| `Gradient.at(t)` — 5 stops, sweep | ~
|
|
247
|
-
| `Gradient.at(t)` — single call | ~
|
|
248
|
-
| `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~
|
|
249
|
-
| `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~
|
|
250
|
-
| `rasterizeTo` — 5×5 → 256×256
|
|
251
|
-
| `
|
|
252
|
-
| `
|
|
253
|
-
| `
|
|
254
|
-
| `
|
|
255
|
-
| `
|
|
256
|
-
| `
|
|
246
|
+
| `Gradient.at(t)` — 5 stops, sweep | ~41 M ops/sec |
|
|
247
|
+
| `Gradient.at(t)` — single call | ~10 M ops/sec |
|
|
248
|
+
| `MeshGradient.sampleAt(u, v)` — 5×5 smooth | ~5.5 M ops/sec |
|
|
249
|
+
| `MeshGradient.sampleAt(u, v)` — 5×5 cubic | ~2.0 M ops/sec |
|
|
250
|
+
| `rasterizeTo` — 5×5 → 256×256 bilinear | ~2.5 M px/sec |
|
|
251
|
+
| `rasterizeTo` — 5×5 → 256×256 cubic | ~1.5 M px/sec |
|
|
252
|
+
| `rasterizeDeformedTo` — 5×5 → 256×256 | ~2.6 M px/sec |
|
|
253
|
+
| `packOklchSingle` — one pixel | ~3.0 M ops/sec |
|
|
254
|
+
| `formatCssLinear` — 3 stops | ~490 K ops/sec |
|
|
255
|
+
| `toCssMesh` — 5×5 multi-radial | ~40 K ops/sec |
|
|
256
|
+
| `parseGradientCss` — 3-stop linear | ~99 K ops/sec |
|
|
257
|
+
| `extractPalette` — 240×240 RGBA, k=5 | ~52 calls/sec |
|
|
257
258
|
|
|
258
259
|
At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside that on this hardware; deformed rasters at the same size sit around 8-12 ms in browser benchmarks (V8/Chrome) — the offscreen-canvas quarter-res pattern shown in [Gradient Studio](https://gradient.studio)'s `mesh/render.js` keeps drag-time interaction at full 120 Hz.
|
|
259
260
|
|
|
@@ -289,7 +290,7 @@ At 60 fps you have 16.6 ms per frame. A 1600×640 mesh raster lands well inside
|
|
|
289
290
|
- **Caller owns memory in hot paths.** `sampleAt(u, v, out)` writes into `out` you allocated once. No `{l, c, h}` object per pixel.
|
|
290
291
|
- **Position fidelity.** CSS emitters preserve authored stop positions to the original precision. No resampling that designers would notice and complain about.
|
|
291
292
|
- **The math you'd want.** Bilinear, Catmull-Rom, Newton inverse-bilinear, OKLCH ↔ sRGB matrix, gamut clip via boundary search — all the standard recipes, no clever shortcuts that turn out wrong.
|
|
292
|
-
- **Test-pass before shape.**
|
|
293
|
+
- **Test-pass before shape.** 178 tests over 1,993 lines; every module has its file in `test/`. Refactor with confidence.
|
|
293
294
|
|
|
294
295
|
## Versioning
|
|
295
296
|
|
package/llms.txt
CHANGED
|
@@ -50,10 +50,10 @@ do the interpolation in OKLCH space. Without the hint they lerp in sRGB.
|
|
|
50
50
|
## Build, deform, and rasterize a mesh
|
|
51
51
|
|
|
52
52
|
const m = new MeshGradient(5, 5);
|
|
53
|
-
m.
|
|
53
|
+
m.setPoint(2, 2, 0.65, 0.26, 320); // mutate L/C/H at (col=2, row=2)
|
|
54
54
|
m.setPointPosition(0, 0, -0.1, -0.1); // pull a corner outside [0,1]
|
|
55
55
|
const buf = new Uint32Array(W * H);
|
|
56
|
-
m.rasterizeDeformedTo(buf, W, H, {
|
|
56
|
+
m.rasterizeDeformedTo(buf, W, H, { interpolation: 'smooth' });
|
|
57
57
|
// buf is ARGB-packed sRGB. Wrap as ImageData:
|
|
58
58
|
const img = new ImageData(new Uint8ClampedArray(buf.buffer), W, H);
|
|
59
59
|
|
|
@@ -63,17 +63,25 @@ positions affect rasterization geometry but NOT the color basis. To
|
|
|
63
63
|
sampleAt and ignore the deformation. That's the right behavior when
|
|
64
64
|
resizing a mesh and preserving its theme.
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
- '
|
|
69
|
-
- '
|
|
66
|
+
Mesh interpolation modes (sampleAt's `mode` arg / rasterize's
|
|
67
|
+
`opts.interpolation`):
|
|
68
|
+
- 'bilinear' — bilinear OKLCH. Fastest. Facet-visible at cell borders. Default.
|
|
69
|
+
- 'smooth' — bilinear with smoothstep ease. C¹ at seams.
|
|
70
|
+
- 'cubic' — Catmull-Rom across cells. Smoothest, ~2.5× cost.
|
|
71
|
+
|
|
72
|
+
Legacy boolean accepted for sampleAt: `false` → bilinear, `true` → smooth.
|
|
73
|
+
Rasterize also accepts `opts.smooth: true` as a legacy alias.
|
|
70
74
|
|
|
71
75
|
## Multi-format export
|
|
72
76
|
|
|
73
77
|
const state = { mode: 'linear', angle: 135, stops };
|
|
74
|
-
toTokens1d('css',
|
|
75
|
-
toTokens1d('tailwind',
|
|
76
|
-
toTokens1d('svg',
|
|
78
|
+
toTokens1d(state, 'css', { name: 'sunset' });
|
|
79
|
+
toTokens1d(state, 'tailwind', { name: 'sunset' });
|
|
80
|
+
toTokens1d(state, 'svg', { name: 'sunset' });
|
|
81
|
+
|
|
82
|
+
Signature: toTokens1d(g1dState, formatId, opts) — STATE first, FORMAT
|
|
83
|
+
second. Returns a string. toTokensMesh(mesh, formatId, opts) follows
|
|
84
|
+
the same pattern.
|
|
77
85
|
|
|
78
86
|
Supported 1D formats: 'css', 'css-var', 'scss', 'tailwind', 'json', 'svg'.
|
|
79
87
|
Supported mesh formats: 'css', 'css-var', 'json'.
|
|
@@ -87,28 +95,22 @@ Round-trips with formatCssLinear/Radial/Conic.
|
|
|
87
95
|
|
|
88
96
|
## Extract a palette from an image
|
|
89
97
|
|
|
90
|
-
//
|
|
91
|
-
const palette = extractPalette(
|
|
92
|
-
//
|
|
98
|
+
// rgba is a Uint8ClampedArray of length width*height*4
|
|
99
|
+
const palette = extractPalette(rgba, 5);
|
|
100
|
+
// → [{ l, c, h }, ...] — up to 5 picks, chroma-weighted, ≥50° hue separated
|
|
101
|
+
|
|
102
|
+
Signature: extractPalette(pixels, count?). Width/height NOT required —
|
|
103
|
+
the algorithm scans linearly. `count` defaults to 5; output may be
|
|
104
|
+
shorter if the image lacks that many distinct hues.
|
|
93
105
|
|
|
94
106
|
Chroma-weighted means vivid colors win over dominant-by-pixel-count
|
|
95
107
|
colors. A baby-in-blue-shirt photo returns a palette with blue, not
|
|
96
108
|
five shades of skin.
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
width and height as separate parameters. Earlier draft docs claimed a
|
|
100
|
-
`(rgba, w, h, count)` signature -- corrected in 1.0.
|
|
101
|
-
|
|
102
|
-
## Convert OKLCH <-> hex
|
|
103
|
-
|
|
104
|
-
toHex({ l: 0.65, c: 0.26, h: 320 }); // -> '#cf5fb9' (sRGB-gamut clipped)
|
|
105
|
-
fromHex('#cf5fb9'); // -> { l, c, h, a }
|
|
110
|
+
## Convert OKLCH ↔ hex
|
|
106
111
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
oklchToLinearSrgb(0.5, 0.2, 240, rgbOut); // writes into rgbOut, returns rgbOut
|
|
110
|
-
const lchOut = { l: 0, c: 0, h: 0 };
|
|
111
|
-
linearSrgbToOklch(0.4, 0.3, 0.8, lchOut); // writes into lchOut, returns lchOut
|
|
112
|
+
toHex({ l: 0.65, c: 0.26, h: 320 }); // → '#cf5fb9' (sRGB-gamut clipped)
|
|
113
|
+
fromHex('#cf5fb9'); // → { l, c, h, a }
|
|
112
114
|
|
|
113
115
|
# Stop shape
|
|
114
116
|
|
|
@@ -148,7 +150,7 @@ the rasterizer writes — no copy on putImageData.
|
|
|
148
150
|
# Anti-patterns
|
|
149
151
|
|
|
150
152
|
- DO NOT construct a new Gradient per frame in a hot loop. Make it once,
|
|
151
|
-
store the reference, mutate via
|
|
153
|
+
store the reference, mutate via setPoint / setPointPosition if
|
|
152
154
|
the colors change.
|
|
153
155
|
- DO NOT pass a fresh scratch object to sampleAt per call. Allocate
|
|
154
156
|
one at module scope or per editor instance.
|
package/package.json
CHANGED
|
@@ -1,34 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-gradient-studio",
|
|
3
|
-
"version": "1.0.
|
|
4
|
-
"description": "Authoring engine for OKLCH gradients
|
|
3
|
+
"version": "1.0.1",
|
|
4
|
+
"description": "Authoring engine for OKLCH gradients — linear, radial, conic, and N×M mesh — with zero-GC rasterization, multi-format export (CSS/SCSS/Tailwind/JSON/SVG), and chroma-weighted palette extraction.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
8
|
-
"main": "
|
|
9
|
-
"module": "./src/index.js",
|
|
10
|
-
"types": "./src/index.d.ts",
|
|
8
|
+
"main": "src/index.js",
|
|
11
9
|
"exports": {
|
|
12
|
-
".":
|
|
13
|
-
"node": "./src/index.js",
|
|
14
|
-
"import": "./src/index.js",
|
|
15
|
-
"types": "./src/index.d.ts",
|
|
16
|
-
"default": "./src/index.js"
|
|
17
|
-
}
|
|
10
|
+
".": "./src/index.js"
|
|
18
11
|
},
|
|
19
12
|
"files": [
|
|
20
|
-
"src
|
|
21
|
-
"README.md",
|
|
13
|
+
"src",
|
|
22
14
|
"llms.txt",
|
|
23
15
|
"CHANGELOG.md",
|
|
24
|
-
"LICENSE"
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"README.md"
|
|
25
18
|
],
|
|
26
19
|
"scripts": {
|
|
27
|
-
"test":
|
|
28
|
-
"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"
|
|
20
|
+
"test": "node --test --expose-gc test/*.test.js",
|
|
21
|
+
"bench": "node --expose-gc bench/run.mjs"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@zakkster/lite-color": "^1.0.6",
|
|
25
|
+
"@zakkster/lite-color-engine": "^1.0.0",
|
|
26
|
+
"@zakkster/lite-gradient": "^1.0.4"
|
|
32
27
|
},
|
|
33
28
|
"keywords": [
|
|
34
29
|
"gradient",
|
|
@@ -43,39 +38,16 @@
|
|
|
43
38
|
"color-interpolation",
|
|
44
39
|
"perceptually-uniform",
|
|
45
40
|
"zero-gc",
|
|
46
|
-
"zero-allocation",
|
|
47
41
|
"lite",
|
|
48
42
|
"no-build",
|
|
49
|
-
"esm"
|
|
50
|
-
"rasterizer",
|
|
51
|
-
"deterministic"
|
|
43
|
+
"esm"
|
|
52
44
|
],
|
|
53
|
-
"homepage":
|
|
45
|
+
"homepage": "https://github.com/PeshoVurtoleta/lite-gradient-studio#readme",
|
|
54
46
|
"repository": {
|
|
55
47
|
"type": "git",
|
|
56
|
-
"url":
|
|
48
|
+
"url": "git+https://github.com/PeshoVurtoleta/lite-gradient-studio.git"
|
|
57
49
|
},
|
|
58
50
|
"bugs": {
|
|
59
|
-
"url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
|
|
60
|
-
"email": "shinikchiev@yahoo.com"
|
|
61
|
-
},
|
|
62
|
-
"engines": {
|
|
63
|
-
"node": ">=18"
|
|
64
|
-
},
|
|
65
|
-
"funding": {
|
|
66
|
-
"type": "github",
|
|
67
|
-
"url": "https://github.com/sponsors/PeshoVurtoleta"
|
|
68
|
-
},
|
|
69
|
-
"publishConfig": {
|
|
70
|
-
"access": "public"
|
|
71
|
-
},
|
|
72
|
-
"sideEffects": false,
|
|
73
|
-
"dependencies": {
|
|
74
|
-
"@zakkster/lite-color": "^1.0.6",
|
|
75
|
-
"@zakkster/lite-color-engine": "^1.0.0",
|
|
76
|
-
"@zakkster/lite-gradient": "^1.0.4"
|
|
77
|
-
},
|
|
78
|
-
"devDependencies": {
|
|
79
|
-
"c8": "^11.0.0"
|
|
51
|
+
"url": "https://github.com/PeshoVurtoleta/lite-gradient-studio/issues"
|
|
80
52
|
}
|
|
81
53
|
}
|
package/src/color-convert.js
CHANGED
|
@@ -1,136 +1,88 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* OKLCH
|
|
2
|
+
* OKLCH ↔ hex.
|
|
3
3
|
*
|
|
4
4
|
* Pure math; no DOM. Ported from `@zakkster/lite-hueforge` so this
|
|
5
|
-
* library doesn't depend on Hueforge for a single utility
|
|
5
|
+
* library doesn't depend on Hueforge for a single utility — and so
|
|
6
6
|
* future consumers can use Gradient Studio's exporters standalone.
|
|
7
7
|
*
|
|
8
8
|
* `toHex` is the hot path (called once per stop per export). The
|
|
9
9
|
* gamut mapper does a 10-iteration binary search on chroma when the
|
|
10
|
-
* requested color is out of sRGB
|
|
10
|
+
* requested color is out of sRGB — precision Δc ≈ 0.0002, which is
|
|
11
11
|
* well below perceptual threshold.
|
|
12
|
-
*
|
|
13
|
-
* Zero-GC discipline:
|
|
14
|
-
* - `oklchToLinearSrgb(L, C, H, out?)`: pass a `[r, g, b]` array to
|
|
15
|
-
* `out` for zero-allocation; otherwise allocates one fresh array.
|
|
16
|
-
* - `linearSrgbToOklch(r, g, b, out?)`: pass a `{l, c, h}` object to
|
|
17
|
-
* `out` for zero-allocation; otherwise allocates one fresh object.
|
|
18
|
-
* - The internal binary-search helper uses a module-level scratch and
|
|
19
|
-
* takes (L, cosH, sinH) by parameter rather than closing over them,
|
|
20
|
-
* so no per-call closure is allocated either way.
|
|
21
12
|
*/
|
|
22
13
|
|
|
23
|
-
/* Module-level scratch for the gamut-mapping binary search. Single-
|
|
24
|
-
* threaded JS: safe to reuse across calls. Not exposed. */
|
|
25
|
-
const _gamutTry = [0, 0, 0];
|
|
26
|
-
|
|
27
14
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*/
|
|
31
|
-
function evalOklchRgb(L, cosH, sinH, cVal, dst) {
|
|
32
|
-
const a = cVal * cosH;
|
|
33
|
-
const b = cVal * sinH;
|
|
34
|
-
const lP = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
35
|
-
const mP = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
36
|
-
const sP = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
37
|
-
const lL = lP * lP * lP;
|
|
38
|
-
const mL = mP * mP * mP;
|
|
39
|
-
const sL = sP * sP * sP;
|
|
40
|
-
dst[0] = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
|
|
41
|
-
dst[1] = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
|
|
42
|
-
dst[2] = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* OKLCH -> linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
|
|
47
|
-
* Output range: each channel in [0, 1].
|
|
48
|
-
*
|
|
49
|
-
* @param {number} L
|
|
50
|
-
* @param {number} C
|
|
51
|
-
* @param {number} H
|
|
52
|
-
* @param {number[]} [out] 3-element array. If provided, written in place
|
|
53
|
-
* and returned (zero allocation). Otherwise a
|
|
54
|
-
* fresh `[r, g, b]` array is allocated.
|
|
55
|
-
* @returns {number[]} Same `out` if provided, else a new array.
|
|
15
|
+
* OKLCH → linear sRGB triplet, with sRGB-gamut mapping by chroma reduction.
|
|
16
|
+
* Output: [r, g, b] each in [0, 1].
|
|
56
17
|
*/
|
|
57
|
-
export function oklchToLinearSrgb(L, C, H
|
|
58
|
-
if (!out) out = [0, 0, 0];
|
|
59
|
-
|
|
18
|
+
export function oklchToLinearSrgb(L, C, H) {
|
|
60
19
|
const hRad = H * Math.PI / 180;
|
|
61
20
|
const cosH = Math.cos(hRad);
|
|
62
21
|
const sinH = Math.sin(hRad);
|
|
63
22
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
23
|
+
function getRgb(cVal) {
|
|
24
|
+
const a = cVal * cosH;
|
|
25
|
+
const b = cVal * sinH;
|
|
26
|
+
const lP = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
27
|
+
const mP = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
28
|
+
const sP = L - 0.0894841775 * a - 1.2914855480 * b;
|
|
29
|
+
const lL = lP * lP * lP;
|
|
30
|
+
const mL = mP * mP * mP;
|
|
31
|
+
const sL = sP * sP * sP;
|
|
32
|
+
const r = +4.0767416621 * lL - 3.3077115913 * mL + 0.2309699292 * sL;
|
|
33
|
+
const g = -1.2684380046 * lL + 2.6097574011 * mL - 0.3413193965 * sL;
|
|
34
|
+
const bb = -0.0041960863 * lL - 0.7034186147 * mL + 1.7076147010 * sL;
|
|
35
|
+
return [r, g, bb];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let [r, g, bb] = getRgb(C);
|
|
39
|
+
|
|
40
|
+
if (r >= 0 && r <= 1 && g >= 0 && g <= 1 && bb >= 0 && bb <= 1) {
|
|
41
|
+
return [r, g, bb];
|
|
72
42
|
}
|
|
73
43
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
if (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (out[0] < 0) out[0] = 0; else if (out[0] > 1) out[0] = 1;
|
|
84
|
-
if (out[1] < 0) out[1] = 0; else if (out[1] > 1) out[1] = 1;
|
|
85
|
-
if (out[2] < 0) out[2] = 0; else if (out[2] > 1) out[2] = 1;
|
|
86
|
-
return out;
|
|
44
|
+
// Gamut map via binary search on chroma. The C=0 point at this L is
|
|
45
|
+
// always in gamut if L ∈ [0,1]; defensive fallback covers caller
|
|
46
|
+
// passing out-of-range L.
|
|
47
|
+
const [gr, gg, gb] = getRgb(0);
|
|
48
|
+
if (gr < 0 || gr > 1 || gg < 0 || gg > 1 || gb < 0 || gb > 1) {
|
|
49
|
+
if (r < 0) r = 0; else if (r > 1) r = 1;
|
|
50
|
+
if (g < 0) g = 0; else if (g > 1) g = 1;
|
|
51
|
+
if (bb < 0) bb = 0; else if (bb > 1) bb = 1;
|
|
52
|
+
return [r, g, bb];
|
|
87
53
|
}
|
|
88
54
|
|
|
89
|
-
// Binary search for the largest in-gamut chroma in [0, C]. The fit
|
|
90
|
-
// is held in `out`; the candidate at the current `midC` is in
|
|
91
|
-
// `_gamutTry`. Precision dC ~= C / 2^10 ~= 0.0002 -- well below
|
|
92
|
-
// perceptual threshold.
|
|
93
55
|
let lo = 0, hi = C;
|
|
94
|
-
|
|
56
|
+
let fitR = gr, fitG = gg, fitB = gb;
|
|
95
57
|
for (let i = 0; i < 10; i++) {
|
|
96
58
|
const midC = (lo + hi) / 2;
|
|
97
|
-
|
|
98
|
-
if (
|
|
99
|
-
_gamutTry[0] >= 0 && _gamutTry[0] <= 1 &&
|
|
100
|
-
_gamutTry[1] >= 0 && _gamutTry[1] <= 1 &&
|
|
101
|
-
_gamutTry[2] >= 0 && _gamutTry[2] <= 1
|
|
102
|
-
) {
|
|
59
|
+
const [mr, mg, mbb] = getRgb(midC);
|
|
60
|
+
if (mr >= 0 && mr <= 1 && mg >= 0 && mg <= 1 && mbb >= 0 && mbb <= 1) {
|
|
103
61
|
lo = midC;
|
|
104
|
-
|
|
105
|
-
out[1] = _gamutTry[1];
|
|
106
|
-
out[2] = _gamutTry[2];
|
|
62
|
+
fitR = mr; fitG = mg; fitB = mbb;
|
|
107
63
|
} else {
|
|
108
64
|
hi = midC;
|
|
109
65
|
}
|
|
110
66
|
}
|
|
111
|
-
return
|
|
67
|
+
return [fitR, fitG, fitB];
|
|
112
68
|
}
|
|
113
69
|
|
|
114
|
-
/** sRGB transfer (linear
|
|
70
|
+
/** sRGB transfer (linear → gamma-encoded). IEC 61966-2-1. */
|
|
115
71
|
export function srgbGamma(x) {
|
|
116
72
|
return x <= 0.0031308
|
|
117
73
|
? x * 12.92
|
|
118
74
|
: 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
119
75
|
}
|
|
120
76
|
|
|
121
|
-
/** sRGB inverse transfer (gamma-encoded
|
|
77
|
+
/** sRGB inverse transfer (gamma-encoded → linear). */
|
|
122
78
|
export function srgbInverseGamma(x) {
|
|
123
79
|
return x <= 0.04045
|
|
124
80
|
? x / 12.92
|
|
125
81
|
: Math.pow((x + 0.055) / 1.055, 2.4);
|
|
126
82
|
}
|
|
127
83
|
|
|
128
|
-
/* Scratch for `toHex`. Single allocation at module load; safe to reuse
|
|
129
|
-
* because toHex completes synchronously and JS is single-threaded. */
|
|
130
|
-
const _toHexRgb = [0, 0, 0];
|
|
131
|
-
|
|
132
84
|
/**
|
|
133
|
-
* OKLCH
|
|
85
|
+
* OKLCH → hex. Emits '#rrggbb' for opaque colors (a >= 1 or undefined)
|
|
134
86
|
* and '#rrggbbaa' when alpha is below 1. Output always lowercase.
|
|
135
87
|
*
|
|
136
88
|
* Bit-pack trick: `(1<<24) | (R<<16) | (G<<8) | B` produces a number
|
|
@@ -139,10 +91,10 @@ const _toHexRgb = [0, 0, 0];
|
|
|
139
91
|
* intermediate garbage.
|
|
140
92
|
*/
|
|
141
93
|
export function toHex({ l, c, h, a }) {
|
|
142
|
-
oklchToLinearSrgb(l, c, h
|
|
143
|
-
const R = (srgbGamma(
|
|
144
|
-
const G = (srgbGamma(
|
|
145
|
-
const B = (srgbGamma(
|
|
94
|
+
const linRgb = oklchToLinearSrgb(l, c, h);
|
|
95
|
+
const R = (srgbGamma(linRgb[0]) * 255 + 0.5) | 0;
|
|
96
|
+
const G = (srgbGamma(linRgb[1]) * 255 + 0.5) | 0;
|
|
97
|
+
const B = (srgbGamma(linRgb[2]) * 255 + 0.5) | 0;
|
|
146
98
|
const rgb = '#' + ((1 << 24) | (R << 16) | (G << 8) | B).toString(16).slice(1);
|
|
147
99
|
// Opacity: skip the alpha byte when fully opaque, so the common case
|
|
148
100
|
// produces clean 6-char hex matching design-tool conventions.
|
|
@@ -157,19 +109,9 @@ function clampByte(v) {
|
|
|
157
109
|
}
|
|
158
110
|
|
|
159
111
|
/**
|
|
160
|
-
* Linear sRGB
|
|
161
|
-
*
|
|
162
|
-
* @param {number} r Linear red, [0, 1].
|
|
163
|
-
* @param {number} g
|
|
164
|
-
* @param {number} b
|
|
165
|
-
* @param {{l:number,c:number,h:number,a?:number}} [out]
|
|
166
|
-
* If provided, written in place and returned (zero allocation).
|
|
167
|
-
* The `a` field is left untouched -- callers that need alpha
|
|
168
|
-
* plumbing set it themselves.
|
|
169
|
-
* @returns {{l:number,c:number,h:number}} Same `out` if provided.
|
|
112
|
+
* Linear sRGB → OKLCH. Used by fromHex and the palette extractor.
|
|
170
113
|
*/
|
|
171
|
-
export function linearSrgbToOklch(r, g, b
|
|
172
|
-
if (!out) out = { l: 0, c: 0, h: 0 };
|
|
114
|
+
export function linearSrgbToOklch(r, g, b) {
|
|
173
115
|
const lLms = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
174
116
|
const mLms = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
175
117
|
const sLms = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
@@ -177,17 +119,16 @@ export function linearSrgbToOklch(r, g, b, out) {
|
|
|
177
119
|
const mP = Math.cbrt(mLms);
|
|
178
120
|
const sP = Math.cbrt(sLms);
|
|
179
121
|
const L = 0.2104542553 * lP + 0.7936177850 * mP - 0.0040720468 * sP;
|
|
180
|
-
const
|
|
122
|
+
const a = 1.9779984951 * lP - 2.4285922050 * mP + 0.4505937099 * sP;
|
|
181
123
|
const bb = 0.0259040371 * lP + 0.7827717662 * mP - 0.8086757660 * sP;
|
|
182
|
-
const C = Math.sqrt(
|
|
183
|
-
let H = Math.atan2(bb,
|
|
124
|
+
const C = Math.sqrt(a * a + bb * bb);
|
|
125
|
+
let H = Math.atan2(bb, a) * 180 / Math.PI;
|
|
184
126
|
if (H < 0) H += 360;
|
|
185
|
-
|
|
186
|
-
return out;
|
|
127
|
+
return { l: L, c: C, h: H };
|
|
187
128
|
}
|
|
188
129
|
|
|
189
130
|
/**
|
|
190
|
-
* Hex string
|
|
131
|
+
* Hex string → { l, c, h, a }. Accepts:
|
|
191
132
|
* - 3-char '#rgb' (alpha defaults to 1)
|
|
192
133
|
* - 4-char '#rgba' (each nibble doubled)
|
|
193
134
|
* - 6-char '#rrggbb' (alpha defaults to 1)
|
package/src/exporters.js
CHANGED
|
@@ -82,6 +82,27 @@ export function toTokensMesh(mesh, format, opts = {}) {
|
|
|
82
82
|
|
|
83
83
|
/* ── 1D exporters ────────────────────────────────────────────────── */
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Read the position field from a stop, accepting both conventions.
|
|
87
|
+
*
|
|
88
|
+
* The 1D state shape carries position as `stop` (matches the editor's
|
|
89
|
+
* field name and the exporter signature documented in `toTokens1d`).
|
|
90
|
+
* The `Gradient` class (re-exported from `@zakkster/lite-gradient`)
|
|
91
|
+
* carries it as `pos`. README quick-start examples that pipe
|
|
92
|
+
* `gradient.stops` directly into `toTokens1d` would otherwise produce
|
|
93
|
+
* `NaN%` positions; this fallback makes either shape work without a
|
|
94
|
+
* caller-side map step.
|
|
95
|
+
*
|
|
96
|
+
* Precedence: `stop` first (it's the documented field), `pos` as
|
|
97
|
+
* fallback. Returns 0 if neither is set — defensive, mirrors how the
|
|
98
|
+
* rest of the exporter treats malformed input.
|
|
99
|
+
*/
|
|
100
|
+
function readStopPos(s) {
|
|
101
|
+
if (s.stop !== undefined) return s.stop;
|
|
102
|
+
if (s.pos !== undefined) return s.pos;
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
85
106
|
/** Return the raw CSS gradient string for this 1D state. */
|
|
86
107
|
function cssBodyFor1d(g) {
|
|
87
108
|
// Build a Gradient-shaped stops array (renames `stop` → `pos`).
|
|
@@ -90,7 +111,7 @@ function cssBodyFor1d(g) {
|
|
|
90
111
|
const stopsForCss = g.stops.map((s) => ({
|
|
91
112
|
l: s.l, c: s.c, h: s.h,
|
|
92
113
|
a: s.a === undefined ? 1 : s.a,
|
|
93
|
-
pos: s
|
|
114
|
+
pos: readStopPos(s),
|
|
94
115
|
}));
|
|
95
116
|
const fake = { stops: stopsForCss }; // formatCss* only reads .stops
|
|
96
117
|
if (g.mode === 'linear') {
|
|
@@ -162,7 +183,7 @@ export function toJson1d(g, opts = {}) {
|
|
|
162
183
|
interpolation: 'oklch',
|
|
163
184
|
stops: g.stops.map((s) => {
|
|
164
185
|
const stop = {
|
|
165
|
-
position: round3(s
|
|
186
|
+
position: round3(readStopPos(s)),
|
|
166
187
|
color: { l: round3(s.l), c: round3(s.c), h: round1(s.h) },
|
|
167
188
|
hex: toHex(s),
|
|
168
189
|
};
|
package/src/mesh.js
CHANGED
|
@@ -281,29 +281,36 @@ export class MeshGradient {
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
/**
|
|
284
|
-
* Sample the
|
|
285
|
-
* Zero-GC
|
|
286
|
-
* per-pixel allocation a return-by-value version would force.
|
|
287
|
-
* Bilinear in OKLCH, hue-shortest-path via `lerpOklchTo`.
|
|
284
|
+
* Sample the mesh at normalized (u, v) ∈ [0, 1]² into `out`.
|
|
285
|
+
* Zero-GC. Bilinear in OKLCH, hue-shortest-path via lerpOklchTo.
|
|
288
286
|
*
|
|
289
|
-
* Hue normalization
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
287
|
+
* Hue normalization: lerpOklchTo takes the shortest-path delta but
|
|
288
|
+
* does NOT bound its output to [0, 360). At extreme corners or after
|
|
289
|
+
* wrap-crossing lerps, the intermediate hue can land at 390 or -30.
|
|
290
|
+
* Left unnormalized, that propagates into the second lerp and adds
|
|
291
|
+
* another 360 to the final hue. We normalize after each stage.
|
|
292
|
+
*
|
|
293
|
+
/**
|
|
294
|
+
* Sample the gradient at parametric (u, v) ∈ [0, 1]². Writes into `out`
|
|
295
|
+
* to avoid the per-pixel allocation a return-by-value version would force.
|
|
296
|
+
*
|
|
297
|
+
* Hue normalization matters: `lerpOklchTo` picks the shortest hue path but
|
|
298
|
+
* does NOT bound its output to [0, 360). At extreme corners or after
|
|
299
|
+
* wrap-crossing lerps, the intermediate hue can land at 390 or -30.
|
|
300
|
+
* Left unnormalized, that propagates into the second lerp and adds
|
|
301
|
+
* another 360 to the final hue. We normalize after each stage.
|
|
295
302
|
*
|
|
296
303
|
* The optional fourth parameter selects interpolation. Accepts:
|
|
297
|
-
* - false / undefined
|
|
298
|
-
* - true
|
|
299
|
-
* - 'cubic'
|
|
304
|
+
* - false / undefined → 'bilinear' (original, C⁰)
|
|
305
|
+
* - true → 'smooth' (smoothstep on cu/cv, C¹ at seams)
|
|
306
|
+
* - 'cubic' → Catmull-Rom 2D (C¹ everywhere; 4× cost)
|
|
300
307
|
* - any of the strings above explicitly
|
|
301
308
|
*
|
|
302
309
|
* @param {number} u
|
|
303
310
|
* @param {number} v
|
|
304
|
-
* @param {{l:number,c:number,h:number
|
|
311
|
+
* @param {{l:number,c:number,h:number}} out
|
|
305
312
|
* @param {boolean|string} [modeOrSmooth=false]
|
|
306
|
-
* @returns {{l:number,c:number,h:number
|
|
313
|
+
* @returns {{l:number,c:number,h:number}} same `out`
|
|
307
314
|
*/
|
|
308
315
|
sampleAt(u, v, out, modeOrSmooth = false) {
|
|
309
316
|
// Normalize the legacy boolean to the string form. Hot path —
|
package/src/palette-extract.js
CHANGED
|
@@ -11,32 +11,32 @@
|
|
|
11
11
|
* top buckets from the warm cluster (variants of skin) and the blue
|
|
12
12
|
* either gets dedup'd against the warm picks or ranked below them.
|
|
13
13
|
* Designer's lived expectation: "import this photo of a baby in a blue
|
|
14
|
-
* shirt
|
|
14
|
+
* shirt → I should see blue in the palette."
|
|
15
15
|
*
|
|
16
|
-
* The fix is to count CHROMA, not pixels
|
|
17
|
-
* separation between picks. A handful of vivid-blue pixels at C
|
|
18
|
-
* contribute more weight than a thousand near-grey pixels at C
|
|
19
|
-
* A 50
|
|
16
|
+
* The fix is to count CHROMA, not pixels — and to enforce a minimum hue
|
|
17
|
+
* separation between picks. A handful of vivid-blue pixels at C ≈ 0.20
|
|
18
|
+
* contribute more weight than a thousand near-grey pixels at C ≈ 0.02.
|
|
19
|
+
* A 50° hue-separation rule then guarantees the second pick can't be
|
|
20
20
|
* another shade of the first pick's hue.
|
|
21
21
|
*
|
|
22
22
|
* Algorithm:
|
|
23
23
|
* 1. Single-pass walk of RGBA pixels. Convert each to OKLCH inline
|
|
24
|
-
* (no per-pixel function call overhead
|
|
24
|
+
* (no per-pixel function call overhead — the matrix path is
|
|
25
25
|
* duplicated from color-convert.js for speed).
|
|
26
|
-
* 2. Skip pixels darker than `darkFloor` (L < 0.10)
|
|
26
|
+
* 2. Skip pixels darker than `darkFloor` (L < 0.10) — shadows, not
|
|
27
27
|
* "colors". Skip fully-transparent pixels.
|
|
28
28
|
* 3. Pixels below `chromaticThreshold` (C < 0.02) feed a NEUTRAL
|
|
29
|
-
* pool
|
|
29
|
+
* pool — accumulated L average becomes the single representative
|
|
30
30
|
* neutral output, slotted at the desired position.
|
|
31
|
-
* 4. Chromatic pixels are binned into 24 hue buckets (15
|
|
31
|
+
* 4. Chromatic pixels are binned into 24 hue buckets (15° each).
|
|
32
32
|
* Per bucket we track: total chroma weight, count, and the
|
|
33
33
|
* MOST VIVID pixel's L/C/H (this becomes the bucket's
|
|
34
|
-
* representative
|
|
34
|
+
* representative — picking the most-vivid avoids muddy
|
|
35
35
|
* "average" colors that real-photo k-means produces).
|
|
36
36
|
* 5. Sort buckets by total chroma weight. Greedy pick top buckets,
|
|
37
37
|
* enforcing `minHueSeparation` between picks. If we can't fill
|
|
38
|
-
* enough slots at 50
|
|
39
|
-
* 6. Sort the final picks by L for natural dark
|
|
38
|
+
* enough slots at 50°, progressively relax to 30° / 15° / 0°.
|
|
39
|
+
* 6. Sort the final picks by L for natural dark→light gradient order.
|
|
40
40
|
*
|
|
41
41
|
* Behavior preserved from v1:
|
|
42
42
|
* - Returns Array<{l, c, h}> sorted by lightness ascending.
|
|
@@ -51,38 +51,18 @@
|
|
|
51
51
|
* 1D callers still get the same neutral via the standard pick path
|
|
52
52
|
* when they request enough slots.
|
|
53
53
|
*
|
|
54
|
-
* Performance: a
|
|
55
|
-
* a typical laptop.
|
|
56
|
-
*
|
|
57
|
-
* and reset (not re-allocated) on each call.
|
|
54
|
+
* Performance: a 240×180 sample = 43 200 pixels iterates in ~5 ms on
|
|
55
|
+
* a typical laptop. Single Map allocation (none — pre-allocated
|
|
56
|
+
* buckets array). Zero per-pixel allocations in the inner loop.
|
|
58
57
|
*/
|
|
59
58
|
|
|
60
|
-
const HUE_BUCKETS = 24; // 15
|
|
59
|
+
const HUE_BUCKETS = 24; // 15° per bucket
|
|
61
60
|
const DEFAULT_MIN_HUE_SEP = 50; // initial hue separation requirement
|
|
62
|
-
const RELAXATION_LADDER = [30, 15, 0]; // tried in order if 50
|
|
63
|
-
const CHROMATIC_THRESHOLD = 0.02; // C below this
|
|
64
|
-
const DARK_FLOOR = 0.10; // L below this
|
|
61
|
+
const RELAXATION_LADDER = [30, 15, 0]; // tried in order if 50° underfills
|
|
62
|
+
const CHROMATIC_THRESHOLD = 0.02; // C below this → neutral pool
|
|
63
|
+
const DARK_FLOOR = 0.10; // L below this → skip (shadow)
|
|
65
64
|
const NEUTRAL_CHROMA = 0.005; // tiny tint for the neutral slot
|
|
66
65
|
|
|
67
|
-
/* Module-level bucket state. One allocation at module load; reset per
|
|
68
|
-
* call. Single-threaded JS: safe to share across calls because each
|
|
69
|
-
* `extractPalette` runs synchronously to completion. */
|
|
70
|
-
const _buckets = new Array(HUE_BUCKETS);
|
|
71
|
-
for (let i = 0; i < HUE_BUCKETS; i++) {
|
|
72
|
-
_buckets[i] = { weight: 0, count: 0, maxC: 0, mcL: 0, mcH: 0 };
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/* Reusable scratch for the "filtered + sorted buckets" view. Sized to
|
|
76
|
-
* fit all chromatic buckets; trim length per call. */
|
|
77
|
-
const _sortedBuckets = new Array(HUE_BUCKETS);
|
|
78
|
-
|
|
79
|
-
function resetBuckets() {
|
|
80
|
-
for (let i = 0; i < HUE_BUCKETS; i++) {
|
|
81
|
-
const b = _buckets[i];
|
|
82
|
-
b.weight = 0; b.count = 0; b.maxC = 0; b.mcL = 0; b.mcH = 0;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
66
|
/**
|
|
87
67
|
* @param {Uint8ClampedArray} pixels RGBA, length must be multiple of 4.
|
|
88
68
|
* @param {number} [count] Target palette size (default 5).
|
|
@@ -92,25 +72,27 @@ function resetBuckets() {
|
|
|
92
72
|
export function extractPalette(pixels, count = 5) {
|
|
93
73
|
if (count < 1) return [];
|
|
94
74
|
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
75
|
+
// Pre-allocated bucket state — written in place, never reallocated.
|
|
76
|
+
const buckets = new Array(HUE_BUCKETS);
|
|
77
|
+
for (let i = 0; i < HUE_BUCKETS; i++) {
|
|
78
|
+
buckets[i] = { weight: 0, count: 0, maxC: 0, mcL: 0, mcH: 0 };
|
|
79
|
+
}
|
|
98
80
|
let neutralLsum = 0;
|
|
99
81
|
let neutralCount = 0;
|
|
100
82
|
|
|
101
|
-
// Single pass. Convert each pixel sRGB byte
|
|
83
|
+
// Single pass. Convert each pixel sRGB byte → OKLCH inline to keep
|
|
102
84
|
// the hot path allocation-free. The matrix coefficients are the
|
|
103
|
-
// canonical OKLab transform (
|
|
85
|
+
// canonical OKLab transform (Björn Ottosson, 2020).
|
|
104
86
|
for (let i = 0; i < pixels.length; i += 4) {
|
|
105
87
|
if (pixels[i + 3] < 128) continue; // skip transparent
|
|
106
88
|
|
|
107
89
|
const r8 = pixels[i], g8 = pixels[i + 1], b8 = pixels[i + 2];
|
|
108
90
|
const rN = r8 / 255, gN = g8 / 255, bN = b8 / 255;
|
|
109
|
-
// sRGB inverse gamma
|
|
91
|
+
// sRGB inverse gamma → linear sRGB.
|
|
110
92
|
const rL = rN <= 0.04045 ? rN / 12.92 : Math.pow((rN + 0.055) / 1.055, 2.4);
|
|
111
93
|
const gL = gN <= 0.04045 ? gN / 12.92 : Math.pow((gN + 0.055) / 1.055, 2.4);
|
|
112
94
|
const bL = bN <= 0.04045 ? bN / 12.92 : Math.pow((bN + 0.055) / 1.055, 2.4);
|
|
113
|
-
// Linear sRGB
|
|
95
|
+
// Linear sRGB → LMS' (cube-rooted) → OKLab.
|
|
114
96
|
const lp = Math.cbrt(0.4122214708 * rL + 0.5363325363 * gL + 0.0514459929 * bL);
|
|
115
97
|
const mp = Math.cbrt(0.2119034982 * rL + 0.6806995451 * gL + 0.1073969566 * bL);
|
|
116
98
|
const sp = Math.cbrt(0.0883024619 * rL + 0.2817188376 * gL + 0.6299787005 * bL);
|
|
@@ -121,18 +103,18 @@ export function extractPalette(pixels, count = 5) {
|
|
|
121
103
|
|
|
122
104
|
if (L < DARK_FLOOR) continue; // shadows are not colors
|
|
123
105
|
|
|
124
|
-
if (C < CHROMATIC_THRESHOLD) { // near-grey
|
|
106
|
+
if (C < CHROMATIC_THRESHOLD) { // near-grey → neutral pool
|
|
125
107
|
neutralLsum += L;
|
|
126
108
|
neutralCount++;
|
|
127
109
|
continue;
|
|
128
110
|
}
|
|
129
111
|
|
|
130
|
-
// Hue in [0, 360). atan2 returns [
|
|
112
|
+
// Hue in [0, 360). atan2 returns [-π, π].
|
|
131
113
|
let H = Math.atan2(B, A) * 180 / Math.PI;
|
|
132
114
|
if (H < 0) H += 360;
|
|
133
115
|
|
|
134
116
|
const bucketIdx = (H * HUE_BUCKETS / 360) | 0;
|
|
135
|
-
const bkt =
|
|
117
|
+
const bkt = buckets[bucketIdx];
|
|
136
118
|
bkt.weight += C; // chroma-weighted, NOT pixel-count weighted
|
|
137
119
|
bkt.count++;
|
|
138
120
|
if (C > bkt.maxC) { // track the most-vivid pixel as the rep
|
|
@@ -142,25 +124,18 @@ export function extractPalette(pixels, count = 5) {
|
|
|
142
124
|
}
|
|
143
125
|
}
|
|
144
126
|
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
// Truncate-length sort via a small bubble-like pass would dominate
|
|
152
|
-
// for sortedLen <= 24; Array.prototype.sort on the sliced view is
|
|
153
|
-
// O(n log n) and the allocation is one tiny temporary. We sort the
|
|
154
|
-
// module-scope view itself in-place over its first sortedLen entries.
|
|
155
|
-
sortInPlaceByWeightDesc(_sortedBuckets, sortedLen);
|
|
127
|
+
// Sort buckets by total chroma weight, descending. Only consider
|
|
128
|
+
// buckets that actually received pixels.
|
|
129
|
+
const sortedBuckets = buckets
|
|
130
|
+
.filter((b) => b.count > 0)
|
|
131
|
+
.sort((a, b) => b.weight - a.weight);
|
|
156
132
|
|
|
157
133
|
// Greedy pick respecting min hue separation. The separation rule is
|
|
158
134
|
// what surfaces cool subject colors from a warm-dominated photo.
|
|
159
135
|
const pickWithSeparation = (separation) => {
|
|
160
136
|
const picks = [];
|
|
161
|
-
for (
|
|
137
|
+
for (const b of sortedBuckets) {
|
|
162
138
|
if (picks.length >= count) break;
|
|
163
|
-
const b = _sortedBuckets[i];
|
|
164
139
|
let tooClose = false;
|
|
165
140
|
for (const p of picks) {
|
|
166
141
|
const d = Math.abs(b.mcH - p.h) % 360;
|
|
@@ -171,7 +146,7 @@ export function extractPalette(pixels, count = 5) {
|
|
|
171
146
|
return picks;
|
|
172
147
|
};
|
|
173
148
|
|
|
174
|
-
// Try the canonical 50
|
|
149
|
+
// Try the canonical 50° separation. If the image is genuinely low-
|
|
175
150
|
// diversity (monochrome, hue-collapsed), relax progressively.
|
|
176
151
|
let picks = pickWithSeparation(DEFAULT_MIN_HUE_SEP);
|
|
177
152
|
if (picks.length < count) {
|
|
@@ -193,24 +168,7 @@ export function extractPalette(pixels, count = 5) {
|
|
|
193
168
|
});
|
|
194
169
|
}
|
|
195
170
|
|
|
196
|
-
// Lightness-ascending output
|
|
171
|
+
// Lightness-ascending output — natural dark→light gradient order.
|
|
197
172
|
picks.sort((a, b) => a.l - b.l);
|
|
198
173
|
return picks;
|
|
199
174
|
}
|
|
200
|
-
|
|
201
|
-
/* In-place insertion sort of buckets[0..n-1] by `weight` descending.
|
|
202
|
-
* For n <= 24 (HUE_BUCKETS) this is O(n^2 / 2) ~= 288 comparisons in
|
|
203
|
-
* the worst case -- faster and allocation-free vs `Array.prototype
|
|
204
|
-
* .sort` which builds a temporary copy. */
|
|
205
|
-
function sortInPlaceByWeightDesc(arr, n) {
|
|
206
|
-
for (let i = 1; i < n; i++) {
|
|
207
|
-
const cur = arr[i];
|
|
208
|
-
const curW = cur.weight;
|
|
209
|
-
let j = i - 1;
|
|
210
|
-
while (j >= 0 && arr[j].weight < curW) {
|
|
211
|
-
arr[j + 1] = arr[j];
|
|
212
|
-
j--;
|
|
213
|
-
}
|
|
214
|
-
arr[j + 1] = cur;
|
|
215
|
-
}
|
|
216
|
-
}
|