- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
445 lines
14 KiB
TypeScript
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);
|
|
});
|
|
});
|