docs(openspec): изменение fix-edit-preview-handoff

- proposal/design/tasks и delta-спеки photos, realtime
- серверный признак edit_pending, бесшовная подмена предпросмотра, мгновенный сброс
This commit is contained in:
iu
2026-10-25 10:02:04 +03:00
parent 4d5cf3c1cb
commit 1c29f5d961
6 changed files with 374 additions and 0 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-25
@@ -0,0 +1,151 @@
# 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/тип (поле бэкенда безвредно); откат
бэкенда не ломает старый фронтенд.
@@ -0,0 +1,69 @@
# Proposal
## Why
Клиент узнаёт о готовности серверного рендера правок только по появлению
`edit_url`/`edit_preview_url` в объекте фото, а признака «рендер сохранённых правок
ещё идёт» в API нет. Из-за этого в просмотрщике:
- после отложенного сохранения (дебаунс 10 с) при появлении полного рендера
клиентский предпросмотр выключается раньше, чем новое изображение загружено, и
в зуме на время загрузки виден оригинал (мигание «оригинал → правка»);
- повторная правка, у которой уже есть предыдущий рендер, после сохранения
показывает старую отрендеренную версию, пока не завершится новый рендер;
- сброс, сделанный возвратом контролов в значения по умолчанию, не виден до
срабатывания дебаунса: локальные параметры уже пусты, предпросмотр выключен, и
просмотрщик показывает старую серверную версию — «сброс срабатывает через 10 с».
## What Changes
- Фото получает аддитивный признак незавершённого рендера правок — булево поле
`edit_pending` в ответах API и в событиях `photo.updated`: `true`, пока в
`edit_jobs` есть задание на рендер; `false`, когда рендера нет или он завершён.
- Клиент перестаёт угадывать готовность по наличию URL и опирается на признак:
пока есть несохранённые локальные правки или серверный рендер не завершён,
просмотрщик показывает клиентский предпросмотр поверх исходной версии.
- Замена предпросмотра серверной версией выполняется только после загрузки и
декодирования нового изображения — без промежуточного показа оригинала или
предыдущей отрендеренной версии.
- Сброс правок (кнопкой и возвратом контролов в значения по умолчанию) виден в
просмотрщике сразу, не дожидаясь отложенного сохранения.
- Viewer перестаёт предпочитать устаревшую локальную копию фото: URL для показа
берутся из актуального объекта, обновляемого SSE-событиями.
- Индикатор «Рендерим правки на сервере…» в панели редактора опирается на
`edit_pending`, а не на отсутствие URL.
- Без изменений: отложенное сохранение (10 с), неразрушающий рендер, роли
объектов, формат `edit_params`, схема БД, Android-клиент (поле аддитивное,
отсутствие трактуется как `false`).
## Capabilities
### New Capabilities
Нет — изменение уточняет существующие способности.
### Modified Capabilities
- `photos`: новый признак незавершённого рендера правок; уточняются «Живой
предпросмотр цветовых правок», «Живой предпросмотр эффектов» (бесшовная
подмена) и добавляется требование о мгновенном показе сброса.
- `realtime`: `photo.updated` отражает жизненный цикл рендера правок — сохранение
(`edit_pending=true`), готовность (`edit_pending=false` + URL отредактированных
вариантов), сброс (без URL отредактированных вариантов).
## Impact
- `backend/internal/models/models.go` — поле `EditPending` в `Photo`.
- `backend/internal/photos/store.go`, `sqlite_store.go` — `EXISTS` по `edit_jobs`
в общем списке колонок; сканирование поля. Миграции не нужны: таблица уже есть.
- `backend/internal/photos/edit_service.go`, `worker.go`, `handlers.go` — признак
попадает в ответы сохранения/сброса и в события (через общие запросы фото).
- `frontend/src/lib/types.ts` — поле в типе `Photo`.
- `frontend/src/lib/components/Viewer.svelte` — выбор источника изображения по
состоянию (локальные правки / `edit_pending` / загруженность нового кадра),
предзагрузка и декодирование, единый актуальный источник URL.
- `frontend/src/lib/components/PhotoEditPanel.svelte` — статус рендера.
- Новый чистый модуль выбора источника предпросмотра + vitest.
- Тесты: контрактные (репозитории, SQLite и Postgres), e2e рендера правок,
frontend-модульные тесты, проверка `svelte-check`.
- API: аддитивное поле `edit_pending`; старые клиенты (Android) его игнорируют.
@@ -0,0 +1,91 @@
# Spec Delta: photos
## ADDED Requirements
### Requirement: Признак незавершённого рендера правок
Фото в ответах API и в событиях `photo.updated` SHALL содержать булево поле `edit_pending`. Оно SHALL быть `true`, пока серверный рендер сохранённых правок не завершён, и `false`, когда рендера нет или он завершён. Пока `edit_pending` равно `true`, URL отредактированных вариантов SHALL NOT считаться актуальными, даже если они присутствуют. Поле SHALL быть аддитивным: клиент, который его не читает, продолжает работать как раньше.
#### Scenario: Сохранение правок
- **WHEN** правки фото сохранены, а серверный рендер ещё не завершён
- **THEN** ответ на сохранение и событие `photo.updated` содержат `edit_pending=true`
#### Scenario: Предыдущий рендер устарел
- **WHEN** у фото уже есть отрендеренная версия, а для новых сохранённых правок рендер ещё не завершён
- **THEN** фото содержит `edit_pending=true`, а URL отредактированных вариантов не выдаются за актуальные
#### Scenario: Готовность рендера
- **WHEN** серверный рендер сохранённых правок завершён
- **THEN** `edit_pending=false`, а URL отредактированных вариантов соответствуют сохранённым правкам
#### Scenario: Сброс правок
- **WHEN** правки фото сброшены
- **THEN** `edit_pending=false` и URL отредактированных вариантов отсутствуют
### Requirement: Мгновенный показ сброса правок
Клиент SHALL показывать исходный вид фото сразу после возврата контролов редактора в значения по умолчанию или нажатия сброса, не дожидаясь отложенного сохранения на сервер. Пока сброс, выполненный возвратом контролов, ещё не отправлен, просмотрщик SHALL NOT показывать отредактированную версию фото.
#### Scenario: Сброс возвратом контролов
- **WHEN** пользователь возвращает контролы редактора в значения по умолчанию (двойной клик по ползунку, шкала в нуле)
- **THEN** просмотрщик сразу показывает исходный вид, не дожидаясь срабатывания отложенного сохранения
#### Scenario: Сброс кнопкой
- **WHEN** пользователь нажимает «Сброс»
- **THEN** просмотрщик показывает исходный вид не позже ответа на запрос сброса
#### Scenario: Сброс при готовом рендере
- **WHEN** сброс выполнен, а отредактированная версия ещё существует на сервере
- **THEN** просмотрщик показывает исходный вид, а не отредактированную версию
## MODIFIED Requirements
### Requirement: Живой предпросмотр цветовых правок
Клиент SHALL показывать результат цветовых правок в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или пока локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После завершения рендера SHALL показываться серверная версия. Клиент SHALL NOT показывать исходную или предыдущую отрендеренную версию между предпросмотром и серверной версией: подмена SHALL выполняться только после загрузки нового изображения.
#### Scenario: Правки видны сразу
- **WHEN** пользователь двигает цветовой контрол
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
#### Scenario: Серверная версия заменяет предпросмотр
- **WHEN** серверный рендер цветовых правок завершён
- **THEN** просмотрщик показывает отрендеренную версию
#### Scenario: Бесшовная подмена предпросмотра
- **WHEN** серверный рендер завершён, но новое изображение ещё загружается
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
### Requirement: Живой предпросмотр эффектов
Клиент SHALL показывать эффекты в просмотрщике до завершения серверного рендера. Пока серверный рендер сохранённых правок не завершён (`edit_pending`) или пока локальные правки не сохранены, клиент SHALL показывать клиентский предпросмотр поверх исходной версии. После готовности серверной версии клиент SHALL показывать её после загрузки нового изображения, не показывая в промежутке оригинал или предыдущую отрендеренную версию. Предпросмотр пространственных эффектов (текстура, чёткость, дымка) MAY отличаться от серверного рендера деталями, но SHALL передавать характер эффекта.
#### Scenario: Эффекты видны сразу
- **WHEN** пользователь двигает ползунок эффекта
- **THEN** просмотрщик обновляет изображение без ожидания серверного рендера
#### Scenario: Серверная версия заменяет предпросмотр
- **WHEN** серверный рендер эффектов завершён
- **THEN** просмотрщик показывает серверную версию
#### Scenario: Бесшовная подмена предпросмотра
- **WHEN** серверный рендер эффектов завершён, но новое изображение ещё загружается
- **THEN** просмотрщик продолжает показывать предпросмотр и не показывает оригинал или предыдущую отрендеренную версию
#### Scenario: Предпросмотр без WebGL
- **WHEN** аппаратное ускорение недоступно
- **THEN** клиент показывает эффекты доступным способом или показывает их после серверного рендера, не блокируя интерфейс
@@ -0,0 +1,38 @@
# Spec Delta: realtime
## MODIFIED Requirements
### Requirement: События альбома
Система SHALL публиковать события photo.created, photo.updated, photo.deleted,
comment.created, like.added, like.removed, member.joined, member.left, album.updated.
Событие photo.created SHALL обозначать появление фото в альбоме, а готовность вариантов
существующего фото SHALL публиковаться как photo.updated. Сохранение, готовность и сброс
правок фото SHALL также публиковаться как photo.updated, неся аддитивный признак
незавершённого рендера правок (`edit_pending`) и актуальность URL отредактированных
вариантов.
#### Scenario: Новое фото
- **WHEN** в альбоме появляется фото
- **THEN** подписчики получают событие photo.created с метаданными и URL превью
#### Scenario: Готовы варианты
- **WHEN** для существующего фото сгенерированы thumb и preview
- **THEN** подписчики получают событие photo.updated с этим фото
#### Scenario: Сохранены правки фото
- **WHEN** пользователь сохраняет правки фото, а рендер ещё не завершён
- **THEN** подписчики получают событие photo.updated с `edit_pending=true`
#### Scenario: Рендер правок готов
- **WHEN** серверный рендер сохранённых правок завершён
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и URL отредактированных вариантов
#### Scenario: Правки фото сброшены
- **WHEN** правки фото сброшены
- **THEN** подписчики получают событие photo.updated с `edit_pending=false` и без URL отредактированных вариантов
@@ -0,0 +1,23 @@
# Tasks
## 1. Бэкенд: признак `edit_pending` в модели и запросах
- [x] 1.1 Добавить `EditPending bool` в `models.Photo` (JSON-тег `edit_pending`, поле присутствует всегда) и `EXISTS (SELECT 1 FROM edit_jobs ej WHERE ej.photo_id = p.id)` в общие списки колонок фото для Postgres (`photoColumns`) и SQLite (`sqlitePhotoColumns`); обновить сканирование поля в функциях чтения фото и корзины. Проверка: `cd backend && go build -tags nodynamic ./...` и `go test ./internal/photos/` проходят.
- [x] 1.2 Покрыть контрактными тестами оба диалекта: после `EnqueueEditJob` фото из `GetByID` и `List` имеет `EditPending=true`, после `CompleteEditJob` и `DeleteEditJob` — `false`. Проверка: `cd backend && go test ./internal/repo/ -v`; с `TEST_DATABASE_URL` тот же набор проходит и на Postgres.
- [x] 1.3 Добавить e2e-проверку жизненного цикла: `EditPhoto` возвращает `edit_pending=true` (в том числе при повторной правке, когда старый рендер ещё привязан), `Worker.ProcessOnce` — `false` вместе с URL отредактированных вариантов, `ResetEdit` — `false` без URL. Проверка: `cd backend && go test ./internal/e2e/ -run 'Edit' -v`.
## 2. Фронтенд: выбор источника изображения по состоянию
- [x] 2.1 Добавить `edit_pending?: boolean` в тип `Photo` (`frontend/src/lib/types.ts`). Проверка: `cd frontend && npm run check`.
- [x] 2.2 Вынести выбор источника в чистый модуль (`frontend/src/lib/viewer-source.ts`): вход — локальные и сохранённые параметры, `edit_pending`, режим зума, URL вариантов; выход — показывать базовую или серверную версию, нужен ли canvas, какой URL грузить. Покрыть сценариями спеки: локальные правки, пустые локальные правки (сброс), `edit_pending=true` при старом рендере, готовый рендер, отсутствие поля (старый бэкенд = `false`). Проверка: `cd frontend && npm run test -- viewer-source`.
- [x] 2.3 Подключить модуль в `Viewer.svelte`: canvas держится до предзагрузки и `decode()` нового изображения (устаревшие предзагрузки отменяются), показ строится от актуального `current`, а `fresh` становится фолбэком с защитой от устаревших ответов. Проверка: `cd frontend && npm run check && npm run test`.
- [x] 2.4 Перевести индикатор «Рендерим правки на сервере…» в `PhotoEditPanel.svelte` на `photo.edit_pending` вместо `hasEdits && !photo.edit_preview_url`. Проверка: `cd frontend && npm run check`.
## 3. Интеграционная проверка
- [x] 3.1 Сквозной сценарий на собранном приложении (`make build`): правка с зумом не показывает оригинал между предпросмотром и серверной версией; сброс возвратом ползунков в значения по умолчанию сразу показывает исходный вид, не дожидаясь 10 секунд. Проверка: автопрогон в chromium 156 (headless + CDP) на собранном бинарнике: 189 замеров состояния — 0 нарушений инварианта «без canvas только серверная версия», 34 кадра подмены — оригинала нет ни на одном, после сброса через 80 мс показан оригинал (canvas снят, temp=0), серверный сброс доехал после debounce. Результат зафиксирован в отчёте об изменении.
- [x] 3.2 Полный набор проверок: `make test`, `cd frontend && npm run test && npm run check`, `openspec validate fix-edit-preview-handoff --strict`. Проверка: все команды завершаются успешно.
## Workflow follow-up
- Заархивировать изменение после проверки и обновления основного отчёта об изменениях.