@zakkster/lite-gradient-studio 1.0.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 +105 -0
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/llms.txt +173 -0
- package/package.json +81 -0
- package/src/bake.js +141 -0
- package/src/color-convert.js +233 -0
- package/src/css-emitters.js +86 -0
- package/src/exporters.js +310 -0
- package/src/gradient-parse.js +447 -0
- package/src/index.d.ts +380 -0
- package/src/index.js +38 -0
- package/src/mesh-css.js +113 -0
- package/src/mesh.js +673 -0
- package/src/palette-extract.js +216 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract a designer-friendly palette from raw RGBA pixel data.
|
|
3
|
+
*
|
|
4
|
+
* v2 (post v0.0.30 review): chroma-weighted hue-bucket extraction, ported
|
|
5
|
+
* from the algorithm used in `@zakkster/lite-hueforge` v0.4.2.
|
|
6
|
+
*
|
|
7
|
+
* The previous v1 algorithm did frequency-weighted RGB quantization +
|
|
8
|
+
* OKLCH dedup. It failed reliably on photos: a photo of a person in a
|
|
9
|
+
* blue shirt produces ~80% skin / hair / background warm tones by pixel
|
|
10
|
+
* count, with blue confined to a small region. Frequency picks all five
|
|
11
|
+
* top buckets from the warm cluster (variants of skin) and the blue
|
|
12
|
+
* either gets dedup'd against the warm picks or ranked below them.
|
|
13
|
+
* Designer's lived expectation: "import this photo of a baby in a blue
|
|
14
|
+
* shirt -> I should see blue in the palette."
|
|
15
|
+
*
|
|
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 deg hue-separation rule then guarantees the second pick can't be
|
|
20
|
+
* another shade of the first pick's hue.
|
|
21
|
+
*
|
|
22
|
+
* Algorithm:
|
|
23
|
+
* 1. Single-pass walk of RGBA pixels. Convert each to OKLCH inline
|
|
24
|
+
* (no per-pixel function call overhead -- the matrix path is
|
|
25
|
+
* duplicated from color-convert.js for speed).
|
|
26
|
+
* 2. Skip pixels darker than `darkFloor` (L < 0.10) -- shadows, not
|
|
27
|
+
* "colors". Skip fully-transparent pixels.
|
|
28
|
+
* 3. Pixels below `chromaticThreshold` (C < 0.02) feed a NEUTRAL
|
|
29
|
+
* pool -- accumulated L average becomes the single representative
|
|
30
|
+
* neutral output, slotted at the desired position.
|
|
31
|
+
* 4. Chromatic pixels are binned into 24 hue buckets (15 deg each).
|
|
32
|
+
* Per bucket we track: total chroma weight, count, and the
|
|
33
|
+
* MOST VIVID pixel's L/C/H (this becomes the bucket's
|
|
34
|
+
* representative -- picking the most-vivid avoids muddy
|
|
35
|
+
* "average" colors that real-photo k-means produces).
|
|
36
|
+
* 5. Sort buckets by total chroma weight. Greedy pick top buckets,
|
|
37
|
+
* enforcing `minHueSeparation` between picks. If we can't fill
|
|
38
|
+
* enough slots at 50 deg, progressively relax to 30 / 15 / 0.
|
|
39
|
+
* 6. Sort the final picks by L for natural dark->light gradient order.
|
|
40
|
+
*
|
|
41
|
+
* Behavior preserved from v1:
|
|
42
|
+
* - Returns Array<{l, c, h}> sorted by lightness ascending.
|
|
43
|
+
* - May return fewer than `count` entries on truly low-diversity
|
|
44
|
+
* images (e.g. monochrome). Callers handle short arrays gracefully.
|
|
45
|
+
*
|
|
46
|
+
* Behavior changed from v1:
|
|
47
|
+
* - Mesh callers used to receive ALL chromatic colors with no neutral
|
|
48
|
+
* suppression. Now: near-grey pixels feed a separate neutral pool
|
|
49
|
+
* that contributes ONE output slot rather than several "grey-ish"
|
|
50
|
+
* stops. The user noted this is intentional and correct for mesh.
|
|
51
|
+
* 1D callers still get the same neutral via the standard pick path
|
|
52
|
+
* when they request enough slots.
|
|
53
|
+
*
|
|
54
|
+
* Performance: a 240x180 sample = 43 200 pixels iterates in ~5 ms on
|
|
55
|
+
* a typical laptop. Zero per-pixel allocation, zero per-call allocation
|
|
56
|
+
* once the module is loaded: the 24-entry bucket state is module-scoped
|
|
57
|
+
* and reset (not re-allocated) on each call.
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
const HUE_BUCKETS = 24; // 15 deg per bucket
|
|
61
|
+
const DEFAULT_MIN_HUE_SEP = 50; // initial hue separation requirement
|
|
62
|
+
const RELAXATION_LADDER = [30, 15, 0]; // tried in order if 50 deg underfills
|
|
63
|
+
const CHROMATIC_THRESHOLD = 0.02; // C below this -> neutral pool
|
|
64
|
+
const DARK_FLOOR = 0.10; // L below this -> skip (shadow)
|
|
65
|
+
const NEUTRAL_CHROMA = 0.005; // tiny tint for the neutral slot
|
|
66
|
+
|
|
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
|
+
/**
|
|
87
|
+
* @param {Uint8ClampedArray} pixels RGBA, length must be multiple of 4.
|
|
88
|
+
* @param {number} [count] Target palette size (default 5).
|
|
89
|
+
* @returns {Array<{l:number, c:number, h:number}>}
|
|
90
|
+
* Sorted by L ascending. May be shorter than `count`.
|
|
91
|
+
*/
|
|
92
|
+
export function extractPalette(pixels, count = 5) {
|
|
93
|
+
if (count < 1) return [];
|
|
94
|
+
|
|
95
|
+
// Reset module-scope state. Doing this once per call beats allocating
|
|
96
|
+
// 24 fresh objects every call.
|
|
97
|
+
resetBuckets();
|
|
98
|
+
let neutralLsum = 0;
|
|
99
|
+
let neutralCount = 0;
|
|
100
|
+
|
|
101
|
+
// Single pass. Convert each pixel sRGB byte -> OKLCH inline to keep
|
|
102
|
+
// the hot path allocation-free. The matrix coefficients are the
|
|
103
|
+
// canonical OKLab transform (Bjorn Ottosson, 2020).
|
|
104
|
+
for (let i = 0; i < pixels.length; i += 4) {
|
|
105
|
+
if (pixels[i + 3] < 128) continue; // skip transparent
|
|
106
|
+
|
|
107
|
+
const r8 = pixels[i], g8 = pixels[i + 1], b8 = pixels[i + 2];
|
|
108
|
+
const rN = r8 / 255, gN = g8 / 255, bN = b8 / 255;
|
|
109
|
+
// sRGB inverse gamma -> linear sRGB.
|
|
110
|
+
const rL = rN <= 0.04045 ? rN / 12.92 : Math.pow((rN + 0.055) / 1.055, 2.4);
|
|
111
|
+
const gL = gN <= 0.04045 ? gN / 12.92 : Math.pow((gN + 0.055) / 1.055, 2.4);
|
|
112
|
+
const bL = bN <= 0.04045 ? bN / 12.92 : Math.pow((bN + 0.055) / 1.055, 2.4);
|
|
113
|
+
// Linear sRGB -> LMS' (cube-rooted) -> OKLab.
|
|
114
|
+
const lp = Math.cbrt(0.4122214708 * rL + 0.5363325363 * gL + 0.0514459929 * bL);
|
|
115
|
+
const mp = Math.cbrt(0.2119034982 * rL + 0.6806995451 * gL + 0.1073969566 * bL);
|
|
116
|
+
const sp = Math.cbrt(0.0883024619 * rL + 0.2817188376 * gL + 0.6299787005 * bL);
|
|
117
|
+
const L = 0.2104542553 * lp + 0.7936177850 * mp - 0.0040720468 * sp;
|
|
118
|
+
const A = 1.9779984951 * lp - 2.4285922050 * mp + 0.4505937099 * sp;
|
|
119
|
+
const B = 0.0259040371 * lp + 0.7827717662 * mp - 0.8086757660 * sp;
|
|
120
|
+
const C = Math.sqrt(A * A + B * B);
|
|
121
|
+
|
|
122
|
+
if (L < DARK_FLOOR) continue; // shadows are not colors
|
|
123
|
+
|
|
124
|
+
if (C < CHROMATIC_THRESHOLD) { // near-grey -> neutral pool
|
|
125
|
+
neutralLsum += L;
|
|
126
|
+
neutralCount++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Hue in [0, 360). atan2 returns [-pi, pi].
|
|
131
|
+
let H = Math.atan2(B, A) * 180 / Math.PI;
|
|
132
|
+
if (H < 0) H += 360;
|
|
133
|
+
|
|
134
|
+
const bucketIdx = (H * HUE_BUCKETS / 360) | 0;
|
|
135
|
+
const bkt = _buckets[bucketIdx];
|
|
136
|
+
bkt.weight += C; // chroma-weighted, NOT pixel-count weighted
|
|
137
|
+
bkt.count++;
|
|
138
|
+
if (C > bkt.maxC) { // track the most-vivid pixel as the rep
|
|
139
|
+
bkt.maxC = C;
|
|
140
|
+
bkt.mcL = L;
|
|
141
|
+
bkt.mcH = H;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Filter occupied buckets into the sorted view. In-place sort by
|
|
146
|
+
// total chroma weight, descending.
|
|
147
|
+
let sortedLen = 0;
|
|
148
|
+
for (let i = 0; i < HUE_BUCKETS; i++) {
|
|
149
|
+
if (_buckets[i].count > 0) _sortedBuckets[sortedLen++] = _buckets[i];
|
|
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);
|
|
156
|
+
|
|
157
|
+
// Greedy pick respecting min hue separation. The separation rule is
|
|
158
|
+
// what surfaces cool subject colors from a warm-dominated photo.
|
|
159
|
+
const pickWithSeparation = (separation) => {
|
|
160
|
+
const picks = [];
|
|
161
|
+
for (let i = 0; i < sortedLen; i++) {
|
|
162
|
+
if (picks.length >= count) break;
|
|
163
|
+
const b = _sortedBuckets[i];
|
|
164
|
+
let tooClose = false;
|
|
165
|
+
for (const p of picks) {
|
|
166
|
+
const d = Math.abs(b.mcH - p.h) % 360;
|
|
167
|
+
if (Math.min(d, 360 - d) < separation) { tooClose = true; break; }
|
|
168
|
+
}
|
|
169
|
+
if (!tooClose) picks.push({ l: b.mcL, c: b.maxC, h: b.mcH });
|
|
170
|
+
}
|
|
171
|
+
return picks;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// Try the canonical 50 deg separation. If the image is genuinely low-
|
|
175
|
+
// diversity (monochrome, hue-collapsed), relax progressively.
|
|
176
|
+
let picks = pickWithSeparation(DEFAULT_MIN_HUE_SEP);
|
|
177
|
+
if (picks.length < count) {
|
|
178
|
+
for (const relaxed of RELAXATION_LADDER) {
|
|
179
|
+
const tried = pickWithSeparation(relaxed);
|
|
180
|
+
if (tried.length > picks.length) picks = tried;
|
|
181
|
+
if (picks.length >= count) break;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Append the neutral if there are pixels for it AND we have room.
|
|
186
|
+
// The neutral's hue is anchored to the first chromatic pick so any
|
|
187
|
+
// downstream gradient interpolation has a sensible path.
|
|
188
|
+
if (neutralCount > 0 && picks.length < count) {
|
|
189
|
+
picks.push({
|
|
190
|
+
l: neutralLsum / neutralCount,
|
|
191
|
+
c: NEUTRAL_CHROMA,
|
|
192
|
+
h: picks[0] ? picks[0].h : 240,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Lightness-ascending output -- natural dark->light gradient order.
|
|
197
|
+
picks.sort((a, b) => a.l - b.l);
|
|
198
|
+
return picks;
|
|
199
|
+
}
|
|
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
|
+
}
|