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

152 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Design
## Context
Просмотрщик уже рисует клиентский предпросмотр правок: canvas с отфильтрованной
видимой областью лежит поверх базовой версии фото (`original_url` в зуме,
`preview_url` иначе). Решение «показывать ли серверную версию» сейчас принимается
по флагам `hasEdits`, `pending` (локальные параметры ≠ сохранённым) и `!editUrl`
([Viewer.svelte](frontend/src/lib/components/Viewer.svelte)) — то есть по наличию
URL, а не по состоянию рендера.
Сохранение отложено на 10 секунд ([PhotoEditPanel.svelte](frontend/src/lib/components/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/тип (поле бэкенда безвредно); откат
бэкенда не ломает старый фронтенд.