@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.
@@ -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
+ }