Files
ds-galo/frontend/src/lib/edit.test.ts
T
iu c953008a86 feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
2026-10-24 22:57:30 +03:00

445 lines
14 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
applyColorToPixels,
applyGrainToPixels,
applyVignetteToPixels,
buildCurveLUT,
curveSvgPath,
effectiveCurvePoints,
effectsSeed,
emptyColor,
emptyEditParams,
emptyEffects,
isEmptyColor,
isEmptyEditParams,
isEmptyEffects,
normalizeEditParams,
normalizeVignette,
readEditParams,
whiteBalanceFromRGB
} from './edit';
import type { Photo } from './types';
describe('параметры правок', () => {
it('зажимает значения в допустимые диапазоны', () => {
const params = normalizeEditParams({
light: { ...emptyEditParams().light, exposure: 99, contrast: -500 }
});
expect(params.light.exposure).toBe(5);
expect(params.light.contrast).toBe(-100);
});
it('считает пустыми нулевые параметры и линейную кривую', () => {
expect(isEmptyEditParams(emptyEditParams())).toBe(true);
const linear = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }
}
});
expect(isEmptyEditParams(linear)).toBe(true);
const bent = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0, y: 0 }, { x: 1, y: 0.5 }] }
}
});
expect(isEmptyEditParams(bent)).toBe(false);
});
it('читает параметры из метаданных фото', () => {
const photo = {
edit_params: { light: { exposure: 0.5, curve: { rgb: [{ x: 0, y: 0 }] } } }
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.light.contrast).toBe(0);
});
it('сортирует точки кривой и убирает дубликаты', () => {
const params = normalizeEditParams({
light: {
...emptyEditParams().light,
curve: { rgb: [{ x: 0.8, y: 0.9 }, { x: 0.2, y: 0.1 }, { x: 0.8, y: 0.7 }] }
}
});
const points = params.light.curve.rgb ?? [];
expect(points.length).toBe(2);
expect(points[0].x).toBeCloseTo(0.2);
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);
});
});
describe('эффекты', () => {
function uniform(width: number, height: number, value = 128): Uint8ClampedArray {
const pixels = new Uint8ClampedArray(width * height * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = value;
pixels[i + 1] = value;
pixels[i + 2] = value;
pixels[i + 3] = 255;
}
return pixels;
}
function channel(pixels: Uint8ClampedArray, width: number, x: number, y: number): [number, number, number] {
const i = (y * width + x) * 4;
return [pixels[i], pixels[i + 1], pixels[i + 2]];
}
it('зажимает значения и заполняет дефолты', () => {
const params = normalizeEditParams({
light: emptyEditParams().light,
color: emptyColor(),
effects: {
texture: 500,
clarity: -500,
dehaze: 50,
vignette: {
mode: 'weird' as never,
amount: 200,
midpoint: -10,
feather: 300,
roundness: 500,
highlights: -5,
color: 'red'
},
grain: { amount: -10, size: 500, roughness: -5 }
}
});
expect(params.effects.texture).toBe(100);
expect(params.effects.clarity).toBe(-100);
expect(params.effects.dehaze).toBe(50);
expect(params.effects.vignette.mode).toBe('light');
expect(params.effects.vignette.amount).toBe(100);
expect(params.effects.vignette.midpoint).toBe(0);
expect(params.effects.vignette.feather).toBe(100);
expect(params.effects.vignette.roundness).toBe(100);
expect(params.effects.vignette.highlights).toBe(0);
expect(params.effects.vignette.color).toBe('#000000');
expect(params.effects.grain.amount).toBe(0);
expect(params.effects.grain.size).toBe(100);
expect(params.effects.grain.roughness).toBe(0);
});
it('нормализует цвет виньетки', () => {
expect(normalizeVignette({ color: '#AABBCC' }).color).toBe('#aabbcc');
expect(normalizeVignette({ color: '#abc' }).color).toBe('#aabbcc');
expect(normalizeVignette({ color: 'нет' }).color).toBe('#000000');
expect(normalizeVignette({}).midpoint).toBe(50);
expect(normalizeVignette({}).feather).toBe(50);
});
it('считает пустыми эффекты без силы', () => {
expect(isEmptyEffects(emptyEffects())).toBe(true);
expect(isEmptyEffects({ ...emptyEffects(), texture: 1 })).toBe(false);
const noVignettePower = emptyEffects();
noVignettePower.vignette.amount = 0;
noVignettePower.vignette.midpoint = 90;
noVignettePower.vignette.color = '#ff0000';
expect(isEmptyEffects(noVignettePower)).toBe(true);
const grain = emptyEffects();
grain.grain.amount = 1;
expect(isEmptyEffects(grain)).toBe(false);
});
it('читает эффекты из метаданных и не теряет свет и цвет', () => {
const photo = {
edit_params: {
light: { exposure: 0.5 },
color: { temperature: 20 },
effects: { texture: 30, grain: { amount: 10 } }
}
} as unknown as Photo;
const params = readEditParams(photo);
expect(params.light.exposure).toBe(0.5);
expect(params.color.temperature).toBe(20);
expect(params.effects.texture).toBe(30);
expect(params.effects.grain.amount).toBe(10);
expect(params.effects.grain.size).toBe(25);
expect(isEmptyEditParams(params)).toBe(false);
});
it('затемняет края и не трогает центр', () => {
const pixels = uniform(9, 9);
applyVignetteToPixels(
pixels,
{ ...emptyEffects().vignette, amount: 100 },
9,
9
);
const center = channel(pixels, 9, 4, 4);
const corner = channel(pixels, 9, 0, 0);
expect(center).toEqual([128, 128, 128]);
expect(corner[0]).toBeLessThan(center[0]);
});
it('осветляет края при отрицательной силе', () => {
const pixels = uniform(9, 9);
applyVignetteToPixels(
pixels,
{ ...emptyEffects().vignette, amount: -100 },
9,
9
);
expect(channel(pixels, 9, 0, 0)[0]).toBeGreaterThan(128);
});
it('тонирует и заливает края цветом', () => {
const tinted = uniform(9, 9);
applyVignetteToPixels(
tinted,
{
...emptyEffects().vignette,
mode: 'color',
amount: 100,
color: '#ff0000'
},
9,
9
);
const colorCorner = channel(tinted, 9, 0, 0);
expect(colorCorner[0]).toBeGreaterThan(colorCorner[1]);
expect(colorCorner[0]).toBeGreaterThan(colorCorner[2]);
expect(channel(tinted, 9, 4, 4)).toEqual([128, 128, 128]);
const painted = uniform(9, 9);
applyVignetteToPixels(
painted,
{
...emptyEffects().vignette,
mode: 'colorize',
amount: 100,
color: '#ff0000'
},
9,
9
);
expect(channel(painted, 9, 0, 0)).toEqual([255, 0, 0]);
});
it('не трогает прозрачные пиксели', () => {
const pixels = new Uint8ClampedArray([10, 20, 30, 0]);
applyVignetteToPixels(
pixels,
{ ...emptyEffects().vignette, amount: 100 },
1,
1
);
expect(Array.from(pixels)).toEqual([10, 20, 30, 0]);
});
it('делает зерно детерминированным', () => {
const seed = effectsSeed('photo-1');
const first = uniform(16, 16);
const second = uniform(16, 16);
const grain = { ...emptyEffects().grain, amount: 80 };
applyGrainToPixels(first, grain, 16, 16, seed);
applyGrainToPixels(second, grain, 16, 16, seed);
expect(Array.from(first)).toEqual(Array.from(second));
expect(first[0]).not.toBe(128);
const other = uniform(16, 16);
applyGrainToPixels(other, grain, 16, 16, effectsSeed('photo-2'));
expect(Array.from(other)).not.toEqual(Array.from(first));
});
it('не меняет кадр при нулевой силе зерна', () => {
const pixels = uniform(8, 8);
applyGrainToPixels(pixels, emptyEffects().grain, 8, 8, effectsSeed('photo-1'));
expect(Array.from(pixels)).toEqual(Array.from(uniform(8, 8)));
});
});
describe('кривая', () => {
const medium = [
{ x: 0, y: 0 },
{ x: 0.25, y: 0.2 },
{ x: 0.5, y: 0.5 },
{ x: 0.75, y: 0.8 },
{ x: 1, y: 1 }
];
const strong = [
{ x: 0, y: 0 },
{ x: 0.2, y: 0.12 },
{ x: 0.5, y: 0.5 },
{ x: 0.8, y: 0.88 },
{ x: 1, y: 1 }
];
const inner = [
{ x: 0.1, y: 0.05 },
{ x: 0.4, y: 0.3 },
{ x: 0.9, y: 0.95 }
];
const samples = [0, 32, 64, 128, 192, 224, 255];
it('показывает крайние точки у пустого канала', () => {
expect(effectiveCurvePoints(undefined)).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]);
expect(effectiveCurvePoints([{ x: 0.5, y: 0.4 }])).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 }
]);
expect(effectiveCurvePoints(inner)).toEqual(inner);
});
it('совпадает с серверной PCHIP-кривой', () => {
const values = (points: { x: number; y: number }[]) => {
const lut = buildCurveLUT(points);
return samples.map((i) => lut[i]);
};
expect(values([{ x: 0, y: 0 }, { x: 1, y: 1 }])).toEqual([
0, 32, 64, 128, 192, 224, 255
]);
expect(values(medium)).toEqual([0, 23, 51, 128, 205, 233, 255]);
expect(values(strong)).toEqual([0, 17, 43, 128, 213, 239, 255]);
expect(values(inner)).toEqual([13, 17, 42, 104, 186, 234, 242]);
});
it('остаётся монотонной между точками', () => {
for (const points of [medium, strong]) {
const lut = buildCurveLUT(points);
for (let i = 1; i < lut.length; i++) {
expect(lut[i]).toBeGreaterThanOrEqual(lut[i - 1]);
}
}
});
it('делает плоские полки вне крайних точек', () => {
const lut = buildCurveLUT(inner);
for (let i = 0; i <= Math.floor(0.1 * 255); i++) expect(lut[i]).toBe(13);
for (let i = Math.ceil(0.9 * 255); i < 256; i++) expect(lut[i]).toBe(242);
});
it('рисует плавный SVG-путь с полками', () => {
const path = curveSvgPath(inner);
expect(path.startsWith('M0.0000,95.0000 L10.0000,95.0000')).toBe(true);
expect(path).toContain('C');
expect(path.endsWith('L100.0000,5.0000')).toBe(true);
});
});