# 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` (двойной клик сбрасывает значение). - Виньетка: три кнопки-вкладки («Свет», «Цвет», «Закарасить»), под ними сила («Виньетка»), средняя точка, растушёвка, округлость, света; для цветных режимов — свотч цвета (``), значение хранится 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 Нет.