@zakkster/lite-color-engine 1.3.0 → 1.5.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 +202 -0
- package/README.md +465 -68
- package/index.d.ts +110 -5
- package/index.js +8 -2
- package/llms.txt +38 -7
- package/package.json +4 -3
- package/src/Gamut.d.ts +18 -0
- package/src/Gamut.js +33 -0
- package/src/authoring.js +60 -3
- package/src/lut.js +37 -16
- package/src/runtime.js +297 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,207 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.5.0] - 2026-07-13
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Blue-noise dither kernels** — shared kernel for the whole ecosystem
|
|
8
|
+
(studio, hueforge, gradient consumers) so there is exactly one
|
|
9
|
+
implementation to reason about:
|
|
10
|
+
- `getBlueNoise64()` — returns a shared, read-only `Uint8Array(4096)`
|
|
11
|
+
holding a 64x64 void-and-cluster blue-noise tile. Lazily decoded from
|
|
12
|
+
an inlined base64 blob on first call (sub-millisecond); subsequent
|
|
13
|
+
calls return the same reference. Histogram is exactly uniform (each
|
|
14
|
+
byte 0..255 appears 16 times). Index via
|
|
15
|
+
`tile[((y & 63) << 6) | (x & 63)]`; the tile is torus-tileable.
|
|
16
|
+
Generator committed as `tools/generate-bluenoise.mjs` with a fixed
|
|
17
|
+
seed. The tile is gated on its *spectral* properties, not just its
|
|
18
|
+
fingerprint — see `test/bluenoise-spectral.test.js`.
|
|
19
|
+
- `packOklchBufferToUint32Dithered(buf, off, alpha = 1.0, noise01 = 0.5)`
|
|
20
|
+
— scalar dithered packer. Threshold offset applied in gamma-encoded space, with
|
|
21
|
+
the same `noise01` shared across R, G, B (luminance-patterned dither,
|
|
22
|
+
no chroma speckle). Alpha is undithered. Parity guarantee:
|
|
23
|
+
`noise01 === 0.5` reproduces the byte output of
|
|
24
|
+
`packOklchBufferToUint32` bit-for-bit.
|
|
25
|
+
- `packOklchBufferToUint32IntoNDithered(src, offSrc, dst, offDst, n,
|
|
26
|
+
alpha, noiseTile, x0, y0, rowWidth)` — batch variant that walks the
|
|
27
|
+
tile in row-major order over an arbitrary destination rectangle. Zero
|
|
28
|
+
allocations; no modulo/division in the hot loop.
|
|
29
|
+
|
|
30
|
+
**Honest note on `useLut`:** the batch dithered packer does *not* accept
|
|
31
|
+
the 4k transfer LUT flag. `SRGB_LUT` stores rounded bytes, which
|
|
32
|
+
discards the sub-integer information the dither threshold shift needs;
|
|
33
|
+
enabling it would silently collapse dither to plain rounding. A
|
|
34
|
+
companion `Float32Array` LUT of encoded floats (~16 KB, lazy) is a
|
|
35
|
+
v1.6 candidate that would restore this axis.
|
|
36
|
+
|
|
37
|
+
- **Batch P3 packer:** `packOklchBufferToUint32P3IntoN(src, offSrc, dst,
|
|
38
|
+
offDst, n, alpha?, useLut?)` — P3 sibling of the v1.3 sRGB batch. The
|
|
39
|
+
P3 transfer function is identical to sRGB (IEC 61966-2-1), so the same
|
|
40
|
+
`SRGB_LUT` is reused when `useLut=true` — no separate 4 KB table is
|
|
41
|
+
allocated. Same accuracy story as the sRGB LUT path (within 1 LSB of
|
|
42
|
+
exact) and same fast-packer throughput class.
|
|
43
|
+
|
|
44
|
+
- **Batch MINDE:** `gamutMapToSrgbBufferN(inBuf, off, outBuf, offOut, n)`
|
|
45
|
+
(on the `./gamut` subpath) — batch sibling of the scalar MINDE mapper.
|
|
46
|
+
Bit-for-bit identical to `n` scalar calls; the batch amortizes call
|
|
47
|
+
overhead for authoring / LUT-building large color runs (hueforge P3
|
|
48
|
+
exporters, studio bake paths) that today loop the scalar. Setup-time /
|
|
49
|
+
bake-time convenience — MINDE stays out of the per-frame hot path.
|
|
50
|
+
|
|
51
|
+
- **Cyclic LUT bake:** `bakeGradientToUint32` gained a trailing `opts`
|
|
52
|
+
parameter with `{ closed: true }`. In closed mode, stops are treated
|
|
53
|
+
cyclically (last stop wraps back to first), sample positions become
|
|
54
|
+
`i / resolution` (period spacing, no duplicated endpoint), and
|
|
55
|
+
`easeFn` outputs are period-wrapped via `t - floor(t)` instead of
|
|
56
|
+
clamped. Pairs with `sampleColorLUTWrapped` on the consumer side. The
|
|
57
|
+
seam continuity is asserted: `|lut[res-1] - lut[0]|` per channel is
|
|
58
|
+
bounded by the maximum adjacent-cell interior delta.
|
|
59
|
+
|
|
60
|
+
- **Preflight gate (stale-base cure):** `npm run preflight` fetches the
|
|
61
|
+
latest published tarball via `npm pack` and hash-compares every file
|
|
62
|
+
in `files[]` against the working tree. Hard-fails on code drift
|
|
63
|
+
(`index.js`, `index.d.ts`, `src/**`, `package.json`); warns for doc
|
|
64
|
+
drift (`README.md`, `CHANGELOG.md`, `LICENSE.md`, `llms.txt`). This is
|
|
65
|
+
the structural cure for the v1.3/v1.4 regressions that started from an
|
|
66
|
+
unrebased working tree. Session protocol: run preflight before feature
|
|
67
|
+
work.
|
|
68
|
+
|
|
69
|
+
### Fixed (pre-release, against the 1.5.0 release candidate)
|
|
70
|
+
|
|
71
|
+
- **Blue-noise tile: Phase 3 of the void-and-cluster generator was
|
|
72
|
+
inverted.** The RC selected the 0-cell with the *highest* filtered
|
|
73
|
+
ones-field — the cell most surrounded by 1s, which is an **isolated**
|
|
74
|
+
zero (the largest void of the complement), not its tightest cluster.
|
|
75
|
+
The correct selector is the *minimum*: because the filter is toroidal
|
|
76
|
+
and constant-sum, `zerosField = sum(K) - onesField`, so
|
|
77
|
+
`argmax(zerosField) === argmin(onesField)` and Phase 3 reduces to the
|
|
78
|
+
same `findLargestVoid()` call as Phase 2.
|
|
79
|
+
|
|
80
|
+
Effect: ranks below 2048 were unaffected, so every threshold pattern at
|
|
81
|
+
or below mid-gray was correct — and every threshold pattern **above**
|
|
82
|
+
it was 5–7x over-clustered. Dithering was sound in the shadows and
|
|
83
|
+
broken in the highlights, which is exactly where a dither earns its
|
|
84
|
+
keep. Radial power at `r=1` was 2.05 against a white-noise floor of
|
|
85
|
+
0.083: at the tile period the RC tile was *worse than random*.
|
|
86
|
+
|
|
87
|
+
The tile fingerprint therefore rotates:
|
|
88
|
+
`78926ec1…` → `8867ddb65e16379ad42244fa24b82dcbd813c684ce14944f86a9e3e8f6b72968`.
|
|
89
|
+
Same seed (`0xc0ffee1e`), same generator, one comparator.
|
|
90
|
+
|
|
91
|
+
- **The RC gates could not see any of it.** All 17 tests in
|
|
92
|
+
`dither.test.js` passed the defective tile. A uniform histogram is
|
|
93
|
+
invariant under *any* permutation of ranks (a linear ramp satisfies
|
|
94
|
+
it); the SHA-256 assertion pins the artifact rather than validating it,
|
|
95
|
+
and was cementing the defect into the contract; and "dithered
|
|
96
|
+
run-length halved vs undithered" is satisfied by white noise too — it
|
|
97
|
+
tests *that* there is noise, not that the noise is blue. The
|
|
98
|
+
generator's own self-check measured field variance at `T=128` **only**,
|
|
99
|
+
which is precisely the one threshold an inverted Phase 3 cannot affect,
|
|
100
|
+
and it reported an identical healthy 0.5133 for the broken and the
|
|
101
|
+
fixed tile.
|
|
102
|
+
|
|
103
|
+
New `test/bluenoise-spectral.test.js` gates the property the feature is
|
|
104
|
+
named after: minority-phase clumpiness under 0.70 across `T=16..240`,
|
|
105
|
+
that curve symmetric about `T=128` (the assertion that fails the RC
|
|
106
|
+
first), radial power for `r<=4` below the white-noise floor, high-band
|
|
107
|
+
power above it, and wrap-edge decorrelation within 82% of interior. The
|
|
108
|
+
generator now runs the same sweep as a hard gate and refuses to emit a
|
|
109
|
+
tile that fails it.
|
|
110
|
+
|
|
111
|
+
- `packOklchBufferToUint32Dithered` had no parameter defaults while every
|
|
112
|
+
sibling packer defaults `alpha = 1.0`. Calling it with the plain
|
|
113
|
+
packer's arity returned `0x00000000` — silent transparent black. It now
|
|
114
|
+
defaults `alpha = 1.0, noise01 = 0.5`, so a 2-argument call is exactly
|
|
115
|
+
`packOklchBufferToUint32`.
|
|
116
|
+
|
|
117
|
+
- **The sRGB transfer function had 8 copies in `src/runtime.js`** — 6 of them
|
|
118
|
+
added by the F1 dither work, which inlined the encode per channel in both
|
|
119
|
+
dithered packers. They now derive from a single `srgbEncode(c)` helper, which
|
|
120
|
+
`SRGB_LUT` and `linearToSrgbByte` also route through: one definition of the
|
|
121
|
+
IEC 61966-2-1 constants in the module. The dither packers genuinely cannot use
|
|
122
|
+
`linearToSrgbByte` (it rounds) or `SRGB_LUT` (it stores rounded bytes) — they
|
|
123
|
+
need the sub-integer encoded value — but that argued for extracting the shared
|
|
124
|
+
step, not for copying it six times. The real exposure was the
|
|
125
|
+
`noise01 === 0.5` parity guarantee: it holds only while the dither path and
|
|
126
|
+
the plain path agree on the encode, and nothing structural was keeping them in
|
|
127
|
+
step. Now they are the same function, and the parity test guards it.
|
|
128
|
+
|
|
129
|
+
Measured, since "the duplication forces V8 to monomorphize the loop" is a
|
|
130
|
+
testable claim: interleaved A/B, 40 rounds, 12 primer passes, N=100k. The
|
|
131
|
+
helper form came in at 0.9978x / 0.9981x / 0.9967x / 0.9983x of the inlined
|
|
132
|
+
form on the dithered batch, and within +/-0.3% on the scalar. V8 inlines it
|
|
133
|
+
outright. The duplication bought nothing.
|
|
134
|
+
|
|
135
|
+
`src/Gamut.js` and `src/Remap.js` keep their own single copies **on purpose**.
|
|
136
|
+
Both modules currently have zero imports, and `Gamut.js` is a subpath export
|
|
137
|
+
(`./gamut`); making it import from `runtime.js` would drag the module-level
|
|
138
|
+
`SRGB_LUT` build and the 5.4 KB blue-noise blob into the gamut consumer's
|
|
139
|
+
graph. One duplicated line each is the cheaper trade. A shared `src/transfer.js`
|
|
140
|
+
would collapse the last two without that cost, if it is ever worth a file.
|
|
141
|
+
|
|
142
|
+
- `getBlueNoise64()` read `Buffer` as a free identifier. Webpack 4 and
|
|
143
|
+
browserify pattern-match that token and inject the ~20 KB buffer
|
|
144
|
+
polyfill into browser bundles — for a package whose pitch is a
|
|
145
|
+
single zero-dependency file. Now reads `globalThis.Buffer`, which is
|
|
146
|
+
not matched; browser builds stay on the `atob` path.
|
|
147
|
+
|
|
148
|
+
### Demo
|
|
149
|
+
|
|
150
|
+
- `demo/index.html` particle cull guard rewritten from `(out of range)` to
|
|
151
|
+
`!(in range)`. Identical for every finite value and the same four compares —
|
|
152
|
+
but NaN makes every comparison false, so the old form let a NaN coordinate
|
|
153
|
+
through to `(pY[i] | 0) * w + (pX[i] | 0)`, where `NaN | 0` is `0` and the
|
|
154
|
+
particle silently paints pixel (0,0) forever. Not reachable today (the `+ 1`
|
|
155
|
+
in `d2` rules out the divide-by-zero and `F_DAMP` bounds the integration);
|
|
156
|
+
this is a free guard against a future edit to the force math.
|
|
157
|
+
|
|
158
|
+
### Known / deferred
|
|
159
|
+
|
|
160
|
+
- **Dither DC bias.** `noise01 = byte / 256` has mean `127.5/256 =
|
|
161
|
+
0.498046875`, not `0.5`, so `floor(v + noise)` darkens by a constant
|
|
162
|
+
`-1/512` LSB on every pixel. It is deterministic, not noise, and
|
|
163
|
+
invisible (0.2% of one code value) — but it is a bias, and the JSDoc
|
|
164
|
+
leans on "the mean and variance of a uniform distribution" as if that
|
|
165
|
+
mean were 0.5. The fix, `(byte + 0.5) / 256`, would centre it exactly —
|
|
166
|
+
at the cost of the `noise01 === 0.5` parity guarantee, since no byte
|
|
167
|
+
maps to 127.5. Kept as-is for v1.5; the parity property is worth more
|
|
168
|
+
than 1/512 of a code value. Revisit alongside the v1.6 float-LUT.
|
|
169
|
+
|
|
170
|
+
### Notes
|
|
171
|
+
|
|
172
|
+
- No breaking changes. All v1.4 exports (batch kernels, 4k LUT, Display
|
|
173
|
+
P3, CSS formatters, `sampleColorLUT`) are retained with identical
|
|
174
|
+
byte-level output.
|
|
175
|
+
- Existing open-mode `bakeGradientToUint32` calls are byte-identical to
|
|
176
|
+
v1.4 (verified by regression tests) — closed mode is opt-in via the
|
|
177
|
+
new trailing `opts` argument.
|
|
178
|
+
- Zero-GC audit: every new hot-path function is allocation-free after
|
|
179
|
+
the one-time lazy noise decode.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## [1.4.0] - 2026-07-10
|
|
184
|
+
|
|
185
|
+
### Added
|
|
186
|
+
- **CSS formatters (round-trip emit):**
|
|
187
|
+
- `formatOklchCss(buf, off, alpha?)` - emits `oklch(L% C H)` (or `.../ alpha` when
|
|
188
|
+
alpha is supplied and < 1). Round-trips through `parseCSSColor`.
|
|
189
|
+
- `formatHex(buf, off)` - emits `#rrggbb` via the accurate sRGB pack path.
|
|
190
|
+
Round-trips with `parseHexToBuffer` to within 1 LSB per channel.
|
|
191
|
+
- Both are authoring-time helpers (they allocate a string) and are not for
|
|
192
|
+
per-frame hot paths.
|
|
193
|
+
|
|
194
|
+
### Fixed
|
|
195
|
+
- Named-color table: `darkslateggrey` -> `darkslategrey` and `navajawhite` ->
|
|
196
|
+
`navajowhite` (previously unreachable typo keys), and `lightsteelblue` now
|
|
197
|
+
returns its correct value `#b0c4de` instead of powderblue's `#b0e0e6`.
|
|
198
|
+
|
|
199
|
+
### Notes
|
|
200
|
+
- No breaking changes; all v1.3 exports (batch kernels, 4k LUT, Display P3,
|
|
201
|
+
`sampleColorLUT`) are retained.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
3
205
|
## [1.3.0] - 2026-07-10
|
|
4
206
|
|
|
5
207
|
### Added
|