Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и fill, но presentation-атрибуты не понимают var(), а переменные темы хранят компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном фоне графика кривую не было видно. Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка точек — hsl(var(--bg)).
264 lines
8.3 KiB
TypeScript
264 lines
8.3 KiB
TypeScript
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> = {
|
|
// --text в теме хранит компоненты HSL, поэтому цвет собирается через hsl().
|
|
rgb: 'hsl(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;
|
|
}
|
|
|
|
/**
|
|
* Строит 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);
|
|
}
|
|
}
|