Files
ds-galo/openspec/changes/archive/2026-10-11-thumbnail-variants/design.md
T

5.9 KiB

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

Нет.