- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
313 lines
10 KiB
TypeScript
313 lines
10 KiB
TypeScript
import { readFile } from 'node:fs/promises';
|
|
import { beforeAll, describe, expect, it } from 'vitest';
|
|
import {
|
|
applyColorToPixels,
|
|
applyLightToPixels,
|
|
buildCurveLUT,
|
|
emptyColor,
|
|
emptyEditParams,
|
|
GRADING_WHEELS,
|
|
MIXER_BANDS,
|
|
type ColorParams,
|
|
type LightParams
|
|
} from './edit';
|
|
|
|
interface LightExports {
|
|
memory: WebAssembly.Memory;
|
|
apply(
|
|
data: number,
|
|
len: number,
|
|
luts: number,
|
|
exposureMul: number,
|
|
contrastK: number,
|
|
highlights: number,
|
|
shadows: number,
|
|
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 () => {
|
|
const bytes = await readFile(new URL('../../static/light.wasm', import.meta.url));
|
|
const { instance } = await WebAssembly.instantiate(bytes, {});
|
|
wasm = instance.exports as unknown as LightExports;
|
|
});
|
|
|
|
/** Применяет правки модулем WASM к копии пикселей. */
|
|
function applyWasm(pixels: Uint8ClampedArray, light: LightParams): 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 curve = light.curve ?? {};
|
|
const tables = [
|
|
buildCurveLUT(curve.rgb),
|
|
buildCurveLUT(curve.red),
|
|
buildCurveLUT(curve.green),
|
|
buildCurveLUT(curve.blue)
|
|
];
|
|
const luts = new Uint8Array(256 * tables.length);
|
|
tables.forEach((table, i) => luts.set(table, i * 256));
|
|
new Uint8Array(mem.buffer, LUTS_OFFSET, luts.length).set(luts);
|
|
|
|
new Uint8ClampedArray(mem.buffer, PIXELS_OFFSET, pixels.length).set(pixels);
|
|
wasm.apply(
|
|
PIXELS_OFFSET,
|
|
pixels.length,
|
|
LUTS_OFFSET,
|
|
Math.pow(2, light.exposure),
|
|
1 + light.contrast / 100,
|
|
(light.highlights / 100) * 80,
|
|
(light.shadows / 100) * 80,
|
|
(light.whites / 100) * 80,
|
|
(light.blacks / 100) * 80
|
|
);
|
|
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 = {
|
|
...emptyEditParams().light,
|
|
exposure: 0.8,
|
|
contrast: 25,
|
|
highlights: -30,
|
|
shadows: 40,
|
|
whites: 10,
|
|
blacks: -15,
|
|
curve: { rgb: [{ x: 0, y: 0 }, { x: 0.5, y: 0.4 }, { x: 1, y: 1 }] }
|
|
};
|
|
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();
|
|
applyLightToPixels(expected, light);
|
|
const actual = applyWasm(source.slice(), light);
|
|
expect(Array.from(actual)).toEqual(Array.from(expected));
|
|
});
|
|
|
|
it('не меняет прозрачные пиксели', () => {
|
|
const light: LightParams = { ...emptyEditParams().light, exposure: 2 };
|
|
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
|
const out = applyWasm(pixels, light);
|
|
expect(Array.from(out)).toEqual([10, 20, 30, 0]);
|
|
});
|
|
|
|
it('осветляет при положительной экспозиции', () => {
|
|
const light: LightParams = { ...emptyEditParams().light, exposure: 1 };
|
|
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)}`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|