Skip to content

Commit eb8301c

Browse files
committed
Add palette-grade LUT to image editor and fix compare sizing
- Pick theme colors + adjust strength to color-grade images using the current colors.toml palette (luminance-preserving HSL colorize) - Auto-sort stops by luminance, render real LUT preview strip - Fix original/preview size mismatch by pinning width/height attributes - Click-hold on image to compare, rename button to Apply
1 parent d789af0 commit eb8301c

3 files changed

Lines changed: 396 additions & 16 deletions

File tree

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

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,13 @@
44
import {
55
DEFAULT_FILTERS,
66
hasActiveCrop,
7+
buildPaletteLUT,
78
type Filters,
89
} from '$lib/utils/canvas-filters';
10+
import {getPalette} from '$lib/stores/theme.svelte';
11+
import {isLightColor} from '$lib/utils/color';
12+
13+
const MAX_PALETTE_STOPS = 4;
914
1015
type SliderDef = {
1116
key: string;
@@ -46,9 +51,88 @@
4651
// Section expanded states
4752
let lightExpanded = $state(true);
4853
let colorExpanded = $state(false);
54+
let paletteExpanded = $state(false);
4955
let detailExpanded = $state(false);
5056
let presetsExpanded = $state(false);
5157
58+
// Current theme palette — the colors.toml turned into pickable LUT stops.
59+
// Filter out duplicates and empties so the picker only shows distinct colors.
60+
let paletteColors = $derived.by(() => {
61+
const seen = new Set<string>();
62+
const out: string[] = [];
63+
for (const c of getPalette()) {
64+
if (!c || !c.startsWith('#') || c.length < 7) continue;
65+
const key = c.toLowerCase();
66+
if (seen.has(key)) continue;
67+
seen.add(key);
68+
out.push(c);
69+
}
70+
return out;
71+
});
72+
73+
function togglePaletteStop(hex: string) {
74+
const stops = filters.paletteStops;
75+
const idx = stops.findIndex(s => s.toLowerCase() === hex.toLowerCase());
76+
let next: string[];
77+
if (idx >= 0) {
78+
next = stops.filter((_, i) => i !== idx);
79+
} else if (stops.length >= MAX_PALETTE_STOPS) {
80+
return; // cap reached
81+
} else {
82+
next = [...stops, hex];
83+
}
84+
filters = {...filters, paletteStops: next};
85+
debouncedPreview();
86+
}
87+
88+
function clearPaletteStops() {
89+
filters = {
90+
...filters,
91+
paletteStops: [],
92+
paletteStrength: DEFAULT_FILTERS.paletteStrength,
93+
};
94+
debouncedPreview();
95+
}
96+
97+
function setPaletteStrength(v: number) {
98+
filters = {...filters, paletteStrength: v};
99+
debouncedPreview();
100+
}
101+
102+
function stopIndex(hex: string): number {
103+
return filters.paletteStops.findIndex(
104+
s => s.toLowerCase() === hex.toLowerCase()
105+
);
106+
}
107+
108+
// Render the actual effective LUT as a data-URL strip, so the preview bar
109+
// shows exactly what will be applied to the image (HSL-space colorize with
110+
// luminance pinned to source) — not a naive straight-RGB gradient between
111+
// stops, which would misrepresent the grade.
112+
let rampPreviewUrl = $derived.by(() => {
113+
if (filters.paletteStops.length < 2) return '';
114+
const lut = buildPaletteLUT(filters.paletteStops);
115+
if (!lut) return '';
116+
const canvas = document.createElement('canvas');
117+
canvas.width = 256;
118+
canvas.height = 1;
119+
const ctx = canvas.getContext('2d');
120+
if (!ctx) return '';
121+
const img = ctx.createImageData(256, 1);
122+
for (let i = 0; i < 256; i++) {
123+
img.data[i * 4] = lut[i * 3];
124+
img.data[i * 4 + 1] = lut[i * 3 + 1];
125+
img.data[i * 4 + 2] = lut[i * 3 + 2];
126+
img.data[i * 4 + 3] = 255;
127+
}
128+
ctx.putImageData(img, 0, 0);
129+
return canvas.toDataURL();
130+
});
131+
132+
let paletteGradeActive = $derived(
133+
filters.paletteStops.length >= 2 && filters.paletteStrength > 0
134+
);
135+
52136
// Slider definitions per section
53137
const lightSliders: SliderDef[] = [
54138
{key: 'brightness', label: 'Brightness', min: 0, max: 200, step: 1},
@@ -370,6 +454,149 @@
370454
</ExpandableSection>
371455
</section>
372456

457+
<section class="border-b border-[rgba(255,255,255,0.06)] p-3">
458+
<ExpandableSection
459+
title="Palette Grade"
460+
bind:expanded={paletteExpanded}
461+
suffix={paletteGradeActive ? ' \u2022' : ''}
462+
>
463+
<div class="space-y-3 pt-1">
464+
<p class="text-fg-dimmed text-[10px] leading-relaxed">
465+
Click up to {MAX_PALETTE_STOPS} theme colors to build a tone
466+
ramp (shadows → highlights). Preserves original brightness;
467+
only repaints hue and saturation.
468+
</p>
469+
470+
<!-- Palette swatch grid -->
471+
<div class="grid grid-cols-8 gap-1">
472+
{#each paletteColors as hex}
473+
{@const idx = stopIndex(hex)}
474+
{@const picked = idx >= 0}
475+
{@const capped =
476+
!picked &&
477+
filters.paletteStops.length >=
478+
MAX_PALETTE_STOPS}
479+
<button
480+
type="button"
481+
class="relative aspect-square border transition-all duration-100
482+
{picked
483+
? 'border-accent ring-accent/40 ring-1'
484+
: 'border-[rgba(255,255,255,0.08)] hover:border-[rgba(255,255,255,0.25)]'}
485+
{capped ? 'opacity-30' : ''}"
486+
style="background-color: {hex}"
487+
title={picked
488+
? `Stop ${idx + 1} · ${hex} — click to remove`
489+
: capped
490+
? `Max ${MAX_PALETTE_STOPS} stops`
491+
: `${hex} — click to add as stop`}
492+
disabled={capped}
493+
onclick={() => togglePaletteStop(hex)}
494+
>
495+
{#if picked}
496+
<span
497+
class="absolute inset-0 flex items-center justify-center text-[9px] font-bold tabular-nums"
498+
style="color: {isLightColor(hex)
499+
? '#000'
500+
: '#fff'}">{idx + 1}</span
501+
>
502+
{/if}
503+
</button>
504+
{/each}
505+
</div>
506+
507+
<!-- Ramp preview — shows the actual LUT (what each source
508+
luminance maps to), not a naive stop-to-stop RGB gradient.
509+
Auto-sorted by luminance, so shadows are on the left. -->
510+
<div class="space-y-1.5">
511+
<div class="flex items-center justify-between">
512+
<span class="text-fg-dimmed text-[10px]">
513+
{filters.paletteStops.length === 0
514+
? 'No stops selected'
515+
: filters.paletteStops.length === 1
516+
? '1 stop — pick one more'
517+
: `${filters.paletteStops.length} stops · auto-sorted`}
518+
</span>
519+
{#if filters.paletteStops.length > 0}
520+
<button
521+
type="button"
522+
class="text-fg-dimmed hover:text-fg-secondary text-[10px] transition-colors"
523+
onclick={clearPaletteStops}>Clear</button
524+
>
525+
{/if}
526+
</div>
527+
{#if filters.paletteStops.length >= 2 && rampPreviewUrl}
528+
<img
529+
src={rampPreviewUrl}
530+
alt="Effective LUT"
531+
class="h-6 w-full border border-[rgba(255,255,255,0.08)]"
532+
style="image-rendering: pixelated; object-fit: fill;"
533+
/>
534+
{:else}
535+
<div
536+
class="h-6 border border-[rgba(255,255,255,0.08)]"
537+
style={filters.paletteStops.length === 1
538+
? `background: ${filters.paletteStops[0]}`
539+
: 'background: repeating-linear-gradient(45deg, rgba(255,255,255,0.04) 0 4px, transparent 4px 8px)'}
540+
></div>
541+
{/if}
542+
<div
543+
class="text-fg-dimmed flex justify-between text-[9px]"
544+
>
545+
<span>Shadows</span>
546+
<span>Midtones</span>
547+
<span>Highlights</span>
548+
</div>
549+
</div>
550+
551+
<!-- Strength slider (only matters once a ramp is valid) -->
552+
<div class="space-y-1.5">
553+
<div class="flex items-center justify-between">
554+
<span class="text-fg-secondary text-[11px]"
555+
>Strength</span
556+
>
557+
<!-- svelte-ignore a11y_no_static_element_interactions -->
558+
<span
559+
class="min-w-[36px] cursor-pointer text-right font-mono text-[11px] tabular-nums
560+
{filters.paletteStrength !==
561+
DEFAULT_FILTERS.paletteStrength
562+
? 'text-fg-primary'
563+
: 'text-fg-dimmed'}"
564+
role="button"
565+
tabindex="-1"
566+
ondblclick={() =>
567+
setPaletteStrength(
568+
DEFAULT_FILTERS.paletteStrength
569+
)}
570+
title="Double-click to reset"
571+
>{filters.paletteStrength}</span
572+
>
573+
</div>
574+
<input
575+
type="range"
576+
class="w-full cursor-pointer disabled:opacity-40"
577+
min="0"
578+
max="100"
579+
step="1"
580+
disabled={filters.paletteStops.length < 2}
581+
value={filters.paletteStrength}
582+
oninput={e =>
583+
setPaletteStrength(
584+
parseFloat(e.currentTarget.value)
585+
)}
586+
ondblclick={() =>
587+
setPaletteStrength(
588+
DEFAULT_FILTERS.paletteStrength
589+
)}
590+
/>
591+
<p class="text-fg-dimmed text-[9px] leading-snug">
592+
≤ 50% reads as a tint · ≥ 60% rebuilds the image in
593+
the palette.
594+
</p>
595+
</div>
596+
</div>
597+
</ExpandableSection>
598+
</section>
599+
373600
<section class="border-b border-[rgba(255,255,255,0.06)] p-3">
374601
<ExpandableSection
375602
title="Detail"

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

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -165,17 +165,23 @@
165165
class="bg-accent hover:bg-accent-hover px-5 py-1.5 text-[11px] font-medium text-[#111116] transition-colors disabled:opacity-40"
166166
onclick={handleApply}
167167
disabled={isProcessing || !hasChanges}
168-
>{isProcessing ? 'Exporting...' : 'Apply & Extract'}</button
168+
>{isProcessing ? 'Exporting...' : 'Apply'}</button
169169
>
170170
</div>
171171
</div>
172172

173173
<!-- Main content -->
174174
<div class="flex flex-1 overflow-hidden">
175175
<div class="flex flex-1 flex-col bg-[#080809]">
176+
<!-- svelte-ignore a11y_no_static_element_interactions -->
176177
<div
177178
class="relative flex flex-1 items-center justify-center overflow-hidden p-6"
178179
bind:this={previewAreaEl}
180+
onmousedown={() => {
181+
if (!cropMode && hasChanges) showOriginal = true;
182+
}}
183+
onmouseup={() => (showOriginal = false)}
184+
onmouseleave={() => (showOriginal = false)}
179185
>
180186
{#if originalUrl && isVideo}
181187
<!-- svelte-ignore a11y_media_has_caption -->
@@ -197,15 +203,20 @@
197203
onload={handleImageLoad}
198204
/>
199205

200-
<!-- Visible preview -->
206+
<!-- Single image — width/height pinned to original's
207+
natural dimensions so the element computes the same
208+
display size regardless of which src is loaded -->
201209
<img
202210
bind:this={displayImgEl}
203211
src={showOriginal || !previewUrl
204212
? originalUrl
205213
: previewUrl}
206214
alt="Preview"
207-
class="max-h-full max-w-full object-contain"
215+
width={naturalWidth || undefined}
216+
height={naturalHeight || undefined}
217+
class="max-h-full max-w-full select-none object-contain"
208218
style="filter: drop-shadow(0 4px 24px rgba(0,0,0,0.5))"
219+
draggable="false"
209220
/>
210221

211222
<!-- Crop overlay -->
@@ -244,21 +255,14 @@
244255
<div
245256
class="flex items-center justify-between border-t border-[rgba(255,255,255,0.06)] bg-[#0c0c10] px-5 py-2"
246257
>
247-
<button
248-
class="text-fg-dimmed hover:text-fg-secondary border border-[rgba(255,255,255,0.06)] px-3 py-1 text-[10px] transition-colors"
249-
onmousedown={() => (showOriginal = true)}
250-
onmouseup={() => (showOriginal = false)}
251-
onmouseleave={() => (showOriginal = false)}
252-
>{showOriginal
253-
? 'Showing Original'
254-
: 'Hold to Compare'}</button
255-
>
256-
257258
<span class="text-fg-dimmed text-[10px]">
258-
{#if cropMode}
259+
{#if showOriginal}
260+
Showing Original
261+
{:else if cropMode}
259262
Drag corners to crop · Drag inside to move
260263
{:else}
261-
Live preview · Double-click slider to reset
264+
Click & hold image to compare · Double-click slider
265+
to reset
262266
{/if}
263267
</span>
264268
</div>

0 commit comments

Comments
 (0)