Files
ds-galo/frontend/src/lib/edit.ts
T
iu d904b2fe31 fix(web): линия тональной кривой была не видна
Цвета каналов задавались CSS-переменными темы прямо в SVG-атрибутах stroke и
fill, но presentation-атрибуты не понимают var(), а переменные темы хранят
компоненты HSL, а не готовый цвет. В итоге обводка была пустой, и на тёмном
фоне графика кривую не было видно.

Теперь цвет канала идёт через style в формате hsl(var(--text)), а заливка
точек — hsl(var(--bg)).
2026-10-21 01:24:40 +03:00

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