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

154 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).