feat(importer): phase 5 — color correction sliders

Brightness, contrast, saturation, and gamma adjustments before palette
quantization. Pure-function applyColorCorrection in src/lib keeps the CLI
and browser importer in sync. Inserted post-resize so slider moves stay
responsive on large sources; precision loss is negligible through a
32-color palette.

UI is a collapsible "Color correction" section with four sliders, live
values, a "Reset" button, and an "on" badge when any value differs from
default. CLI gains --brightness, --contrast, --saturation, --gamma
(all -100..+100 except gamma which is 0.1..3.0).

9 unit tests pin down identity, saturate-to-0-or-255 clamping, greyscale
at saturation=-100, gamma midtone direction, and alpha preservation.
This commit is contained in:
tiennm99 committed 2026-04-17 11:56:35 +07:00
1 parent 4a5b6ef903
commit 6c35771445
6 files changed
+224 -9

No files matched your search

+65 -1
View File
@@ -3,6 +3,7 @@
import { rgbaToPalette, paletteToRgba, DITHER_METHODS } from '../../lib/image-to-palette.js';
import { resizeRgba } from '../../lib/image-resize.js';
import { transformRgba } from '../../lib/image-transform.js';
import { applyColorCorrection } from '../../lib/image-color-correction.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
@@ -41,6 +42,13 @@
let flipV = $state(false);
let rotation = $state(0); // 0 | 90 | 180 | 270
// Color correction
let brightness = $state(0); // -100..+100
let contrast = $state(0); // -100..+100
let saturation = $state(0); // -100..+100
let gamma = $state(1); // 0.1..3.0
let correctionOpen = $state(false); // collapsible
// Run state
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
let placed = $state(0);
@@ -97,7 +105,10 @@
const resized = (resizeW === transformed.width && resizeH === transformed.height)
? transformed.rgba
: resizeRgba(transformed.rgba, transformed.width, transformed.height, resizeW, resizeH, resampleMethod);
const idx = rgbaToPalette(resized, resizeW, resizeH, { method: ditherMethod });
const corrected = (brightness !== 0 || contrast !== 0 || saturation !== 0 || gamma !== 1)
? applyColorCorrection(resized, resizeW, resizeH, { brightness, contrast, saturation, gamma })
: resized;
const idx = rgbaToPalette(corrected, resizeW, resizeH, { method: ditherMethod });
paletteIndices = idx;
let count = 0;
for (let i = 0; i < idx.length; i++) if (idx[i] >= 0) count++;
@@ -160,6 +171,13 @@
}
}
function resetCorrection() {
brightness = 0;
contrast = 0;
saturation = 0;
gamma = 1;
}
$effect(() => { if (open && paletteIndices) renderPreview(); });
// Push overlay state to the canvas renderer whenever its inputs change.
@@ -354,6 +372,39 @@
</label>
</div>
<div class="section">
<button class="section-head" onclick={() => correctionOpen = !correctionOpen}>
{correctionOpen ? '▾' : '▸'} Color correction
{#if brightness || contrast || saturation || gamma !== 1}<span class="badge">on</span>{/if}
</button>
{#if correctionOpen}
<div class="slider-row">
<span class="lbl">brightness</span>
<input type="range" min="-100" max="100" step="1" bind:value={brightness} />
<span class="val">{brightness}</span>
</div>
<div class="slider-row">
<span class="lbl">contrast</span>
<input type="range" min="-100" max="100" step="1" bind:value={contrast} />
<span class="val">{contrast}</span>
</div>
<div class="slider-row">
<span class="lbl">saturation</span>
<input type="range" min="-100" max="100" step="1" bind:value={saturation} />
<span class="val">{saturation}</span>
</div>
<div class="slider-row">
<span class="lbl">gamma</span>
<input type="range" min="0.1" max="3" step="0.05" bind:value={gamma} />
<span class="val">{gamma.toFixed(2)}</span>
</div>
<div class="row">
<button onclick={resetCorrection}
disabled={!brightness && !contrast && !saturation && gamma === 1}>Reset</button>
</div>
{/if}
</div>
{#if total > 0}
<div class="progress">
<div class="bar"><div class="fill" style="width: {pct}%"></div></div>
@@ -430,6 +481,19 @@
.row button:disabled { opacity: 0.35; cursor: default; }
.row button.active { background: #2d4d78; border-color: #3b6ba8; color: #fff; }
.section { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid #2a2a2a; padding-top: 8px; }
.section-head {
display: flex; align-items: center; gap: 6px; padding: 2px 0;
background: transparent; border: 0; color: #ddd; cursor: pointer;
font-size: 0.9rem; text-align: left;
}
.section-head:hover { color: #fff; }
.badge { background: #2d4d78; color: #fff; font-size: 0.7rem; padding: 1px 6px; border-radius: 8px; }
.slider-row { display: flex; align-items: center; gap: 8px; }
.slider-row input[type="range"] { flex: 1; }
.slider-row .val { font-size: 0.8rem; color: #aaa; min-width: 34px; text-align: right; }
.file-btn {
display: inline-block; padding: 6px 12px; background: #2563eb; border-radius: 6px;
cursor: pointer; color: #fff; font-weight: 500;
+63
View File
@@ -0,0 +1,63 @@
/**
* Pure RGBA color correction: brightness, contrast, saturation, gamma.
*
* Order of operations: brightness → contrast → saturation → gamma.
* All input values are in "human" ranges; the function converts internally.
*
* Identity (defaults) returns a copy of the input so callers can safely mutate.
*/
/**
* @param {Uint8Array|Uint8ClampedArray|number[]} rgba
* @param {number} width
* @param {number} height
* @param {Object} [opts]
* @param {number} [opts.brightness=0] -100..+100
* @param {number} [opts.contrast=0] -100..+100
* @param {number} [opts.saturation=0] -100..+100 (-100 = greyscale, 0 = identity, +100 = 2× saturation)
* @param {number} [opts.gamma=1] (0.1..3.0 typical; 1 = identity)
* @returns {Uint8ClampedArray}
*/
export function applyColorCorrection(rgba, width, height, opts = {}) {
const { brightness = 0, contrast = 0, saturation = 0, gamma = 1 } = opts;
const out = new Uint8ClampedArray(rgba);
if (brightness === 0 && contrast === 0 && saturation === 0 && gamma === 1) return out;
const bOffset = brightness * 2.55;
const cFactor = (259 * (contrast + 255)) / (255 * (259 - contrast));
const sFactor = 1 + saturation / 100;
const applyGamma = gamma !== 1 && gamma > 0;
const gammaInv = applyGamma ? 1 / gamma : 1;
for (let i = 0; i < out.length; i += 4) {
let r = out[i], g = out[i + 1], b = out[i + 2];
if (brightness !== 0) {
r += bOffset; g += bOffset; b += bOffset;
}
if (contrast !== 0) {
r = cFactor * (r - 128) + 128;
g = cFactor * (g - 128) + 128;
b = cFactor * (b - 128) + 128;
}
if (saturation !== 0) {
// Rec. 601 luma
const lum = 0.299 * r + 0.587 * g + 0.114 * b;
r = lum + sFactor * (r - lum);
g = lum + sFactor * (g - lum);
b = lum + sFactor * (b - lum);
}
if (applyGamma) {
const rc = r < 0 ? 0 : r > 255 ? 255 : r;
const gc = g < 0 ? 0 : g > 255 ? 255 : g;
const bc = b < 0 ? 0 : b > 255 ? 255 : b;
r = Math.pow(rc / 255, gammaInv) * 255;
g = Math.pow(gc / 255, gammaInv) * 255;
b = Math.pow(bc / 255, gammaInv) * 255;
}
out[i] = r; out[i + 1] = g; out[i + 2] = b;
// alpha (out[i + 3]) passes through from the copy.
}
return out;
}