feat(web): вкладка «Редактирование» с инструментом «Свет»
В правой панели фото появилась вкладка «Редактирование» с инструментом «Свет»: ползунки экспозиции, контраста, светов, теней, белых и чёрных и точечная кривая с выбором канала. Правки сохраняются автоматически, переключатель «Сброс» удаляет параметры правок и отредактированную версию фото. Редактирование неразрушающее: параметры хранятся в метаданных фото, а отредактированная версия рендерится в фоне отдельным объектом (edit, edit_thumb, edit_medium, edit_preview) рядом с исходными вариантами. Оригинал не меняется, скачивание отдаёт исходный файл. Сетка, просмотр и обложки альбомов показывают отредактированную версию, когда она отрендерена.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user