Files
ds-galo/openspec/changes/photo-curve-presets/design.md
T
iu 0bfee5f2f8 feat(photos): добавить пресеты кривой и починить переключение фото
- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
2026-10-23 22:56:55 +03:00

11 KiB

Design

Context

Сейчас кривая хранится в light.curve как точки на канал, normalizeCurve сортирует/зажимает/дедуплицирует их, а по умолчанию массив пуст. UI рисует статичные углы и прямые сегменты; buildCurveLUT (TS) и curveLUT (Go) интерполируют линейно. Пресетов нет. Настройки профиля лежат в users.preferences (JSON-блоб); PATCH /auth/me/preferences сейчас заменяет весь объект и знает только photo_info, поэтому добавление пресетов без мержа затирало бы другие разделы.

См. proposal.md — Why и specs/photos/spec.md, specs/auth/spec.md — требования.

Goals / Non-Goals

Goals:

  • Крайние узлы как обычные точки; первый клик вставляет между ними; узлы двигаются по x/y; вне них кривая плоская.
  • Плавная монотонная интерполяция в SVG-пути и в LUT; Go и TS считают одинаково.
  • Встроенные пресеты контраста и пользовательские пресеты (кривые всех четырёх каналов) в профиле; сохранение/применение/переименование/удаление; импорт/экспорт JSON.
  • Серверный мерж разделов настроек с валидацией.

Non-Goals:

  • Пресеты как «look» из четырёх кривых; Android-клиент; шаринг пресетов между пользователями (только файл); undo/redo.

Decisions

Крайние точки — обычные узлы.

  • Первая и последняя точки массива являются крайними; по умолчанию это (0,0) и (1,1). Пустой канал в UI показывает эти две точки; первое взаимодействие записывает их в light.curve.
  • Вне крайних точек значение постоянно (текущее поведение curveEval), поэтому сдвинутый по x узел даёт плоскую «полку», как на скрине.
  • При перетаскивании точки остаются отсортированными по x; UI не даёт точке пересечь соседнюю (x ограничивается между соседями), на коммите — normalizeCurve.
  • Альтернатива: фиксировать x крайних на 0/1 — отвергнуто, пользователь хочет двигать и по x.

Интерполяция — монотонный кубический Эрмит (PCHIP).

  • Наклоны отрезков d_i, касательные по Fritsch–Carlson (нулевые при смене знака/нулевом наклоне, ограничение против выбросов). Участок между точками считается по кубической формуле Эрмита.
  • SVG-путь: те же касательные переводятся в кубические Безье (P1 = P0 + (h/3, m_i*h/3), P2 = P3 - (h/3, m_{i+1}*h/3)), плюс плоские сегменты от x=0 до первой и от последней до x=1.
  • LUT 256: те же значения; buildCurveLUT (TS) и curveLUT (Go) обязаны совпадать. WASM и WebGL получают готовый LUT из TS — их менять не нужно.
  • Альтернативы: Catmull-Rom (выбросы/гало на тоновой кривой), линейная интерполяция (текущая, отвергнута).

Пресет — кривые всех четырёх каналов.

  • CurvePreset { name: string; channels: { rgb: CurvePoint[]; red: CurvePoint[]; green: CurvePoint[]; blue: CurvePoint[] } }.
  • Каждый канал содержит 2..16 точек; пустых каналов в пресете нет — линейный канал хранится как (0,0),(1,1).
  • Встроенные Линейный, Средний контраст, Сильный контраст — клиентские константы. Контраст задаётся кривой RGB, каналы R/G/B линейные. Предлагаемые точки RGB:
    • Средний: (0,0),(0.25,0.2),(0.5,0.5),(0.75,0.8),(1,1)
    • Сильный: (0,0),(0.2,0.12),(0.5,0.5),(0.8,0.88),(1,1)
  • Сохранение берёт effectiveCurvePoints каждого канала, поэтому в пресет всегда попадают все четыре кривые.
  • Применение заменяет кривые всех четырёх каналов; текущий выбранный канал не меняется.
  • Подпись: имя пресета, если кривые всех четырёх каналов совпадают с пресетом (с допуском), иначе Пользовательский.

Хранение и мерж настроек.

  • preferencesPayload расширяется curve_presets; поля-указатели позволяют отличить «не передано» от «пусто».
  • Handler читает текущие users.preferences, мержит переданные разделы (photo_info, curve_presets), валидирует, пишет и возвращает полный объект. Сохранение «Сведений» с {photo_info} сохраняет пресеты; сохранение пресетов сохраняет photo_info.
  • Валидация: не больше 100 пресетов, имя 1..64, все четыре канала присутствуют и содержат 2..16 точек, x,y конечные в 0..1. Некорректный раздел отклоняется целиком.
  • Альтернативы: отдельный endpoint (больше поверхности), клиент шлёт весь объект (устаревшие вкладки затирают чужие изменения).

Переключение фото.

  • Вкладка правой панели хранится в AlbumPanel и передаётся в PhotoPanel через bind:tab, поэтому переживает перемонтирование панели при загрузке следующего фото.
  • Cleanup эффекта PhotoEditPanel регистрируется на каждом запуске; при смене photo.id накопленные правки отправляются до инициализации нового фото.
  • Ответы сохранений проверяются по id фото и номеру операции: устаревшее сохранение не подменяет текущее фото и его параметры. Панель рендерит только фото, совпадающее с текущим маршрутом.

Пользовательские действия и файлы.

  • Сохранить: диалог имени, кривые всех четырёх каналов; при совпадении имени — подтверждение перезаписи.
  • Переименовать/удалить: действия в меню пользовательского пресета.
  • Экспорт: JSON { version: 1, presets: CurvePreset[] } с кривыми всех каналов, только пользовательские пресеты (встроенные всегда доступны).
  • Импорт: файл-пикер, валидация, upsert по имени, сохранение через preferences; при некорректном файле профиль не меняется.

Совместимость формата.

  • Старый формат { name, channel, points } читается из профиля и импорта: точки попадают в указанный канал, остальные становятся линейными.
  • При следующем сохранении список перезаписывается новым форматом со всеми четырьмя каналами.

UI.

  • Статичный текст «Линейный» заменяется кнопкой, открывающей popover-меню: встроенные, пользовательские, «Сохранить…», «Импортировать…», «Экспортировать…».
  • В SVG крайние точки — такие же перетаскиваемые кружки, как остальные; первый клик по свободному полю вставляет точку между крайними; двойной клик сбрасывает.
  • Сохраняется существующая клавиатурная правка точек.

Risks / Trade-offs

[Старые кривые начнут выглядеть иначе (плавно вместо линейно)] → это осознанное изменение рендера; точки не мигрируются, плоские участки вне крайних сохраняются.

[Расхождение PCHIP в Go и TS] → одна формула, тесты с захардкоженными значениями LUT в обоих языках; WASM/WebGL используют готовый LUT.

[Гонка при мерже preferences] → мерж на сервере по разделам: разные разделы не затирают друг друга; одновременная запись одного раздела — last-write-wins, приемлемо для настроек одного пользователя.

[Рост списка пресетов / большой импорт] → лимиты количества, размера имени и точек; handler уже ограничивает тело 1 МБ.

[Старые пресеты хранятся в формате одного канала] → клиент конвертирует их при чтении/импорте, а первое сохранение пресетов перезаписывает профиль новым форматом.

[Пересечение точек при перетаскивании] → ограничение x соседями и нормализация на коммите.

Migration Plan

Схема не меняется: curve_presets — ключ в JSON-блобе users.preferences. Старый формат пресетов конвертируется на клиенте при чтении. Откат: удалить ключ; старые кривые остаются в light.curve без изменений данных.

Open Questions

Нет. Точные точки «Среднего/Сильного контраста» — настраиваемый дефолт, не влияет на контракт.