feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой

- неразрушающие правки цвета: баланс белого, вибрация/насыщенность,
  цветовой микшер, колористика и поканальная коррекция
- серверный рендер цвета в Go и паритетная реализация в WASM
- живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк
- пипетка баланса белого по нейтральной точке
- новые контролы: слайдеры с градиентами, цветовые колёса,
  двойной клик по контролу сбрасывает значение
- тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
iu
2026-10-23 21:57:48 +03:00
parent c886c6f22c
commit d791b9a572
19 changed files with 3375 additions and 144 deletions
+212 -1
View File
@@ -1,6 +1,16 @@
import { readFile } from 'node:fs/promises';
import { beforeAll, describe, expect, it } from 'vitest';
import { applyLightToPixels, buildCurveLUT, emptyEditParams, type LightParams } from './edit';
import {
applyColorToPixels,
applyLightToPixels,
buildCurveLUT,
emptyColor,
emptyEditParams,
GRADING_WHEELS,
MIXER_BANDS,
type ColorParams,
type LightParams
} from './edit';
interface LightExports {
memory: WebAssembly.Memory;
@@ -15,11 +25,14 @@ interface LightExports {
whites: number,
blacks: number
): void;
apply_color(data: number, len: number, params: number): void;
}
let wasm: LightExports;
const LUTS_OFFSET = 0;
const COLOR_PARAMS_OFFSET = 2048;
const COLOR_PARAM_COUNT = 42;
const PIXELS_OFFSET = 4096;
beforeAll(async () => {
@@ -61,6 +74,44 @@ function applyWasm(pixels: Uint8ClampedArray, light: LightParams): Uint8ClampedA
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
}
/** Применяет цветовые правки модулем WASM к копии пикселей. */
function applyColorWasm(pixels: Uint8ClampedArray, color: ColorParams): Uint8ClampedArray {
const mem = wasm.memory;
const need = Math.ceil((PIXELS_OFFSET + pixels.length) / 65536);
const have = mem.buffer.byteLength / 65536;
if (need > have) mem.grow(need - have);
const params = new Float64Array(COLOR_PARAM_COUNT);
params[0] = color.temperature / 100;
params[1] = color.tint / 100;
params[2] = color.vibrance / 100;
params[3] = color.saturation / 100;
MIXER_BANDS.forEach((band, index) => {
const value = color.mixer[band.key];
const base = 4 + index * 3;
params[base] = value.hue / 100;
params[base + 1] = value.saturation / 100;
params[base + 2] = value.luminance / 100;
});
GRADING_WHEELS.forEach((wheel, index) => {
const value = color.grading[wheel.key];
const base = 28 + index * 2;
params[base] = value.hue;
params[base + 1] = value.saturation / 100;
params[34 + index] = (value.luminance ?? 0) / 100;
});
params[37] = color.grading.blending / 100;
params[38] = color.grading.balance / 100;
params[39] = color.correction.red / 100;
params[40] = color.correction.green / 100;
params[41] = color.correction.blue / 100;
new Float64Array(mem.buffer, COLOR_PARAMS_OFFSET, COLOR_PARAM_COUNT).set(params);
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
wasm.apply_color(PIXELS_OFFSET, pixels.length, COLOR_PARAMS_OFFSET);
return new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).slice();
}
describe('WASM-модуль фильтров', () => {
it('совпадает с JS-реализацией', () => {
const light: LightParams = {
@@ -98,4 +149,164 @@ describe('WASM-модуль фильтров', () => {
const out = applyWasm(new Uint8ClampedArray([100, 100, 100, 255]), light);
expect(out[0]).toBeGreaterThan(100);
});
it('совпадает с JS на большом градиенте (свет)', () => {
const light: LightParams = {
...emptyEditParams().light,
exposure: 0.4,
contrast: 20,
highlights: -20,
shadows: 30
};
const width = 256;
const height = 256;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 20 + (y % 200);
source[i + 1] = 30 + (x % 150);
source[i + 2] = 40 + ((x + y) % 120);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyLightToPixels(expected, light);
const actual = applyWasm(source.slice(), light);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS-реализацией цвета', () => {
const color = emptyColor();
color.temperature = 30;
color.tint = -10;
color.vibrance = 20;
color.saturation = -15;
color.mixer.red = { hue: 20, saturation: 30, luminance: -10 };
color.mixer.blue = { hue: -15, saturation: -20, luminance: 10 };
color.grading.shadows = { hue: 240, saturation: 40, luminance: 10 };
color.grading.highlights = { hue: 40, saturation: 20, luminance: -10 };
color.grading.blending = 30;
color.grading.balance = -20;
color.correction = { red: 10, green: -5, blue: 8 };
const source = new Uint8ClampedArray(4 * 64);
for (let i = 0; i < source.length; i += 4) {
source[i] = (i * 7) % 256;
source[i + 1] = (i * 13) % 256;
source[i + 2] = (i * 29) % 256;
source[i + 3] = 255;
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('не меняет прозрачные пиксели при цветовых правках', () => {
const color = emptyColor();
color.saturation = 100;
color.temperature = 100;
const out = applyColorWasm(new Uint8ClampedArray([10, 20, 30, 0]), color);
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
});
it('совпадает с JS на большом градиенте', () => {
const color = emptyColor();
color.temperature = 25;
color.vibrance = 15;
color.mixer.blue = { hue: -10, saturation: 20, luminance: 10 };
color.grading.shadows = { hue: 240, saturation: 80, luminance: 10 };
color.grading.highlights = { hue: 40, saturation: 30, luminance: -10 };
const width = 256;
const height = 256;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 20 + (y % 200);
source[i + 1] = 30 + (x % 150);
source[i + 2] = 40 + ((x + y) % 120);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS на быстром пути (только колёса)', () => {
const color = emptyColor();
color.temperature = 20;
color.tint = -10;
color.grading.blending = 50;
color.grading.balance = -20;
color.grading.shadows = { hue: 240, saturation: 70, luminance: 25 };
color.grading.midtones = { hue: 40, saturation: 20, luminance: -15 };
color.grading.highlights = { hue: 200, saturation: 30, luminance: 10 };
const width = 128;
const height = 128;
const source = new Uint8ClampedArray(width * height * 4);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = (y * width + x) * 4;
source[i] = 10 + (y % 220);
source[i + 1] = 20 + (x % 180);
source[i + 2] = 30 + ((x + y) % 160);
source[i + 3] = 255;
}
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
expect(Array.from(actual)).toEqual(Array.from(expected));
});
it('совпадает с JS на случайных цветовых параметрах', () => {
for (let n = 0; n < 100; n++) {
const color = emptyColor();
color.temperature = Math.random() * 200 - 100;
color.tint = Math.random() * 200 - 100;
color.vibrance = Math.random() * 200 - 100;
color.saturation = Math.random() * 200 - 100;
color.grading.blending = Math.random() * 100;
color.grading.balance = Math.random() * 200 - 100;
color.grading.shadows = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
color.grading.midtones = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
color.grading.highlights = {
hue: Math.random() * 360,
saturation: Math.random() * 200 - 100,
luminance: Math.random() * 200 - 100
};
const source = new Uint8ClampedArray(64);
for (let i = 0; i < source.length; i += 4) {
source[i] = (i * 7) % 256;
source[i + 1] = (i * 13) % 256;
source[i + 2] = (i * 29) % 256;
source[i + 3] = 255;
}
const expected = source.slice();
applyColorToPixels(expected, color);
const actual = applyColorWasm(source.slice(), color);
for (let i = 0; i < expected.length; i++) {
if (expected[i] !== actual[i]) {
throw new Error(
`mismatch n=${n} i=${i} expected=${expected[i]} actual=${actual[i]} color=${JSON.stringify(color)}`
);
}
}
}
});
});