@@ -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;
diff --git a/src/lib/image-color-correction.js b/src/lib/image-color-correction.js
new file mode 100644
index 0000000..8b301fe
--- /dev/null
+++ b/src/lib/image-color-correction.js
@@ -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;
+}
diff --git a/test/lib/image-color-correction.test.js b/test/lib/image-color-correction.test.js
new file mode 100644
index 0000000..a92edba
--- /dev/null
+++ b/test/lib/image-color-correction.test.js
@@ -0,0 +1,74 @@
+import { describe, it, expect } from 'vitest';
+import { applyColorCorrection } from '../../src/lib/image-color-correction.js';
+
+function solid(w, h, r, g, b, a = 255) {
+ const out = new Uint8ClampedArray(w * h * 4);
+ for (let i = 0; i < w * h; i++) {
+ out[i * 4] = r; out[i * 4 + 1] = g; out[i * 4 + 2] = b; out[i * 4 + 3] = a;
+ }
+ return out;
+}
+
+describe('applyColorCorrection', () => {
+ it('identity (all defaults) returns a copy of the input', () => {
+ const src = solid(2, 2, 100, 150, 200, 220);
+ const out = applyColorCorrection(src, 2, 2);
+ expect(out).toEqual(src);
+ expect(out).not.toBe(src);
+ });
+
+ it('brightness +100 clamps grey pixels to 255', () => {
+ const src = solid(1, 1, 128, 128, 128);
+ const out = applyColorCorrection(src, 1, 1, { brightness: 100 });
+ // 128 + 255 = 383, clamped to 255
+ expect([out[0], out[1], out[2]]).toEqual([255, 255, 255]);
+ });
+
+ it('brightness -100 clamps grey pixels to 0', () => {
+ const src = solid(1, 1, 128, 128, 128);
+ const out = applyColorCorrection(src, 1, 1, { brightness: -100 });
+ expect([out[0], out[1], out[2]]).toEqual([0, 0, 0]);
+ });
+
+ it('contrast +100 pushes dark darker and bright brighter', () => {
+ const dark = solid(1, 1, 50, 50, 50);
+ const bright = solid(1, 1, 200, 200, 200);
+ const dOut = applyColorCorrection(dark, 1, 1, { contrast: 100 });
+ const bOut = applyColorCorrection(bright, 1, 1, { contrast: 100 });
+ expect(dOut[0]).toBeLessThan(50);
+ expect(bOut[0]).toBeGreaterThan(200);
+ });
+
+ it('contrast 0 is identity (within rounding)', () => {
+ const src = solid(1, 1, 100, 100, 100);
+ const out = applyColorCorrection(src, 1, 1, { contrast: 0 });
+ expect([out[0], out[1], out[2]]).toEqual([100, 100, 100]);
+ });
+
+ it('saturation -100 produces greyscale (R=G=B)', () => {
+ const src = solid(1, 1, 200, 80, 30);
+ const out = applyColorCorrection(src, 1, 1, { saturation: -100 });
+ expect(out[0]).toBe(out[1]);
+ expect(out[1]).toBe(out[2]);
+ });
+
+ it('gamma 1 is identity; gamma > 1 brightens midtones', () => {
+ const src = solid(1, 1, 128, 128, 128);
+ const id = applyColorCorrection(src, 1, 1, { gamma: 1 });
+ expect(id[0]).toBe(128);
+ const bright = applyColorCorrection(src, 1, 1, { gamma: 2.2 });
+ expect(bright[0]).toBeGreaterThan(128);
+ });
+
+ it('gamma < 1 darkens midtones', () => {
+ const src = solid(1, 1, 128, 128, 128);
+ const out = applyColorCorrection(src, 1, 1, { gamma: 0.5 });
+ expect(out[0]).toBeLessThan(128);
+ });
+
+ it('alpha channel is preserved unchanged', () => {
+ const src = solid(1, 1, 50, 100, 150, 77);
+ const out = applyColorCorrection(src, 1, 1, { brightness: 50, contrast: 50, saturation: 50, gamma: 2.2 });
+ expect(out[3]).toBe(77);
+ });
+});