feat(photos): добавить эффекты и настройки панели редактора

- текстура, чёткость, дымка, виньетка и зерно в неразрушающем редакторе
- живой WebGL2-предпросмотр эффектов и CPU-фолбэк
- секции редактора свёрнуты по умолчанию, состояние в localStorage
- вкладка «Редактирование» доступна только из просмотрщика
This commit is contained in:
iu
2026-10-24 22:57:30 +03:00
parent 4cc5c0f340
commit c953008a86
18 changed files with 3548 additions and 173 deletions
+187
View File
@@ -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 },