feat(photos): добавить пресеты кривой и починить переключение фото

- PCHIP для предпросмотра и серверного рендера
- пресеты всех каналов в профиле, импорт и экспорт
- мерж настроек auth без потери разделов
- сохранение вкладки и правок текущего фото при переключении
This commit is contained in:
iu
2026-10-23 22:56:55 +03:00
parent 3b8406fb4c
commit 0bfee5f2f8
20 changed files with 2127 additions and 105 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-23
@@ -0,0 +1,148 @@
# 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
Нет. Точные точки «Среднего/Сильного контраста» — настраиваемый дефолт, не влияет
на контракт.
@@ -0,0 +1,73 @@
# Proposal
## Why
Тоновая кривая сейчас ведёт себя не как инструмент из Lightroom: крайние узлы не
существуют до первых кликов, линия строится по прямым сегментам, а готовых
пресетов контраста и пользовательских пресетов нет. Пользователь не может быстро
применить типовой контраст или сохранить свою кривую. Дополнительно
`PATCH /auth/me/preferences` заменяет весь объект настроек, поэтому хранение
пресетов в профиле без мержа затирало бы другие разделы.
## What Changes
- **Кривая.** Крайние узлы `(0,0)` и `(1,1)` существуют сразу и являются обычными
точками; первый клик добавляет точку между ними. Крайние узлы двигаются по x и
y, вне них кривая постоянна («полки», как на скрине). Линия и рендер используют
плавную монотонную кубическую интерполяцию (PCHIP): SVG-путь и LUT в Go/TS.
«Сбросить» возвращает линейную кривую.
- **Встроенные пресеты.** `Линейный`, `Средний контраст`, `Сильный контраст` —
каждый описывает все четыре канала: контраст задаётся кривой RGB, каналы
R/G/B остаются линейными. Подпись показывает активный пресет; при ручной
правке любого канала — `Пользовательский`.
- **Пользовательские пресеты.** Пресет описывает кривые всех каналов
(`name`, `channels: { rgb, red, green, blue }`). Сохранение текущих кривых
всех каналов, применение заменяет все четыре кривые, переименование и
удаление. Хранение в `users.preferences.curve_presets` (профиль пользователя),
приватно для пользователя.
- **Мерж настроек.** `PATCH /auth/me/preferences` принимает `photo_info` и
`curve_presets` по отдельности, мержит переданные разделы с сохранёнными,
валидирует и возвращает полный merged-объект. Сохранение «Сведений» больше не
затирает пресеты, и наоборот.
- **Импорт/экспорт.** JSON-файл `{ version: 1, presets: [...] }`: экспорт
пользовательских пресетов, импорт с добавлением в профиль (при совпадении имени
— перезапись).
- **Совместимость.** Миграций схемы нет: пресеты лежат в JSON-блобе
`preferences`. Старые пресеты формата `{ channel, points }` конвертируются в
четыре канала при чтении и перезаписываются при следующем сохранении.
Существующие сохранённые кривые начнут интерполироваться плавно, поэтому их
визуальный результат может немного измениться.
## Capabilities
### New Capabilities
Нет — поведение входит в существующие способности `photos` и `auth`.
### Modified Capabilities
- `photos`: добавляются требования «Тоновая кривая» (крайние узлы, плавная
интерполяция, встроенные пресеты, сброс) и «Пользовательские пресеты кривой»
(пресет из четырёх каналов, сохранение/применение/переименование/удаление,
импорт/экспорт).
- `auth`: требование «Хранение пользовательских настроек интерфейса»
расширяется мержем разделов и хранением пользовательских пресетов кривой;
добавляется сценарий «сохранение одного раздела не затирает другие».
## Impact
- `backend/internal/auth/handlers.go` — `preferencesPayload` с `curve_presets`,
мерж разделов, валидация имени и кривых всех четырёх каналов.
- `backend/internal/auth/store.go` / `sqlite_store.go` — без изменений схемы;
запись merged-JSON через существующий `UpdatePreferences`.
- `backend/internal/photos/edit.go` — `curveEval`/`curveLUT` переходят на PCHIP;
тесты Go на гладкость/LUT.
- `frontend/src/lib/edit.ts` — PCHIP в `buildCurveLUT`, типы и нормализация
пресетов, точки кривой.
- `frontend/src/lib/components/PhotoEditPanel.svelte` — узлы-углы, плавный SVG-путь,
меню пресетов, сохранение/переименование/удаление, импорт/экспорт.
- `frontend/src/lib/types.ts`, `stores/auth.svelte.ts`, `api.ts` — тип
`Preferences.curve_presets`, сохранение через существующий endpoint.
- Новые вспомогательные модули: меню/диалоги пресетов, импорт/экспорт JSON.
- Тесты: `edit_test.go`, `edit.test.ts`, тесты мержа/валидации preferences,
e2e сохранения пресетов в профиле.
@@ -0,0 +1,26 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Хранение пользовательских настроек интерфейса
Система SHALL хранить пользовательские настройки (отображение интерфейса и пресеты кривой) в профиле пользователя и SHALL возвращать их при получении текущего пользователя. Настройки SHALL быть приватными для пользователя. Отсутствие настроек SHALL трактоваться как значения по умолчанию. Сохранение одного раздела настроек SHALL не затирать остальные разделы.
#### Scenario: Получение настроек
- **WHEN** аутентифицированный пользователь запрашивает свой профиль
- **THEN** ответ содержит его настройки отображения и пресеты кривой (или пустой объект по умолчанию)
#### Scenario: Сохранение настроек
- **WHEN** пользователь сохраняет настройки
- **THEN** система сохраняет их в профиле и возвращает сохранённое состояние
#### Scenario: Сохранение раздела не затирает другие
- **WHEN** пользователь сохраняет только один раздел настроек (например, сведения о фото или пресеты кривой)
- **THEN** остальные разделы профиля сохраняются без изменений
#### Scenario: Изоляция настроек
- **WHEN** другой пользователь запрашивает свой профиль
- **THEN** он не получает настройки первого пользователя
#### Scenario: Некорректные настройки
- **WHEN** пользователь присылает настройки недопустимой структуры или размера
- **THEN** система отклоняет запрос с ошибкой и не меняет сохранённые настройки
@@ -0,0 +1,84 @@
# Spec Delta
## ADDED Requirements
### Requirement: Тоновая кривая
Тоновая кривая SHALL задаваться контрольными точками для каналов RGB, R, G и B. Крайние точки SHALL существовать по умолчанию, SHALL двигаться по обеим осям и SHALL задавать плоские участки вне себя. Кривая SHALL быть плавной и монотонной между точками; первый клик по полю SHALL добавлять точку между крайними. Сброс SHALL возвращать линейную кривую.
#### Scenario: Крайние узлы существуют с самого начала
- **WHEN** пользователь открывает инструмент «Кривая»
- **THEN** у каждого канала уже есть крайние точки, и их можно перетаскивать
#### Scenario: Добавление точки
- **WHEN** пользователь кликает по свободному полю кривой
- **THEN** между крайними точками добавляется новая точка, а крайние остаются на месте
#### Scenario: Подвижные крайние узлы
- **WHEN** пользователь двигает крайнюю точку по x или y
- **THEN** вне крайних точек кривая остаётся плоской, а между точками — плавной
#### Scenario: Плавная интерполяция
- **WHEN** кривая сохранена и отрендерена
- **THEN** предпросмотр и итоговое фото используют одну и ту же плавную монотонную кривую
#### Scenario: Встроенные пресеты
- **WHEN** пользователь выбирает «Линейный», «Средний контраст» или «Сильный контраст»
- **THEN** кривые всех четырёх каналов заменяются кривыми пресета (контраст — в RGB, R/G/B — линейные), а подпись показывает выбранный пресет
#### Scenario: Пользовательская правка
- **WHEN** пользователь вручную меняет точки кривой
- **THEN** подпись показывает «Пользовательский»
#### Scenario: Сброс
- **WHEN** пользователь сбрасывает кривую
- **THEN** текущий канал возвращается к линейной кривой с крайними точками
#### Scenario: Независимые каналы
- **WHEN** пользователь выбирает канал R, G или B
- **THEN** он меняет только выбранный канал, остальные каналы остаются прежними
### Requirement: Переключение фото в редакторе
При переключении фото правая панель SHALL сохранять выбранную вкладку. Несохранённые правки SHALL сохраняться тому фото, для которого они сделаны, в том числе если пользователь уже переключился на другое фото. Ответы фоновых запросов SHALL NOT подменять текущее фото или его параметры.
#### Scenario: Вкладка не меняется
- **WHEN** пользователь переключает фото
- **THEN** выбранная вкладка правой панели остаётся прежней
#### Scenario: Правки уходят текущему фото
- **WHEN** пользователь меняет настройки после переключения
- **THEN** изменения сохраняются текущему фото, а не предыдущему
#### Scenario: Фоновое сохранение не подменяет фото
- **WHEN** сохранение предыдущего фото завершается после переключения
- **THEN** панель продолжает показывать текущее фото, а его параметры не меняются
### Requirement: Пользовательские пресеты кривой
Пользователь SHALL сохранять текущие кривые всех каналов (RGB, R, G и B) как именованный пресет, применять, переименовывать и удалять его. Пресет SHALL содержать кривую для каждого из четырёх каналов. Пресеты SHALL храниться в профиле пользователя и SHALL быть приватными. Импорт и экспорт SHALL выполняться JSON-файлом; импорт SHALL добавлять пресеты в профиль.
#### Scenario: Сохранение пресета
- **WHEN** пользователь сохраняет текущие кривые под именем
- **THEN** пресет появляется в списке и сохраняется в профиле вместе с кривыми RGB, R, G и B
#### Scenario: Применение пресета
- **WHEN** пользователь выбирает пользовательский пресет
- **THEN** кривые всех четырёх каналов заменяются сохранёнными, а текущий канал не меняется
#### Scenario: Переименование и удаление
- **WHEN** пользователь переименовывает или удаляет пресет
- **THEN** список пресетов и профиль обновляются
#### Scenario: Приватность пресетов
- **WHEN** другой пользователь открывает свои пресеты
- **THEN** он не видит пресеты первого пользователя
#### Scenario: Экспорт пресетов
- **WHEN** пользователь экспортирует пресеты
- **THEN** он получает JSON-файл с пользовательскими пресетами и кривыми всех четырёх каналов
#### Scenario: Импорт пресетов
- **WHEN** пользователь импортирует корректный JSON-файл
- **THEN** пресеты добавляются или обновляются в его профиле
#### Scenario: Некорректный файл или пресет
- **WHEN** имя или кривая хотя бы одного канала недопустимы
- **THEN** система отклоняет пресет и не меняет профиль
@@ -0,0 +1,49 @@
# Tasks
## 1. Общая математика кривой (TS + Go)
- [x] 1.1 Заменить линейную интерполяцию в `frontend/src/lib/edit.ts` на монотонный кубический Эрмит (PCHIP): наклоны, касательные, оценка между точками, плоские участки вне крайних — проверить `npm run test` (`edit.test.ts`, значения LUT и монотонность)
- [x] 1.2 Повторить ту же формулу в `backend/internal/photos/edit.go` (`curveEval`/`curveLUT`) — проверить `go test ./internal/photos/ -run TestCurve -v`
- [x] 1.3 Сверить Go и TS на нескольких кривых (линейная, средний/сильный контраст, кривая с крайними внутри) захардкоженными значениями LUT — проверить оба набора тестов
## 2. Backend: мерж настроек и пресеты
- [x] 2.1 Расширить `preferencesPayload` в `backend/internal/auth/handlers.go` полем `curve_presets` и валидацией (лимит количества, имя 1..64, 2..16 точек на канал, x/y в 0..1) — проверить `go test ./internal/auth/ -v`
- [x] 2.2 Сделать серверный мерж разделов: читать текущие `preferences`, мержить `photo_info`/`curve_presets`, писать и возвращать merged-объект — проверить тестом «сохранение photo_info не затирает пресеты и наоборот»
- [x] 2.3 Добавить e2e-проверку сохранения пресетов в профиле и их приватности — проверить `go test ./internal/e2e/ -run TestCurvePresets -v`
## 3. UI: узлы и плавная кривая
- [x] 3.1 Показывать крайние точки `(0,0)` и `(1,1)` по умолчанию; первый клик вставляет точку между ними; крайние двигаются по x/y, вне них — плоские участки — проверить вручную и `npm run check`
- [x] 3.2 Рисовать SVG-путь кубическими Безье по тем же касательным (плоские сегменты до/после крайних) — проверить визуально, что линия плавная
- [x] 3.3 Сохранить клавиатурную правку точек, двойной клик-сброс и ограничение x между соседями — проверить `npm run check` и ручным сценарием
## 4. UI: пресеты и файлы
- [x] 4.1 Заменить статичный «Линейный» на меню встроенных пресетов (`Линейный`, `Средний контраст`, `Сильный контраст`) с активной подписью/«Пользовательский» — проверить `npm run check`
- [x] 4.2 Добавить тип `Preferences.curve_presets` и сохранение/применение/переименование/удаление пользовательских пресетов через существующий endpoint — проверить `npm run test` и сохранение в профиле
- [x] 4.3 Реализовать импорт/экспорт JSON `{ version: 1, presets: [...] }` с валидацией и upsert по имени — проверить ручным импортом/экспортом и `npm run check`
## 5. Интеграция и проверка
- [x] 5.1 Прогнать `npm run check`, `npm run test`, `go test ./...`, `make build` — всё зелёное
- [x] 5.2 Проверить, что WebGL и WASM используют новый LUT, а старые сохранённые кривые рендерятся плавно без миграции данных — визуально и тестами
- [x] 5.3 Проверить приватность и лимиты пресетов (чужой профиль, слишком длинное имя, >16 точек) — тестами `internal/auth`/`internal/e2e`
## 6. Правка: пресет описывает все каналы
- [x] 6.1 Обновить `CurvePreset` в `frontend/src/lib/types.ts` и `curve-presets.ts`: `channels` для RGB/R/G/B, встроенные пресеты и поиск активного пресета по всем каналам — проверить `npm run test`
- [x] 6.2 Обновить серверную модель и валидацию `curve_presets` в `backend/internal/auth/handlers.go`: объект `channels` со всеми четырьмя каналами — проверить `go test ./internal/auth/ -v`
- [x] 6.3 Обновить сохранение и применение в `PhotoEditPanel.svelte` и `CurvePresetMenu.svelte`: сохранять и заменять все четыре канала, не переключать текущий канал — проверить `npm run check`
- [x] 6.4 Обновить импорт/экспорт и совместимость со старым форматом `{name, channel, points}`, тесты и e2e — проверить `npm run test` и `go test ./internal/e2e/ -run TestCurvePresets -v`
## 7. Переключение фото
- [x] 7.1 Сохранять вкладку правой панели при смене фото, вынеся её состояние в `AlbumPanel` — проверить в браузере
- [x] 7.2 Исправить регистрацию cleanup в `PhotoEditPanel`, чтобы накопленные правки отправлялись при переключении — проверить в браузере
- [x] 7.3 Защитить панель от устаревших ответов сохранения/загрузки и кратковременного `photo = null` — проверить `npm run check`
## Workflow follow-up
- Архивировать изменение после удовлетворения требований ревью проекта.
- Проверить архивированный результат.