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,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
Нет.