Files
ds-galo/openspec/changes/archive/2026-10-24-photo-effects-editing/design.md
T
iu cb9433666a chore(openspec): архивировать photo-effects-editing и обновить photos
Изменение применено к основной спеке photos: эффекты, виньетка, зерно,
живой предпросмотр, свёрнутые секции панели и редактирование из просмотрщика.
Change перемещён в archive.
2026-10-24 22:58:07 +03:00

257 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design
## Context
См. `proposal.md` — «Why». Текущее состояние, важное для подхода:
- `EditParams` хранится JSON-блобом в `photo.edit_params` (Postgres `jsonb`,
SQLite `TEXT`); роли `edit`, `edit_thumb`, `edit_medium`, `edit_preview`
рендерит фоновый воркер по исходному объекту. Источник истины — Go-рендер.
- Конвейер правок попиксельный: `applyLight`, затем `applyColor`; цвет уже
использует HSL-переход и быстрый путь без него.
- Живой предпросмотр: `WebGLPreview` рисует видимую область одним проходом
фрагментного шейдера; при недоступности WebGL2 — CPU-фолбэк (WASM, затем JS),
который применяет свет и цвет к пикселям кадра.
- `edit_params` — открытый JSON: новые ключи не требуют миграции; фото без
ключа `effects` рендерятся как раньше. Копии фото правки не наследуют.
Ключевое ограничение: текстура, чёткость и дымка — пространственные операции,
им нужны размытие и локальная статистика. Текущие три реализации (Go, GLSL,
WASM) этого не умеют; виньетка и зерно остаются попиксельными.
## Goals / Non-Goals
**Goals:**
- Полный блок «Эффекты» из макета: текстура, чёткость, дымка, виньетка
(режимы «Свет», «Цвет», «Закарасить»), зерно.
- Живой предпросмотр всех эффектов на WebGL2 и его деградация без WebGL.
- Детерминированный серверный рендер и обратная совместимость `edit_params`.
- Разумная стоимость рендера полноразмерного фото в фоновом воркере.
**Non-Goals:**
- Попиксельное совпадение с Lightroom и точное повторение его формул.
- Локальные маски, кисти, градиенты, синхронизация правок между устройствами.
- Пресеты эффектов, экспорт XMP, редактирование на Android.
- Изменение API-контракта правок: по-прежнему `PATCH /photos/:id/edit` с JSON.
## Decisions
**1. `EffectsParams` — новый блок в `EditParams`, без миграции БД.**
```json
{
"light": { "...": "..." },
"color": { "...": "..." },
"effects": {
"texture": 0,
"clarity": 0,
"dehaze": 0,
"vignette": {
"mode": "light",
"amount": 0,
"midpoint": 50,
"feather": 50,
"roundness": 0,
"highlights": 0,
"color": "#000000"
},
"grain": { "amount": 0, "size": 25, "roughness": 50 }
}
}
```
- `mode`: `light` | `color` | `colorize`; неизвестный режим нормализуется в
`light`. `color` — hex-строка `#rrggbb`, нормализуется к нижнему регистру.
- Нормализация заполняет структурные дефолты (midpoint 50, feather 50, size 25,
roughness 50, mode light, color `#000000`) только для полей, отсутствующих в
JSON: явный `0` — валидное значение геометрии и сохраняется. Для этого модель
запоминает факт присутствия поля при разборе (`midpointSet`, `featherSet`,
`sizeSet`, `roughnessSet`). `IsEmpty` игнорирует дефолты при нулевой силе:
пустыми считаются только ненулевые `texture`, `clarity`, `dehaze`,
`vignette.amount`, `grain.amount`. Так `{}` и «дефолты без силы» одинаково
означают отсутствие правок.
- Альтернатива: отдельная таблица/колонка — отвергнута, потребовала бы миграции
и не даёт выгоды для JSON-параметров.
**2. Порядок рендера: свет → цвет → дымка → текстура → чёткость → виньетка → зерно.**
- Дымка — глобальная тональная коррекция, её логично применять до локального
контраста; текстура работает с высокими частотами, чёткость — со средними,
поэтому текстура идёт раньше. Виньетка и зерно — финальные операции над уже
сведённым кадром (аналог post-crop в Lightroom).
- Альтернатива: применять эффекты до цвета — отвергнута: цветокоррекция должна
работать с уже очищенным от дымки кадром, иначе дымка «перекрашивает» цвета.
- Порядок одинаков во всех реализациях; расхождение с сервером допустимо только
в деталях предпросмотра.
**3. Пространственные эффекты считаются в яркости, цвет не смещается.**
- Для каждого пикселя берётся яркость `Y = 0.299r + 0.587g + 0.114b`;
локальный контраст считается по `Y`, а разница применяется аддитивно ко всем
трём каналам. Это сохраняет оттенок и насыщенность и совпадает с принятым в
проекте подходом «яркость аддитивно».
- Текстура: unsharp mask с малым радиусом, `Y' = Y + k * (Y - blur_small(Y))`.
- Чёткость: unsharp mask с большим радиусом и подавлением ореолов — дельта
ограничивается и взвешивается по средним тонам, чтобы не звонить на границах
контрастных объектов.
- Отрицательные значения интерполируют к размытой версии (сглаживание), а не
просто инвертируют знак дельты.
**4. Размытие — быстрый box-blur (три прохода ≈ гаусс), радиус — доля размера кадра.**
- Радиус задаётся относительно `min(width, height)` (текстура ≈ 0.15 %, чёткость
≈ 0.8 %), с минимумом 1 px. Это ключевое решение для совпадения превью и
полноразмерного рендера: сервер считает по оригиналу (например, 4000 px), а
превью — по preview 1200 px; относительный радиус даёт одинаковую частоту.
- Box-blur через скользящее окно по строкам/столбцам — O(n) независимо от
радиуса, поэтому большой радиус чёткости не бьёт по времени. Размытие идёт по
строкам параллельно, как текущие проходы.
- Альтернатива: `imaging.Blur` — проще, но стоимость растёт с радиусом; для
полноразмерных фото и больших радиусов отвергнуто.
**5. Дымка — dark channel prior на уменьшенной копии.**
- Оценка атмосферного света `A`: по уменьшенной копии (≈512 px) берутся самые
яркие пиксели тёмного канала (верхний 0.1 %).
- Карта пропускания `t = 1 - ω * min_c(I_c / A_c)` сглаживается box-blur и
ограничивается снизу (например, 0.1), затем интерполируется на полный кадр.
- `J_c = (I_c - A_c) / max(t, t0) + A_c`; сила эффекта интерполирует между `I` и
`J`. Отрицательные значения — обратная операция: подмешивание `A` и снижение
контраста.
- Альтернатива: простое «поднять контраст и насыщенность» — дешевле, но не
убирает дымку на дальних планах; отвергнуто как не соответствующее смыслу
контрола.
**6. Виньетка — нормализованная геометрия и три режима.**
- Координаты нормализуются к половине кадра; расстояние до центра возводится в
степень, зависящую от округлости (эллипс ↔ суперэллипс/скруглённый
прямоугольник). Средняя точка задаёт начало зоны, растушёвка — ширину
перехода, знак силы выбирает затемнение или осветление.
- «Свет»: аддитивная поправка яркости с весом маски; «Света» защищает яркие
пиксели (уменьшает поправку при высокой `Y`), чтобы виньетка не «съедала»
блики.
- «Цвет»: подмешивание выбранного цвета с сохранением светлоты (смещение в
хроматические координаты). «Закарасить»: заливка выбранным цветом поверх
кадра с весом маски и силы. В обоих цветных режимах «Света» ограничивает
влияние на яркие участки.
- Альтернатива: отдельные реализации на каждый режим — отвергнута, общая маска
даёт одинаковую геометрию и меньше расхождений между сервером и превью.
**7. Зерно — детерминированный процедурный шум.**
- PRNG (например, splitmix64) инициализируется хешем `photo.id`, а не случайным
зерном: повторный рендер тех же параметров даёт тот же рисунок. Это требование
спеки; иначе `edit_thumb` и полноразмерный рендер расходились бы при каждом
прогоне.
- «Размер» задаёт шаг сетки шума (доля размера кадра, с минимумом 1 px),
«шероховатость» смешивает высокочастотный и сглаженный шум. Шум добавляется в
яркость, чтобы не вносить цветные пятна; сила 0 — шум не считается.
- Превью и полноразмерный рендер используют один PRNG, но разные сетки
дискретизации: рисунок визуально похож, побитового совпадения нет.
**8. WebGL2-предпросмотр переходит на много­проходную схему.**
- Проход 1: существующий шейдер света/цвета рисует кадр в FBO-текстуру вместо
экрана.
- Проход 2: яркость `Y`, разделимое box-размытие (горизонталь + вертикаль) в
две ping-pong текстуры; для чёткости — уменьшенное разрешение, для текстуры —
несколько тапов на полном.
- Дымка: `A` и карта `t` считаются на CPU по базовому превью при смене
изображения/параметров дымки и загружаются текстурой (например, 256 px);
шейдер интерполирует.
- Финальный проход: `A`-текстура, размытая яркость, карта дымки, виньетка и
зерно (хеш-шум в шейдере) → экран.
- Во время перетаскивания ползунка (`previewLow`) уменьшаются разрешение
размытия и число проходов; после паузы — полное качество, как сейчас.
- Альтернатива: один проход с приближением (несколько тапов) — проще, но
большой радиус чёткости требует десятков выборок и даёт хуже картинку;
отвергнуто.
**9. CPU-фолбэк: виньетка и зерно — да, пространственные эффекты — после сервера.**
- В JS-фолбэке применяются свет, цвет, виньетка и зерно (попиксельно); текстура,
чёткость и дымка не считаются, чтобы не блокировать main thread размытием
полного кадра, и появляются после серверного рендера. Это допустимо по спеке
(«предпросмотр без WebGL… после серверного рендера»).
- WASM-модуль не трогаем: он остаётся попиксельным и покрывает свет/цвет.
Альтернатива — расширять `no_std`-модуль буферами под blur — отвергнута как
отдельный крупный проект без выигрыша для основного GPU-пути.
**10. Гистограмма остаётся по свету и цвету.**
- Она считается по сэмплу 256 px (`sampleImage` + `applyLight`/`applyColor`);
на таком размере пространственные эффекты бессмысленны, а виньетка/зерно
искажали бы оценку тональности. Оставляем текущий расчёт и явно фиксируем это
решение в коде/дизайне.
**11. UI: секция «Эффекты» после «Цвет».**
- Заголовок сворачиваемый, как «Свет»/«Цвет». Слайдеры — существующий
`EditSlider` (двойной клик сбрасывает значение).
- Виньетка: три кнопки-вкладки («Свет», «Цвет», «Закарасить»), под ними сила
(«Виньетка»), средняя точка, растушёвка, округлость, света; для цветных
режимов — свотч цвета (`<input type="color">`), значение хранится hex-строкой.
- «Зерно»: сила, размер, шероховатость.
- Глобальная кнопка «Сброс» в секции «Свет» продолжает сбрасывать все правки
целиком (включая эффекты) — новых точек сброса не добавляем, чтобы не
дублировать поведение.
- Сохранение — через существующий debounce и `photosApi.edit`; сервер сам решает,
что параметры пустые. Индикатор «рендерится» уже привязан к наличию правок и
отсутствию `edit_preview_url`, поэтому заработает без изменений.
**12. Свёрнутость секций редактора — в localStorage, по умолчанию всё свёрнуто.**
- Все сворачиваемые блоки панели («Свет», «Кривая», «Цвет», «Цветовой микшер»,
«Точный цвет», «Цветокоррекция», «Эффекты») по умолчанию свёрнуты: панель
открывается компактно и не перегружает экран.
- Выбор пользователя хранится в браузере, в localStorage-ключе
`galo.edit.sections` (JSON `{section: collapsed}`), и восстанавливается при
перезагрузке и смене фото. Неизвестные ключи и небулевы значения
игнорируются, ошибки хранилища не ломают панель.
- Альтернатива: хранить в `preferences` профиля — отвергнута: это настройка
конкретного браузера, а не аккаунта; сервер и Android не затрагиваются.
**13. Вкладка «Редактирование» доступна только в просмотрщике.**
- В режиме сетки правая панель показывает только «Сведения»: редактирование
открывается при просмотре фото в просмотрщике, где доступны живой предпросмотр
и пипетка.
- Реализация: у `PhotoPanel` уже есть признак `viewer`; активная вкладка —
`activeTab = viewer ? tab : 'info'`, кнопка «Редактирование» рендерится только
при `viewer`. Сохранённая вкладка не теряется: вернувшись в просмотрщик,
пользователь увидит тот же раздел.
- Альтернатива: показывать вкладку неактивной — отвергнута: лишний элемент в
режиме просмотра сетки и риск случайных правок.
## Risks / Trade-offs
- **[Расхождение превью и серверного рендера]** → относительные радиусы и
геометрия; CPU/GL предпросмотр явно считается приблизительным, источник истины
— сервер. Это документируется в спеке («MAY отличаться деталями»).
- **[Стоимость рендера полноразмерного фото]** → O(n) box-blur вместо
гауссова с большим радиусом; дымка считается на уменьшенной копии; проходы
пропускаются, если эффект пуст; параллелизм по строкам. Добавить бенчмарк на
12 Мп и следить за временем воркера.
- **[Ореолы чёткости]** → ограничение дельты и веса по тонам; при сильных
значениях полностью убрать ореолы нельзя, это осознанный компромисс.
- **[Зерно и thumbnail]** → seed от `photo.id`, иначе фоновая пересборка даёт
разный шум; цена — визуально разные рисунки превью и полного кадра.
- **[Обратная совместимость]** → ключ `effects` игнорируется старым бинарником;
`IsEmpty` учитывает только силы, поэтому дефолты не «залипают» как правка.
- **[Шум в гистограмме]** → эффекты не входят в гистограмму; это осознанно и
может быть пересмотрено отдельным изменением.
## Migration Plan
Схемных миграций нет. Выкатка сервер + фронтенд одновременно; старые фото и
старые `edit_params` читаются с дефолтами. Откат — возврат предыдущей сборки:
неизвестный ключ `effects` старые версии просто игнорируют, параметры остаются в
метаданных и сбрасываются штатной кнопкой «Сброс».
## Open Questions
Нет.