Skip to content

Commit 1bb809d

Browse files
committed
Fix lightness mismatch in palette grade and consolidate color utils
- Use HSL lightness (max+min)/2 in pixel loop to match LUT builder (was Rec.709 luma, causing saturated colors to grade too dark) - Extract rgbToHsl01, hslToRgb01, hue2rgb to color.ts as shared primitives; remove duplicates from canvas-filters.ts - Extract hasPaletteGrade() predicate for the repeated condition
1 parent eb8301c commit 1bb809d

3 files changed

Lines changed: 78 additions & 103 deletions

File tree

frontend/src/lib/components/wallpaper-editor/FilterControls.svelte

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import {
55
DEFAULT_FILTERS,
66
hasActiveCrop,
7+
hasPaletteGrade,
78
buildPaletteLUT,
89
type Filters,
910
} from '$lib/utils/canvas-filters';
@@ -129,9 +130,7 @@
129130
return canvas.toDataURL();
130131
});
131132
132-
let paletteGradeActive = $derived(
133-
filters.paletteStops.length >= 2 && filters.paletteStrength > 0
134-
);
133+
let paletteGradeActive = $derived(hasPaletteGrade(filters));
135134
136135
// Slider definitions per section
137136
const lightSliders: SliderDef[] = [

frontend/src/lib/utils/canvas-filters.ts

Lines changed: 21 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
* Works in all webkit2gtk versions.
44
*/
55

6+
import {hexToRgb, rgbToHsl01, hslToRgb01} from './color';
7+
68
export interface Filters {
79
blur: number;
810
brightness: number;
@@ -71,6 +73,10 @@ export function hasActiveCrop(f: Filters): boolean {
7173
return f.cropX !== 0 || f.cropY !== 0 || f.cropW !== 1 || f.cropH !== 1;
7274
}
7375

76+
export function hasPaletteGrade(f: Filters): boolean {
77+
return f.paletteStops.length >= 2 && f.paletteStrength > 0;
78+
}
79+
7480
export function applyFilters(
7581
img: HTMLImageElement,
7682
filters: Filters,
@@ -139,10 +145,9 @@ export function applyFilters(
139145

140146
// Build the palette-grade LUT once, if needed. Output depends only on source
141147
// luminance, so a 256-entry table captures the entire tone-map grade.
142-
const paletteLUT =
143-
filters.paletteStops.length >= 2 && filters.paletteStrength > 0
144-
? buildPaletteLUT(filters.paletteStops)
145-
: null;
148+
const paletteLUT = hasPaletteGrade(filters)
149+
? buildPaletteLUT(filters.paletteStops)
150+
: null;
146151

147152
// Single-pass per-pixel adjustments
148153
const needsPixel =
@@ -297,11 +302,12 @@ export function applyFilters(
297302
b += lift * (1 - b / 255);
298303
}
299304

300-
// Palette grade — map this pixel's luminance to the ramp color and
301-
// blend. The LUT already encodes "hue+sat from ramp, lightness from src",
302-
// so we just index by source luminance and lerp by strength.
305+
// Palette grade — LUT is indexed by HSL lightness (max+min)/2,
306+
// consistent with how the ramp was built.
303307
if (paletteLUT) {
304-
const y = (0.2126 * r + 0.7152 * g + 0.0722 * b) | 0;
308+
const cMax = r > g ? (r > b ? r : b) : g > b ? g : b;
309+
const cMin = r < g ? (r < b ? r : b) : g < b ? g : b;
310+
const y = ((cMax + cMin) / 2) | 0;
305311
const yi = y < 0 ? 0 : y > 255 ? 255 : y;
306312
const base = yi * 3;
307313
const t = paletteBlend;
@@ -506,7 +512,7 @@ export function hasActiveFilters(f: Filters): boolean {
506512
f.clarity > 0 ||
507513
f.posterize > 0 ||
508514
f.noise > 0 ||
509-
(f.paletteStops.length >= 2 && f.paletteStrength > 0) ||
515+
hasPaletteGrade(f) ||
510516
hasActiveCrop(f) ||
511517
f.resizeW !== 0 ||
512518
f.resizeH !== 0
@@ -537,9 +543,12 @@ export function buildPaletteLUT(stops: string[]): Uint8ClampedArray | null {
537543
// swatches in any order and the grade always runs shadow→highlight.
538544
const hsl: {h: number; s: number; l: number}[] = [];
539545
for (const hex of stops) {
540-
const parsed = parseHexRgb(hex);
541-
if (!parsed) return null;
542-
hsl.push(rgbToHsl01(parsed.r, parsed.g, parsed.b));
546+
const {r, g, b} = hexToRgb(hex);
547+
if (r === 0 && g === 0 && b === 0 && hex !== '#000000') {
548+
// hexToRgb returns {0,0,0} for invalid input
549+
return null;
550+
}
551+
hsl.push(rgbToHsl01(r, g, b));
543552
}
544553
hsl.sort((a, b) => a.l - b.l);
545554

@@ -569,60 +578,6 @@ export function buildPaletteLUT(stops: string[]): Uint8ClampedArray | null {
569578
return lut;
570579
}
571580

572-
function parseHexRgb(hex: string): {r: number; g: number; b: number} | null {
573-
if (!hex || typeof hex !== 'string') return null;
574-
const h = hex.startsWith('#') ? hex.slice(1) : hex;
575-
if (h.length !== 6) return null;
576-
const n = parseInt(h, 16);
577-
if (Number.isNaN(n)) return null;
578-
return {r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff};
579-
}
580-
581-
// RGB (0-255) → HSL (h,s,l in 0..1)
582-
function rgbToHsl01(
583-
r: number,
584-
g: number,
585-
b: number
586-
): {h: number; s: number; l: number} {
587-
const rn = r / 255,
588-
gn = g / 255,
589-
bn = b / 255;
590-
const max = Math.max(rn, gn, bn);
591-
const min = Math.min(rn, gn, bn);
592-
const l = (max + min) / 2;
593-
let h = 0;
594-
let s = 0;
595-
if (max !== min) {
596-
const d = max - min;
597-
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
598-
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
599-
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
600-
else h = ((rn - gn) / d + 4) / 6;
601-
}
602-
return {h, s, l};
603-
}
604-
605-
// HSL (0..1) → RGB (0..1)
606-
function hslToRgb01(h: number, s: number, l: number): [number, number, number] {
607-
if (s === 0) return [l, l, l];
608-
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
609-
const p = 2 * l - q;
610-
return [
611-
hue2rgb(p, q, h + 1 / 3),
612-
hue2rgb(p, q, h),
613-
hue2rgb(p, q, h - 1 / 3),
614-
];
615-
}
616-
617-
function hue2rgb(p: number, q: number, t: number): number {
618-
if (t < 0) t += 1;
619-
if (t > 1) t -= 1;
620-
if (t < 1 / 6) return p + (q - p) * 6 * t;
621-
if (t < 1 / 2) return q;
622-
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
623-
return p;
624-
}
625-
626581
// Interpolate hue (0..1) along the shortest arc around the wheel.
627582
function lerpHue(a: number, b: number, t: number): number {
628583
let d = b - a;

frontend/src/lib/utils/color.ts

Lines changed: 55 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -34,54 +34,75 @@ export function rgbToHex(r: number, g: number, b: number): string {
3434
);
3535
}
3636

37-
export function hexToHsl(hex: string): {h: number; s: number; l: number} {
38-
if (!hex || hex.length < 7) return {h: 0, s: 0, l: 50};
39-
const r = parseInt(hex.slice(1, 3), 16) / 255;
40-
const g = parseInt(hex.slice(3, 5), 16) / 255;
41-
const b = parseInt(hex.slice(5, 7), 16) / 255;
42-
const max = Math.max(r, g, b),
43-
min = Math.min(r, g, b);
44-
let h = 0,
45-
s = 0;
37+
// RGB (0-255) → HSL with h,s,l all in 0..1 range.
38+
// Shared primitive used by hexToHsl (scales to degrees/percent) and the
39+
// palette-grade LUT builder in canvas-filters.ts.
40+
export function rgbToHsl01(
41+
r: number,
42+
g: number,
43+
b: number
44+
): {h: number; s: number; l: number} {
45+
const rn = r / 255,
46+
gn = g / 255,
47+
bn = b / 255;
48+
const max = Math.max(rn, gn, bn);
49+
const min = Math.min(rn, gn, bn);
4650
const l = (max + min) / 2;
51+
let h = 0;
52+
let s = 0;
4753
if (max !== min) {
4854
const d = max - min;
4955
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
50-
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
51-
else if (max === g) h = ((b - r) / d + 2) / 6;
52-
else h = ((r - g) / d + 4) / 6;
56+
if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6;
57+
else if (max === gn) h = ((bn - rn) / d + 2) / 6;
58+
else h = ((rn - gn) / d + 4) / 6;
5359
}
60+
return {h, s, l};
61+
}
62+
63+
export function hexToHsl(hex: string): {h: number; s: number; l: number} {
64+
if (!hex || hex.length < 7) return {h: 0, s: 0, l: 50};
65+
const {h, s, l} = rgbToHsl01(
66+
parseInt(hex.slice(1, 3), 16),
67+
parseInt(hex.slice(3, 5), 16),
68+
parseInt(hex.slice(5, 7), 16)
69+
);
5470
return {h: h * 360, s: s * 100, l: l * 100};
5571
}
5672

73+
export function hue2rgb(p: number, q: number, t: number): number {
74+
if (t < 0) t += 1;
75+
if (t > 1) t -= 1;
76+
if (t < 1 / 6) return p + (q - p) * 6 * t;
77+
if (t < 1 / 2) return q;
78+
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
79+
return p;
80+
}
81+
82+
// HSL (all 0..1) → RGB (all 0..1).
83+
export function hslToRgb01(
84+
h: number,
85+
s: number,
86+
l: number
87+
): [number, number, number] {
88+
if (s === 0) return [l, l, l];
89+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
90+
const p = 2 * l - q;
91+
return [
92+
hue2rgb(p, q, h + 1 / 3),
93+
hue2rgb(p, q, h),
94+
hue2rgb(p, q, h - 1 / 3),
95+
];
96+
}
97+
5798
export function hslToHex(h: number, s: number, l: number): string {
5899
h = ((h % 360) + 360) % 360;
59-
h /= 360;
60-
s /= 100;
61-
l /= 100;
62-
let r, g, b;
63-
if (s === 0) {
64-
r = g = b = l;
65-
} else {
66-
const hue2rgb = (p: number, q: number, t: number) => {
67-
if (t < 0) t += 1;
68-
if (t > 1) t -= 1;
69-
if (t < 1 / 6) return p + (q - p) * 6 * t;
70-
if (t < 1 / 2) return q;
71-
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
72-
return p;
73-
};
74-
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
75-
const p = 2 * l - q;
76-
r = hue2rgb(p, q, h + 1 / 3);
77-
g = hue2rgb(p, q, h);
78-
b = hue2rgb(p, q, h - 1 / 3);
79-
}
100+
const [r, g, b] = hslToRgb01(h / 360, s / 100, l / 100);
80101
const toHex = (n: number) => {
81102
const hex = Math.round(n * 255).toString(16);
82103
return hex.length === 1 ? '0' + hex : hex;
83104
};
84-
return `#${toHex(r!)}${toHex(g!)}${toHex(b!)}`;
105+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
85106
}
86107

87108
export function copyColor(hex: string): void {

0 commit comments

Comments
 (0)