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); }); });