Изменение применено к основной спеке photos: эффекты, виньетка, зерно, живой предпросмотр, свёрнутые секции панели и редактирование из просмотрщика. Change перемещён в archive.
21 KiB
Design
Context
См. proposal.md — «Why». Текущее состояние, важное для подхода:
EditParamsхранится JSON-блобом вphoto.edit_params(Postgresjsonb, SQLiteTEXT); роли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
Нет.