Files
ds-galo/openspec/changes/archive/2026-10-11-add-video-support/design.md
T

10 KiB
Raw Blame History

Design

Context

См. proposal.md — Why. Текущее состояние, которое формирует подход:

  • Загрузка читает multipart целиком в память (photos/handlers.go:71) и держит единый лимит 100 МБ (handlers.go:16); генерация вариантов — чистый Go (imaging/webp) в photos/pipeline.go, роли объекта — original|thumb|medium|preview (migrations/000008, sqlite/000001).
  • Очередь variant_jobs — одно задание на фото, лизинг interval*4 (worker.go:47), параллелизм — общий (WORKER_CONCURRENCY).
  • Отдача объектов уже поддерживает Range и тип содержимого (storage.go:233), значит плеер получит перемотку без изменений.
  • Ограничения проекта: без Docker (ffmpeg — бинарник на хосте), один Go-бинарник, паритет метаданных PostgreSQL и SQLite, непрозрачные ключи и журнал объектов.

Goals / Non-Goals

Goals:

  • Видео становится медиа-записью в той же сущности photos с видом kind, длительностью и адресом воспроизведения.
  • Гарантированное воспроизведение в браузере: remux для web-совместимых источников, транскод для остальных; постер переиспользует существующую генерацию изображений.
  • Длительная обработка не вытесняет генерацию вариантов изображений.
  • Паритет обоих бэкендов метаданных и обоих бэкендов хранилища.

Non-Goals:

  • Адаптивный стриминг (HLS/DASH) и несколько rendition'ов.
  • Обрезка, монтаж, субтитры, несколько звуковых дорожек в playback.
  • Чанковая/возобновляемая загрузка и фоновая догрузка больших файлов.

Decisions

1. Модель: расширяем photos, а не новая сущность

Добавляем photos.kind (image|video, default image) и photos.duration_ms. Причина: журнал photo_objects, альбомы, права, корзина, share, перенос/копия и social не знают о пикселях. Альтернатива — отдельная videos — дублирует всю эту логику; обобщение в media — широкий рефактор схемы и UI без выгоды для MVP.

2. Новая роль объекта playback; постер — обычные image-варианты

original  = загруженный файл        (скачивание, fallback)
playback  = remux/транскод mp4       (источник <video>)
thumb/medium/preview = WebP-постер   (buildVariants, как для фото)

Постер — это изображение, поэтому buildVariants (pipeline.go:124) переиспользуется без изменений, а сетка/обложка/CoverPicker работают как раньше. Изменяется только ограничение ролей на обоих бэкендах: PG — DROP/ADD CONSTRAINT (как 000011), SQLite — пересборка photo_objects (как sqlite/000003).

3. Загрузка: стриминг во временный файл

io.ReadAll не масштабируется на 2 ГБ. Загрузка SHALL стримиться во временный файл, из него считаются размер и checksum, затем Put в хранилище. Лимит — по виду файла (IMAGE_MAX_UPLOAD_SIZE=100 МБ, VIDEO_MAX_UPLOAD_SIZE=2 ГБ); для fs и s3 используется один путь.

4. Хранилище: потоковое чтение для обработки

Backend.Get возвращает []byte — для входа ffmpeg нужен поток. Добавляем Open(ctx, key) (io.ReadCloser, error) в storage.Backend (fs: os.Open; s3: GetObject.Body). Воркер спуливает оригинал во временный файл (seekable вход), затем пишет транскод в отдельный временный файл (нужен для +faststart) и Put с известным размером.

5. Обработка: ffprobe решает remux или транскод

ffprobe -v error -print_format json -show_streams -show_format in
  -> container, vcodec, acodec, pix_fmt, profile, rotate, w/h, duration

совместимо (mp4|mov|mkv, h264, yuv420p, профиль Baseline/Main/High,
            aac или без звука, длинная сторона <= 1920):
  ffmpeg -map 0:v:0 -map 0:a:0? -c copy -movflags +faststart out.mp4
иначе:
  ffmpeg -map 0:v:0 -map 0:a:0? -c:v libx264 -preset veryfast -crf 23
         -pix_fmt yuv420p -vf "scale='min(1920,iw)':-2" -c:a aac -b:a 128k
         -movflags +faststart out.mp4

постер: -ss 1 -frames:v 1 (fallback -ss 0) -> buildVariants

Альтернатива — всегда транскодировать: проще, но жжёт CPU на уже-хороших файлах. Remux сохраняет rotation-метаданные; транскод применяет AutoOrient/rotate.

6. Очередь: вид задания и отдельный пул

variant_jobs получает kind (image|video). Видео-задание выполняется длительно: длинный лизинг и таймаут (минуты), отдельный малый пул (VIDEO_TRANSCODE_CONCURRENCY=1–2), чтобы длинный транскод не занимал слоты картинок. Приоритет свежих и повтор с backoff сохраняются; перезапуск восстанавливает очередь из состояния метаданных.

7. Контракт API и готовность

Photo получает kind, duration_ms, playback_url. Готовность выводится из наличия роли playback: до готовности playback_url пуст, клиент показывает постер; по завершении воркер публикует photo.updated (существующий worker.go:194). Явное поле статуса не вводим — состояния выводятся из данных, как у текущих вариантов.

8. Наличие видео-инструментов

На старте проверяются ffmpeg/ffprobe (FFMPEG_PATH/FFPROBE_PATH). При отсутствии VIDEO_ENABLED эффективно false: приём видео отклоняется, /api/health сообщает недоступность видео, изображения продолжают работать. Падения на старте нет.

9. Frontend

  • types.ts: kind, duration_ms, playback_url.
  • Uploader.svelte: accept включает video/*, лимит по виду.
  • Viewer.svelte: ветка <video controls poster={posterSrc} src={playback_url}>; зум, панорама и пробел/клавиши зума не перехватываются у нативного плеера.
  • PhotoGrid.svelte: плитка видео показывает постер, значок воспроизведения и длительность.

Risks / Trade-offs

  • [ffmpeg отсутствует на хосте] → VIDEO_ENABLED=false, health-предупреждение, приём видео отклоняется; изображения не страдают.
  • [CPU thrash от транскода] → малый отдельный пул, preset veryfast, cap 1080p, remux для совместимых файлов.
  • [Повторный захват длинного задания] → длинный лизинг и таймаут; по завершении — идемпотентная привязка результата.
  • [Временный диск под 2 ГБ] → спул с defer-очисткой; проверка свободного места и понятная ошибка; временный каталог конфигурируем.
  • [Экзотические контейнеры/поворот] → матрица совместимости покрывается тестами (hevc, yuv444, mkv, vp9, rotation).
  • [Миграция ролей в SQLite] → пересборка таблицы с переносом привязок; контрактные тесты PostgreSQL и SQLite подтверждают паритет.
  • [Время подготовки варианта] → часть видео готова не сразу; UI показывает постер и признак обработки, обновление приходит по SSE.

Migration Plan

  1. Миграции (паритет): photos + kind + duration_ms (default image), photo_objects role CHECK + playback, variant_jobs + kind.
  2. Выкладка приложения с ffmpeg/ffprobe на хосте; существующие изображения не перепроцессируются (только kind=image).
  3. Откат: колонки аддитивны; для отката ролей удаляются привязки playback, затем восстанавливается прежнее ограничение; видео-записи остаются как kind=video и могут быть удалены вручную.

Open Questions

  • Точный эвристический кадр постера (фиксированные -ss 1 vs поиск нечёрного кадра).
  • Стоит ли шарить original как playback для уже-идеальных файлов вместо remux (экономия диска против единообразия URL).