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