chore(openspec): архивировать photo-effects-editing и обновить photos
Изменение применено к основной спеке photos: эффекты, виньетка, зерно, живой предпросмотр, свёрнутые секции панели и редактирование из просмотрщика. Change перемещён в archive.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
# 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
|
||||
|
||||
Нет.
|
||||
Reference in New Issue
Block a user