feat(photos): инструмент «Цвет» с WebGL-превью и пипеткой
- неразрушающие правки цвета: баланс белого, вибрация/насыщенность, цветовой микшер, колористика и поканальная коррекция - серверный рендер цвета в Go и паритетная реализация в WASM - живое превью на WebGL2 (кадр считает GPU), CPU/WASM-фолбэк - пипетка баланса белого по нейтральной точке - новые контролы: слайдеры с градиентами, цветовые колёса, двойной клик по контролу сбрасывает значение - тесты: параметры, рендер, паритет JS/WASM, e2e сохранения и сброса
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
applyColorToPixels,
|
||||
emptyColor,
|
||||
emptyEditParams,
|
||||
isEmptyColor,
|
||||
isEmptyEditParams,
|
||||
normalizeEditParams,
|
||||
readEditParams
|
||||
readEditParams,
|
||||
whiteBalanceFromRGB
|
||||
} from './edit';
|
||||
import type { Photo } from './types';
|
||||
|
||||
@@ -56,3 +60,125 @@ describe('параметры правок', () => {
|
||||
expect(points[1].x).toBeCloseTo(0.8);
|
||||
});
|
||||
});
|
||||
|
||||
describe('цветовые параметры', () => {
|
||||
it('зажимает значения и оттенки колёс', () => {
|
||||
const params = normalizeEditParams({
|
||||
light: emptyEditParams().light,
|
||||
color: {
|
||||
...emptyColor(),
|
||||
temperature: 200,
|
||||
tint: -300,
|
||||
saturation: -500,
|
||||
grading: {
|
||||
...emptyColor().grading,
|
||||
shadows: { hue: 400, saturation: 150, luminance: 0 },
|
||||
balance: -200
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(params.color.temperature).toBe(100);
|
||||
expect(params.color.tint).toBe(-100);
|
||||
expect(params.color.saturation).toBe(-100);
|
||||
expect(params.color.grading.shadows.hue).toBe(360);
|
||||
expect(params.color.grading.shadows.saturation).toBe(100);
|
||||
expect(params.color.grading.balance).toBe(-100);
|
||||
});
|
||||
|
||||
it('считает пустыми нулевые цветовые параметры', () => {
|
||||
expect(isEmptyColor(emptyColor())).toBe(true);
|
||||
expect(isEmptyColor({ ...emptyColor(), vibrance: 1 })).toBe(false);
|
||||
const hueOnly = emptyColor();
|
||||
hueOnly.grading.shadows.hue = 120;
|
||||
expect(isEmptyColor(hueOnly)).toBe(true);
|
||||
const grade = emptyColor();
|
||||
grade.grading.highlights.saturation = 10;
|
||||
expect(isEmptyColor(grade)).toBe(false);
|
||||
});
|
||||
|
||||
it('читает цвет из метаданных и не теряет свет', () => {
|
||||
const photo = {
|
||||
edit_params: {
|
||||
light: { exposure: 0.5 },
|
||||
color: { temperature: 20, vibrance: 10 }
|
||||
}
|
||||
} as unknown as Photo;
|
||||
const params = readEditParams(photo);
|
||||
expect(params.light.exposure).toBe(0.5);
|
||||
expect(params.color.temperature).toBe(20);
|
||||
expect(params.color.vibrance).toBe(10);
|
||||
});
|
||||
|
||||
it('осветляет тёплым балансом белого', () => {
|
||||
const pixels = new Uint8ClampedArray([100, 100, 100, 255]);
|
||||
applyColorToPixels(pixels, { ...emptyColor(), temperature: 100 });
|
||||
expect(pixels[0]).toBeGreaterThan(pixels[1]);
|
||||
expect(pixels[1]).toBeGreaterThan(pixels[2]);
|
||||
});
|
||||
|
||||
it('обеспечивает серый цвет при насыщенности -100', () => {
|
||||
const pixels = new Uint8ClampedArray([200, 50, 50, 255]);
|
||||
applyColorToPixels(pixels, { ...emptyColor(), saturation: -100 });
|
||||
expect(pixels[0]).toBe(pixels[1]);
|
||||
expect(pixels[1]).toBe(pixels[2]);
|
||||
});
|
||||
|
||||
it('тонирует тени синим колесом', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 240, saturation: 100, luminance: 0 };
|
||||
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||
applyColorToPixels(pixels, color);
|
||||
expect(pixels[2]).toBeGreaterThan(pixels[0]);
|
||||
});
|
||||
|
||||
it('повышает яркость теней', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 0, saturation: 0, luminance: 100 };
|
||||
const pixels = new Uint8ClampedArray([40, 40, 40, 255]);
|
||||
applyColorToPixels(pixels, color);
|
||||
expect(pixels[0]).toBeGreaterThan(40);
|
||||
});
|
||||
|
||||
it('одинаково обрабатывает все строки однородного изображения', () => {
|
||||
const color = emptyColor();
|
||||
color.grading.shadows = { hue: 240, saturation: 100, luminance: 20 };
|
||||
const pixels = new Uint8ClampedArray(64 * 64 * 4);
|
||||
for (let i = 0; i < pixels.length; i += 4) {
|
||||
pixels[i] = 40;
|
||||
pixels[i + 1] = 40;
|
||||
pixels[i + 2] = 40;
|
||||
pixels[i + 3] = 255;
|
||||
}
|
||||
applyColorToPixels(pixels, color);
|
||||
for (let i = 4; i < pixels.length; i += 4) {
|
||||
expect(pixels[i]).toBe(pixels[0]);
|
||||
expect(pixels[i + 1]).toBe(pixels[1]);
|
||||
expect(pixels[i + 2]).toBe(pixels[2]);
|
||||
}
|
||||
});
|
||||
|
||||
it('не трогает прозрачные пиксели', () => {
|
||||
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
|
||||
applyColorToPixels(pixels, {
|
||||
...emptyColor(),
|
||||
temperature: 100,
|
||||
saturation: 100
|
||||
});
|
||||
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
|
||||
});
|
||||
|
||||
it('подбирает баланс белого по нейтральной точке', () => {
|
||||
const neutral = whiteBalanceFromRGB(128, 128, 128);
|
||||
expect(neutral.temperature).toBeCloseTo(0, 5);
|
||||
expect(neutral.tint).toBeCloseTo(0, 5);
|
||||
|
||||
const wb = whiteBalanceFromRGB(150, 140, 120);
|
||||
const temperature = wb.temperature / 100;
|
||||
const tint = wb.tint / 100;
|
||||
const r = 150 * (1 + 0.2 * temperature + 0.1 * tint);
|
||||
const g = 140 * (1 - 0.2 * tint);
|
||||
const b = 120 * (1 - 0.2 * temperature + 0.1 * tint);
|
||||
expect(r).toBeCloseTo(g, 5);
|
||||
expect(b).toBeCloseTo(g, 5);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user