@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.
- package/CHANGELOG.md +167 -16
- package/README.md +86 -38
- package/llms.txt +69 -26
- package/package.json +16 -32
- package/src/color-convert.js +64 -108
- package/src/exporters.js +23 -2
- package/src/index.d.ts +39 -0
- package/src/index.js +1 -1
- package/src/mesh.js +155 -15
- package/src/palette-extract.js +39 -81
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
|
-
}
|