import type { Photo } from './types'; /** * Неразрушающее редактирование фотографии. * * Параметры правок хранятся в метаданных фото, а отрендеренная версия * приходит с сервера отдельным объектом. Расчёт кадров и рендер выполняет * сервер (backend/internal/photos/edit.go). */ export interface CurvePoint { x: number; y: number; } export interface CurveParams { rgb?: CurvePoint[]; red?: CurvePoint[]; green?: CurvePoint[]; blue?: CurvePoint[]; } export interface LightParams { exposure: number; contrast: number; highlights: number; shadows: number; whites: number; blacks: number; curve: CurveParams; } export interface EditParams { light: LightParams; } export const CURVE_CHANNELS = ['rgb', 'red', 'green', 'blue'] as const; export type CurveChannel = (typeof CURVE_CHANNELS)[number]; export const CHANNEL_LABELS: Record = { rgb: 'RGB', red: 'R', green: 'G', blue: 'B' }; export const CHANNEL_COLORS: Record = { // --text в теме хранит компоненты HSL, поэтому цвет собирается через hsl(). rgb: 'hsl(var(--text))', red: '#ef4444', green: '#22c55e', blue: '#3b82f6' }; export interface LightSlider { key: Exclude; label: string; min: number; max: number; step: number; } export const LIGHT_SLIDERS: LightSlider[] = [ { key: 'exposure', label: 'Экспозиция', min: -5, max: 5, step: 0.1 }, { key: 'contrast', label: 'Контраст', min: -100, max: 100, step: 1 }, { key: 'highlights', label: 'Света', min: -100, max: 100, step: 1 }, { key: 'shadows', label: 'Тени', min: -100, max: 100, step: 1 }, { key: 'whites', label: 'Белые', min: -100, max: 100, step: 1 }, { key: 'blacks', label: 'Чёрные', min: -100, max: 100, step: 1 } ]; export const MAX_EXPOSURE_STOPS = 5; export const MAX_PERCENT = 100; export const MAX_CURVE_POINTS = 16; export function emptyLight(): LightParams { return { exposure: 0, contrast: 0, highlights: 0, shadows: 0, whites: 0, blacks: 0, curve: {} }; } export function emptyEditParams(): EditParams { return { light: emptyLight() }; } function clamp(v: number, min: number, max: number): number { if (!Number.isFinite(v)) return 0; return Math.min(max, Math.max(min, v)); } function clamp01(v: number): number { return clamp(v, 0, 1); } /** Нормализует кривую: точки зажимаются в 0..1, сортируются по X, дубликаты убираются. */ export function normalizeCurve(points?: CurvePoint[]): CurvePoint[] | undefined { if (!points?.length) return undefined; const sorted = points .map((p) => ({ x: clamp(p.x, 0, 1), y: clamp(p.y, 0, 1) })) .sort((a, b) => a.x - b.x); const out: CurvePoint[] = []; for (const p of sorted) { if (out.length && Math.abs(p.x - out[out.length - 1].x) < 1e-6) continue; out.push(p); } return out.length ? out.slice(0, MAX_CURVE_POINTS) : undefined; } /** Приводит параметры к допустимым диапазонам. */ export function normalizeEditParams(params: EditParams): EditParams { const light = params?.light ?? emptyLight(); return { light: { exposure: clamp(light.exposure, -MAX_EXPOSURE_STOPS, MAX_EXPOSURE_STOPS), contrast: clamp(light.contrast, -MAX_PERCENT, MAX_PERCENT), highlights: clamp(light.highlights, -MAX_PERCENT, MAX_PERCENT), shadows: clamp(light.shadows, -MAX_PERCENT, MAX_PERCENT), whites: clamp(light.whites, -MAX_PERCENT, MAX_PERCENT), blacks: clamp(light.blacks, -MAX_PERCENT, MAX_PERCENT), curve: { rgb: normalizeCurve(light.curve?.rgb), red: normalizeCurve(light.curve?.red), green: normalizeCurve(light.curve?.green), blue: normalizeCurve(light.curve?.blue) } } }; } /** Пустые ли параметры: фотография не тронута. */ export function isEmptyEditParams(params: EditParams): boolean { const light = params.light; if ( light.exposure !== 0 || light.contrast !== 0 || light.highlights !== 0 || light.shadows !== 0 || light.whites !== 0 || light.blacks !== 0 ) { return false; } for (const channel of CURVE_CHANNELS) { for (const point of light.curve?.[channel] ?? []) { if (Math.abs(point.x - point.y) > 1e-6) return false; } } return true; } /** Читает параметры правок из метаданных фото. */ export function readEditParams(photo: Photo | null | undefined): EditParams { const raw = photo?.edit_params as { light?: Partial } | undefined; if (!raw?.light) return emptyEditParams(); return normalizeEditParams({ light: { ...emptyLight(), ...raw.light, curve: raw.light.curve ?? {} } }); } /** URL отредактированной версии фото, если она есть. */ export function photoThumbUrl(photo: Photo): string | undefined { return photo.edit_thumb_url ?? photo.thumb_url; } export function photoMediumUrl(photo: Photo): string | undefined { return photo.edit_medium_url ?? photo.medium_url; } export function photoPreviewUrl(photo: Photo): string | undefined { return photo.edit_preview_url ?? photo.preview_url; } /** Полноразмерная версия: отредактированная, если она отрендерена. */ export function photoFullUrl(photo: Photo): string | undefined { return photo.edit_url ?? photo.original_url; } /** * Строит 256-элементную таблицу соответствия по точкам кривой. Математика * повторяет серверную (backend/internal/photos/edit.go), поэтому предпросмотр * в браузере совпадает с результатом рендера. */ export function buildCurveLUT(points?: CurvePoint[]): Uint8Array { const lut = new Uint8Array(256); const pts = normalizeCurve(points); if (!pts || pts.length < 2) { for (let i = 0; i < 256; i++) lut[i] = i; return lut; } for (let i = 0; i < 256; i++) { lut[i] = Math.round(clamp01(curveEval(pts, i / 255)) * 255); } return lut; } function curveEval(points: CurvePoint[], x: number): number { if (x <= points[0].x) return points[0].y; const last = points[points.length - 1]; if (x >= last.x) return last.y; for (let i = 1; i < points.length; i++) { if (x > points[i].x) continue; const prev = points[i - 1]; const cur = points[i]; if (cur.x - prev.x < 1e-9) return cur.y; const t = (x - prev.x) / (cur.x - prev.x); return prev.y + t * (cur.y - prev.y); } return last.y; } function adjustChannel( value: number, light: LightParams, master: Uint8Array, channel: Uint8Array ): number { // Экспозиция (стопы). let v = value * Math.pow(2, light.exposure); // Контраст вокруг средней точки. const k = 1 + light.contrast / 100; v = 128 + (v - 128) * k; // Света: верхняя часть диапазона. let w = Math.pow(clamp01((v - 128) / 127), 2); v += (light.highlights / 100) * 80 * w; // Тени: нижняя часть диапазона. w = Math.pow(clamp01((128 - v) / 128), 2); v += (light.shadows / 100) * 80 * w; // Белые: самый верх диапазона. w = clamp01((v - 192) / 63); v += (light.whites / 100) * 80 * w; // Чёрные: самый низ диапазона. w = clamp01((64 - v) / 64); v += (light.blacks / 100) * 80 * w; // Тоновая кривая: общая, затем канальная. let idx = Math.min(255, Math.max(0, Math.round(v))); v = master[idx]; idx = Math.min(255, Math.max(0, Math.round(v))); return channel[idx]; } /** Применяет правки «Свет» к пикселям изображения (в том же массиве). */ export function applyLightToPixels(data: Uint8ClampedArray, light: LightParams): void { const master = buildCurveLUT(light.curve?.rgb); const red = buildCurveLUT(light.curve?.red); const green = buildCurveLUT(light.curve?.green); const blue = buildCurveLUT(light.curve?.blue); for (let i = 0; i + 3 < data.length; i += 4) { if (data[i + 3] === 0) continue; data[i] = adjustChannel(data[i], light, master, red); data[i + 1] = adjustChannel(data[i + 1], light, master, green); data[i + 2] = adjustChannel(data[i + 2], light, master, blue); } }