Files
iu 4d23e76ab5 chore(openspec): архивировать fix-edit-preview-handoff и обновить photos, realtime
- photos: признак незавершённого рендера правок, мгновенный показ сброса,
  бесшовная подмена предпросмотра
- realtime: события жизненного цикла рендера правок в photo.updated
2026-10-25 10:04:08 +03:00

13 KiB
Raw Permalink Blame History

Design

Context

Просмотрщик уже рисует клиентский предпросмотр правок: canvas с отфильтрованной видимой областью лежит поверх базовой версии фото (original_url в зуме, preview_url иначе). Решение «показывать ли серверную версию» сейчас принимается по флагам hasEdits, pending (локальные параметры ≠ сохранённым) и !editUrl (Viewer.svelte) — то есть по наличию URL, а не по состоянию рендера.

Сохранение отложено на 10 секунд (PhotoEditPanel.svelte): PATCH /photos/:id/edit ставит задание в edit_jobs и публикует photo.updated, затем воркер публикует photo.updated после вариантов превью и ещё раз после полного варианта. Таблица edit_jobs уже существует (photo_id — первичный ключ), миграции не нужны. Обогащение URL — attachURLs, через него проходят все выдачи фото, включая события SSE. У Viewer есть вторая копия фото fresh, перезапрашиваемая по REST, и её URL имеют приоритет над current, хотя актуальные приходят по SSE.

Goals / Non-Goals

Goals:

  • API даёт однозначный признак «рендер сохранённых правок не завершён».
  • Просмотрщик выбирает источник изображения по состоянию, а не по наличию URL.
  • Подмена предпросмотра серверной версией происходит без показа оригинала или предыдущей отрендеренной версии.
  • Сброс правок виден сразу, в том числе при возврате контролов в значения по умолчанию.

Non-Goals:

  • Менять 10-секундный дебаунс сохранения и схему БД.
  • Новые состояния рендера (ошибки, приоритеты, отмена), статус ошибки рендера.
  • Кроссфейд/анимация подмены.
  • Изменения Android-клиента и синхронизация правок между устройствами сверх SSE.

Decisions

D1. edit_pending — булев признак из наличия задания в edit_jobs, считается в SQL

  • В models.Photo добавляется EditPending bool с JSON-тегом edit_pending. Поле присутствует всегда; его отсутствие означает старый бэкенд и трактуется клиентом как false.
  • В общие списки колонок фото (photoColumns в Postgres, sqlitePhotoColumns в SQLite) добавляется EXISTS (SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id); сканирование поля добавляется в функции чтения фото (включая чтение корзины). В корзине попутно исправляется порядок чтения: tags JSON и album_title были перепутаны (проверяется регрессионным тестом в контракте).
  • Обоснование: ответы сохранения/сброса и оба события photo.updated собираются через GetByID, списки альбома — через List; один SELECT даёт признак во всех путях, без дополнительных запросов и без изменений в сервисном слое.
  • Альтернативы:
    • отдельный метод репозитория PendingEditIDs + обогащение в сервисе — больше точек изменения и N+1 для списков; отвергнуто;
    • хранить ревизию или хэш отрендеренных параметров (миграция и сравнение на клиенте) — точнее переживает неудачный рендер, но дороже; выносится в отдельное изменение, если понадобится;
    • клиентская эвристика «URL изменился с момента сохранения» — хрупкая (сброс, повторный рендер, несколько вкладок); отвергнуто.
  • Семантика: true, пока строка задания есть (ожидает, выполняется, повтор); false после CompleteEditJob/DeleteEditJob, включая сброс. Поиск идёт по первичному ключу edit_jobs.photo_id.
  • Совместимость: поле аддитивное, миграций нет; старые клиенты его игнорируют.

D2. Клиентская машина состояний просмотра

dirty (локальные ≠ сохранённым) edit_pending Что показывает просмотрщик
true не важно базовая версия + canvas с локальными правками; canvas не рисуется, если локальные правки пусты (сброс)
false true базовая версия + canvas с сохранёнными правками
false false серверная версия; canvas держится до загрузки/декодирования нового изображения
  • «Базовая версия» — original_url в зуме, preview_url иначе (без edit-ролей).
  • «Серверная версия» — edit_url в зуме, edit_preview_url иначе; если edit-вариантов нет, показывается базовая версия.
  • edit_pending отсутствует в ответе (старый бэкенд) — считается false (поведение как раньше, без падений).
  • Когда dirty и локальные правки пусты (сброс возвратом контролов) — сразу базовая версия, без canvas и без опоры на серверные edit-URL. Это делает сброс мгновенным.
  • Обоснование: все три наблюдаемых симптома — следствие того, что показ серверной версии включался по !editUrl и hasEdits, а не по «серверный рендер соответствует сохранённым параметрам и картинка готова к показу».

D3. Подмена — только после загрузки и декодирования

  • При переходе в состояние «серверная версия» клиент предзагружает целевой URL (скрытый Image) и ждёт decode() (фолбэк — load); только затем снимает canvas и меняет src. До этого canvas остаётся, поэтому оригинал не мелькает.
  • Canvas поднимается только на время перехода «предпросмотр → серверная версия»: при обычном открытии фото с уже готовым рендером серверную версию грузит сам <img>, лишний кадр оригинала не показывается.
  • Устаревшие предзагрузки отменяются по счётчику/AbortSignal, чтобы поздний ответ не вернул предыдущую версию.
  • Цена — небольшая задержка финального кадра (обычно сотни мс), зато без мигания.
  • Кроссфейд отвергнут: спекой не требуется, лишняя косметика.

D4. Один актуальный источник фото в Viewer

  • current (обновляется SSE photo.updated) — основной источник URL и состояния.
  • fresh остаётся только фолбэком для страниц без SSE (просмотр по share-ссылке) и первичной загрузки; при выборе URL приоритет у актуального объекта, а refetch защищён счётчиком последовательности, чтобы устаревший ответ не перебил новый.
  • Обоснование: photoFullUrl(fresh) всегда возвращает хотя бы original_url, поэтому протухший fresh показывает именно оригинал.

D5. Сброс: показ — мгновенно, отправка — по текущей политике дебаунса

  • Дебаунс сохранения не меняется: показ исходного вида обеспечивает D2 (dirty + пустые параметры → базовая версия), а отложенный flushSave/resetEdit лишь приводит сервер в соответствие. При уходе из панели flushSave уже отправляет сброс.
  • Альтернатива — отправлять сброс сразу, без дебаунса — уменьшает расхождение между вкладками, но добавляет отдельную политику сохранения и DELETE на каждое обнуление контролов; отвергнуто.
  • Следствие: другие вкладки и устройства увидят сброс после дебаунса — как и любое отложенное сохранение; это осознанный компромисс.

D6. Индикатор рендера в панели редактора

  • rendering в панели считается как photo.edit_pending, а не как hasEdits && !photo.edit_preview_url: так индикатор корректен и для повторной правки, и после сброса.

Risks / Trade-offs

  • Рендер падает после всех попыток → задание удаляется, edit_pending=false, остаётся последняя успешная отрендеренная версия, клиент покажет её. Поведение не хуже текущего; отдельный статус ошибки рендера — вне этого изменения.
  • Смешанные версии бэкенда и фронтенда → аддитивное поле: новый фронтенд со старым бэкендом трактует отсутствие как false и работает как раньше; новый бэкенд со старым фронтендом безвреден. Деплой в любом порядке, рекомендуется сначала бэкенд.
  • Дополнительная колонка в горячих SELECT фото → EXISTS по первичному ключу, стоимость пренебрежимо мала; при необходимости позже выносится в отдельное обогащение.
  • Порядок SSE-событий → сохранение публикует photo.updated с edit_pending=true, готовность — с false; клиент зависит только от значения, не от числа событий. Гонка «воркер завершил рендер раньше reload в publishEdit» даёт false и новые URL — консистентно.
  • Большие full-res файлы → финальный кадр появится позже, пока canvas с предпросмотром продолжает показываться; это ожидаемое поведение.

Migration Plan

  • Миграций БД нет. Бэкенд и фронтенд разворачиваются независимо (аддитивное поле); рекомендация — сначала бэкенд.
  • Откат: достаточно вернуть прежний Viewer/тип (поле бэкенда безвредно); откат бэкенда не ломает старый фронтенд.