feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
@@ -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)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user