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

21 KiB
Raw Permalink Blame History

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, без миграции БД.

{
  "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

Нет.