feat(photos): add square medium variant for sharp grid thumbnails

This commit is contained in:
iu
2026-10-11 19:57:00 +03:00
parent 581e479f03
commit f1b3b01bdf
27 changed files with 459 additions and 32 deletions
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,73 @@
# Design
## Context
См. `proposal.md` для мотивации. Технические факты, формирующие подход:
- Генерация вариантов — `photos/pipeline.go:buildVariants`: `thumb = Fit(200,200)`,
`preview = Fit(1200,1200)`, WebP q82. Оба кропа вписывают по длинной стороне.
- Метаданные имеют две реализации (Postgres и SQLite) с общей контрактной проверкой
`internal/repo/contract`; SQL дублируется (`photos/store.go`, `photos/sqlite_store.go`).
- Роли заданы жёстко: `photo_objects.role CHECK (role IN ('original','thumb','preview'))`
(`migrations/000008`, `migrations/sqlite/000001`).
- Варианты генерирует фоновый воркер из очереди `variant_jobs`; лишние объекты удаляются
сборщиком мусора по отсутствию ссылок (`photos/worker.go`).
- Фронтенд-сетка берёт только `thumb_url` (`PhotoGrid.svelte:78`); обложки — только
`cover_thumb_url` (`albums/store.go` джойнит `role='thumb'`).
## Goals / Non-Goals
**Goals:**
- Сетка фото и обзор альбомов показывают изображение без апскейла при любом выбранном размере.
- Единое поведение на Postgres и SQLite; контрактные тесты покрывают новую роль.
- Бэкфилл существующих фото без ручных операций.
**Non-Goals:**
- Динамоческое ресайзирование на лету / image-proxy.
- Иные форматы (AVIF) или согласование форматов.
- Изменение `preview` (просмотрщик) и оригинала.
- Замер точной плотности DPR-пикселей и «идеальный» выбор варианта — допускается запас.
## Decisions
- **Набор вариантов.** `thumb = Fill(200,200)` (квадрат), `medium = Fill(800,800)` (квадрат),
`preview = Fit(1200,1200)`. Причина: плитка сетки квадратная (`aspect-square object-cover`),
квадратный кроп исключает лишний апскейл короткой стороны; просмотрщик не квадратный, поэтому
`preview` остаётся вписанным. Альтернатива — оставить `thumb` вписанным: отклонена, короткая
сторона выходит < 200 и мылит уже мелкие плитки.
- **Роль `medium` в `photo_objects`.** Роли маппятся 1:1 на поля API и на привязки; расширяем
ограничение значением `medium`. Альтернатива — отдельная таблица вариантов: избыточна для
одного размера.
- **`srcset` + `sizes`.** Ширины `200w/800w/1200w`; `sizes` берётся из фактической ширины сетки
(`bind:clientWidth` на `<ul>`, делённой на число колонок). Альтернатива — оценка по `100vw`:
неточна из-за рейла и правой панели, ведёт к перекачке 1200.
- **Бэкфилл через очередь.** Миграция делает `INSERT ... SELECT id FROM photos` в
`variant_jobs`; воркер пересоздаёт роли и перепривязывает, старые объекты уходят в GC по
refcount. Альтернатива — воркер, пропускающий уже готовые роли: требует версионирования
вариантов; альтернатива — отдельный `cmd/backfill`: лишний код. Выбран самый простой путь.
- **Обложка.** Джойн medium-варианта обложки с откатом на `thumb`, если medium ещё не готов;
отдельного поля «preview обложки» не вводим.
## Risks / Trade-offs
- [Перегенерация `preview` впустую при бэкфилле] → воркер работает батчами и параллельно
(настраивается); лишние объекты удаляются сборщиком; разовая стоимость.
- [Рост хранилища на один объект 800px на фото] → WebP q82 скромного размера; приемлемо.
- [Неточный `sizes`] → запас в пользу резкости; при отсутствии `medium_url` вариант просто не
попадает в `srcset`, сетка падает на доступные (thumb/preview).
- [SQLite не умеет `ALTER CHECK`] → миграция пересоздаёт `photo_objects`; на больших таблицах
копирование строк. Приемлемо для текущего масштаба, выполняется в окне обслуживания.
## Migration Plan
1. Применить миграцию: расширить `CHECK` роли, налить `variant_jobs` для существующих фото.
2. Выкатить код: воркер генерирует `medium` и квадратный `thumb`; API отдаёт новые поля.
3. Воркер разбирает очередь; сетка показывает `srcset` с откатом на доступные варианты.
4. Откат: сначала код (API перестаёт отдавать medium, воркер не генерирует); затем down-миграция
восстанавливает `CHECK`, предварительно удалив привязки `medium`.
## Open Questions
Нет.
@@ -0,0 +1,47 @@
# Proposal
## Why
Сетка миниатюр часто отображает изображение крупнее, чем единственный вариант `thumb` (200px),
потому что между миниатюрой 200px и превью 1200px нет промежуточного размера. При увеличении
плиток (малое число колонок) и на экранах с высокой плотностью пикселей фото расплывается;
обложки альбомов в обзоре и вовсе отдают только 200px.
## What Changes
- Добавить четвёртый вариант `medium` — квадратный кроп 800×800, WebP — и роль `medium` в
журнале объектов. `thumb` перевести на квадратный кроп 200×200; `preview` остаётся вписанным
1200×1200 для просмотрщика.
- API фото SHALL отдавать `medium_url` рядом с `thumb_url`/`preview_url`; данные обложки
альбома SHALL отдавать URL более крупного варианта обложки.
- Сетка фото и обзор альбомов SHALL выбирать вариант под фактический размер плитки через
`srcset` (200/800/1200) и `sizes`, а не всегда тянуть 200px.
- Миграция расширяет ограничение роли `photo_objects` значением `medium` (Postgres и SQLite) и
ставит в очередь перегенерацию вариантов для уже загруженных фото.
- Из-за смены кропа у `thumb` существующие миниатюры перегенерируются (квадратными) вместе с
добавлением `medium`.
## Capabilities
### New Capabilities
Нет.
### Modified Capabilities
- `photos`: требование «Варианты изображений» — четыре варианта, квадратный кроп grid-вариантов.
- `media-objects`: требование «Журнал объектов» — роль `medium` в наборе ролей.
- `albums`: требование «Отображение обложки альбома» — URL обложки крупнее миниатюры.
- `ui-workspace`: новое требование — сетка и обзор альбомов подбирают вариант изображения под
размер плитки.
## Impact
- Backend: `photos/{pipeline,worker,objects,models,service,store,sqlite_store}.go`,
`albums/{models,store,sqlite_store,service}.go`, миграции Postgres и SQLite, бэкфилл
`variant_jobs`, контрактные тесты `repo/contract`.
- Frontend: `PhotoGrid.svelte` и обзор альбомов (`(app)/+page.svelte`).
- API: новые поля `medium_url` (фото) и URL medium-обложки (альбом); существующие клиенты
продолжают работать (аддитивно).
- Хранилище: +1 производный объект (800px WebP) на фото; preview у существующих фото
перегенерируется впустую и удаляется сборщиком мусора по ссылкам.
@@ -0,0 +1,24 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Отображение обложки альбома
Данные альбома SHALL включать URL обложки, когда обложка задана, чтобы клиенты могли
показать её в списке, рейле и карточке; URL SHALL указывать на вариант, достаточный для
крупного отображения, с откатом на миниатюру, пока крупный вариант не готов.
#### Scenario: Обложка задана
- **WHEN** клиент запрашивает список альбомов или альбом по id, и у альбома есть обложка
- **THEN** ответ содержит URL обложки
#### Scenario: Обложка отсутствует
- **WHEN** у альбома нет обложки или фото обложки удалено
- **THEN** ответ не содержит URL обложки, и клиент показывает плейсхолдер
#### Scenario: Крупный вариант ещё не готов
- **WHEN** у обложки готов только вариант-миниатюра
- **THEN** ответ содержит URL миниатюры обложки
@@ -0,0 +1,19 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Журнал объектов
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
ролям original, thumb, medium и preview.
#### Scenario: Привязка ролей
- **WHEN** фото загружено и обработано
- **THEN** фото ссылается на объект-оригинал и, когда они готовы, на объекты thumb, medium и preview
#### Scenario: Оригинал без вариантов
- **WHEN** варианты ещё не сгенерированы
- **THEN** фото доступно с оригиналом, а thumb, medium и preview отсутствуют
@@ -0,0 +1,19 @@
# Spec Delta
## MODIFIED Requirements
### Requirement: Варианты изображений
Система SHALL генерировать четыре варианта: thumb (200×200, квадратный кроп), medium
(800×800, квадратный кроп), preview (1200×1200, вписанный) и оригинал, в WebP, и извлекать
EXIF (дата, координаты, размеры).
#### Scenario: Генерация вариантов
- **WHEN** фото загружено
- **THEN** система асинхронно генерирует thumb, medium и preview и заполняет EXIF-поля
#### Scenario: Форма вариантов сетки
- **WHEN** генерируются thumb и medium
- **THEN** они обрезаны до квадрата, а preview вписан с сохранением пропорций
@@ -0,0 +1,23 @@
# Spec Delta
## ADDED Requirements
### Requirement: Резкость миниатюр
Сетка фото и обзор альбомов SHALL подбирать вариант изображения под фактический размер
плитки, чтобы миниатюры не отображались крупнее доступного разрешения.
#### Scenario: Крупные плитки
- **WHEN** пользователь увеличивает миниатюры до крупных плиток
- **THEN** показывается вариант изображения, достаточный для их размера, без расплывания
#### Scenario: Мелкие плитки
- **WHEN** миниатюры показаны мелкими плитками
- **THEN** показывается компактный вариант, чтобы не загружать крупное изображение
#### Scenario: Вариант ещё не готов
- **WHEN** для фото доступен только оригинал или только миниатюра
- **THEN** показывается доступный вариант, и сетка отображается корректно
@@ -0,0 +1,37 @@
# Tasks
## 1. Варианты и роли
- [x] 1.1 `backend/internal/photos/pipeline.go`: `thumb` → `imaging.Fill(200,200)`, добавить `medium = imaging.Fill(800,800)`; `buildVariants` возвращает thumb, medium, preview — проверка: обновлённый тест генерации подтверждает квадратную форму thumb/medium и наличие preview.
- [x] 1.2 `backend/internal/photos/objects.go`: `RoleMedium = "medium"`; `backend/internal/models/models.go`: `MediumKey`, `MediumURL` — проверка: `cd backend && go build ./...`.
## 2. Миграции и бэкфилл
- [x] 2.1 Postgres `backend/migrations/000011_*`: расширить `CHECK` роли `photo_objects` значением `medium`; налить `variant_jobs` для всех фото — проверка: `make migrate` применяется без ошибок на dev-БД.
- [x] 2.2 SQLite `backend/migrations/sqlite/000003_*`: пересоздать `photo_objects` с новым `CHECK` (перенос строк) + бэкфилл `variant_jobs` — проверка: `cd backend && go test ./internal/db/...`.
## 3. Метаданные (Postgres и SQLite)
- [x] 3.1 Postgres `photos/store.go` + `photos/service.go`: JOIN/scan роли `medium`, `attachURLs` заполняет `MediumURL` — проверка: `make test` (с `TEST_DATABASE_URL`).
- [x] 3.2 SQLite `photos/sqlite_store.go`: JOIN/scan роли `medium` — проверка: `make test`.
- [x] 3.3 `albums` (models, `store.go`, `sqlite_store.go`, `service.go`): URL medium-обложки с откатом на `thumb` — проверка: `make test`.
- [x] 3.4 `backend/internal/repo/contract`: тесты привязки роли `medium` и URL обложки для обоих бэкендов — проверка: `make test` (и с `TEST_DATABASE_URL`).
## 4. Генерация вариантов
- [x] 4.1 `backend/internal/photos/worker.go`: создавать и привязывать `medium` (порядок thumb → medium → preview); обновить `worker_test.go` (4 привязанных объекта, `MediumURL`) — проверка: `make test`.
## 5. Фронтенд
- [x] 5.1 `frontend/src/lib/types.ts`: добавить `medium_url`, `cover_medium_url` — проверка: `cd frontend && npm run check`.
- [x] 5.2 `frontend/src/lib/components/PhotoGrid.svelte`: `srcset`/`sizes` по фактической ширине сетки (`bind:clientWidth`), откат на доступные варианты — проверка: `npm run dev` — крупные плитки резкие, мелкие тянут компактный вариант.
- [x] 5.3 Обзор альбомов `frontend/src/routes/(app)/+page.svelte`: `srcset` обложки с откатом на миниатюру — проверка: `npm run dev` — обложки не мылят при крупном размере.
## 6. Интеграционная проверка
- [x] 6.1 `make test` и `cd frontend && npm run check && npm run build` проходят; вручную: резкость на всех размерах, обложки в обзоре, бэкфилл существующих фото не ломает сетку.
## Workflow follow-up
- Заархивировать change после выполнения требований ревью проекта (`/opsx-archive`).
- Проверить результат после архивации.