Изменение применено к основной спеке photos: эффекты, виньетка, зерно, живой предпросмотр, свёрнутые секции панели и редактирование из просмотрщика. Change перемещён в archive.
257 lines
21 KiB
Markdown
257 lines
21 KiB
Markdown
# 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
|
||
|
||
Нет.
|