@zakkster/lite-gradient-studio 1.0.0 → 1.1.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.
@@ -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 -> I should see blue in the palette."
14
+ * shirt I should see blue in the palette."
15
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
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 -- the matrix path is
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) -- shadows, not
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 -- accumulated L average becomes the single representative
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 deg each).
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 -- picking the most-vivid avoids muddy
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 deg, progressively relax to 30 / 15 / 0.
39
- * 6. Sort the final picks by L for natural dark->light gradient order.
38
+ * enough slots at 50°, progressively relax to 30° / 15° / 0°.
39
+ * 6. Sort the final picks by L for natural darklight 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 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.
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 deg per bucket
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 deg underfills
63
- const CHROMATIC_THRESHOLD = 0.02; // C below this -> neutral pool
64
- const DARK_FLOOR = 0.10; // L below this -> skip (shadow)
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
- // Reset module-scope state. Doing this once per call beats allocating
96
- // 24 fresh objects every call.
97
- resetBuckets();
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 -> OKLCH inline to keep
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 (Bjorn Ottosson, 2020).
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 -> linear sRGB.
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 -> LMS' (cube-rooted) -> OKLab.
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 -> neutral pool
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 [-pi, pi].
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 = _buckets[bucketIdx];
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
- // 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);
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 (let i = 0; i < sortedLen; i++) {
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 deg separation. If the image is genuinely low-
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 -- natural dark->light gradient order.
171
+ // Lightness-ascending output natural darklight 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
- }