feat(photos): добавить эффекты и настройки панели редактора
- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе - живой WebGL2-предпросмотр эффектов и CPU-фолбэк - секции редактора свёрнуты по умолчанию, состояние в localStorage - вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
@@ -1,14 +1,20 @@
|
||||
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';
|
||||
@@ -186,6 +192,187 @@ describe('цветовые параметры', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 },
|
||||
|
||||
Reference in New Issue
Block a user