feat(web): вкладка «Редактирование» с инструментом «Свет»

В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»:
ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая
с выбором канала. Правки сохраняются автоматически, переключатель «Сброс»
удаляет параметры правок и отредактированную версию фото.

Редактирование неразрушающее: параметры хранятся в метаданных фото, а
отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb,
edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется,
скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают
отредактированную версию, когда она отрендерена.
This commit is contained in:
iu
2026-10-20 23:06:24 +03:00
parent afaaaab5f0
commit 0548b1ab5e
32 changed files with 1954 additions and 24 deletions
+185
View File
@@ -0,0 +1,185 @@
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<CurveChannel, string> = {
rgb: 'RGB',
red: 'R',
green: 'G',
blue: 'B'
};
export const CHANNEL_COLORS: Record<CurveChannel, string> = {
rgb: 'var(--text)',
red: '#ef4444',
green: '#22c55e',
blue: '#3b82f6'
};
export interface LightSlider {
key: Exclude<keyof LightParams, 'curve'>;
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<LightParams> } | 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;
}