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
Нет. Точные точки «Среднего/Сильного контраста» — настраиваемый дефолт, не влияет на контракт.