154 lines
10 KiB
Markdown
154 lines
10 KiB
Markdown
# 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).
|