feat(video): add video upload with remux/transcode playback

Add video as a media kind in photos: streaming upload with per-kind
limits, ffprobe metadata, poster frame reusing the image variant
pipeline, and a playback object (remux when web-compatible, else
h264/yuv420p/aac transcode). Video jobs run on a separate pool with a
long lease. Frontend gains a video viewer, grid badge/duration and
video upload.
This commit is contained in:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
@@ -0,0 +1,153 @@
# 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).