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:
@@ -55,6 +55,21 @@ WORKER_BATCH=100
|
|||||||
WORKER_CONCURRENCY=4
|
WORKER_CONCURRENCY=4
|
||||||
WORKER_PENDING_TTL=10m
|
WORKER_PENDING_TTL=10m
|
||||||
|
|
||||||
|
# Upload size limits per media kind (plain bytes or KB/MB/GB suffix).
|
||||||
|
IMAGE_MAX_UPLOAD_SIZE=100MB
|
||||||
|
VIDEO_MAX_UPLOAD_SIZE=2GB
|
||||||
|
|
||||||
|
# Video processing toolchain. Video is prepared for browser playback with
|
||||||
|
# ffmpeg/ffprobe installed on the host (no Docker). When either binary is
|
||||||
|
# missing, video upload is disabled and /api/health reports video: unavailable;
|
||||||
|
# images keep working.
|
||||||
|
FFMPEG_PATH=ffmpeg
|
||||||
|
FFPROBE_PATH=ffprobe
|
||||||
|
# How many video transcodes run in parallel (kept separate from image workers).
|
||||||
|
VIDEO_TRANSCODE_CONCURRENCY=1
|
||||||
|
# Directory for temporary spool/transcode files (empty = system temp dir).
|
||||||
|
VIDEO_TEMP_DIR=
|
||||||
|
|
||||||
# Logging. LOG_LEVEL=debug adds per-request access logs and expected 4xx errors.
|
# Logging. LOG_LEVEL=debug adds per-request access logs and expected 4xx errors.
|
||||||
# LOG_FORMAT selects the output encoding.
|
# LOG_FORMAT selects the output encoding.
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
|
||||||
# Seed testdata parameters (see `make testdata`). Counts: ALBUMS, PHOTOS, SEED, TIMEOUT.
|
# Seed testdata parameters (see `make testdata`). Counts: ALBUMS, PHOTOS, SEED, TIMEOUT.
|
||||||
# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY.
|
# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY, VIDEO.
|
||||||
# Extra passthrough flags: ARGS.
|
# VIDEO requires ffmpeg on PATH; it is skipped without it. Extra passthrough flags: ARGS.
|
||||||
ALBUMS ?= 2
|
ALBUMS ?= 2
|
||||||
PHOTOS ?= 10
|
PHOTOS ?= 10
|
||||||
SEED ?= 0
|
SEED ?= 0
|
||||||
@@ -76,6 +76,7 @@ testdata:
|
|||||||
$(if $(filter 1,$(LIKES)),--likes,) \
|
$(if $(filter 1,$(LIKES)),--likes,) \
|
||||||
$(if $(filter 1,$(SHARE)),--share-links,) \
|
$(if $(filter 1,$(SHARE)),--share-links,) \
|
||||||
$(if $(filter 1,$(INVITES)),--invites,) \
|
$(if $(filter 1,$(INVITES)),--invites,) \
|
||||||
|
$(if $(filter 1,$(VIDEO)),--video,) \
|
||||||
$(if $(filter 1,$(WAIT)),--wait-variants,) \
|
$(if $(filter 1,$(WAIT)),--wait-variants,) \
|
||||||
$(if $(filter 1,$(DRY)),--dry-run,) \
|
$(if $(filter 1,$(DRY)),--dry-run,) \
|
||||||
$(ARGS)
|
$(ARGS)
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ SvelteKit через `go:embed`, запуск через systemd.
|
|||||||
- Go 1.22+ (сборка проверена на 1.26)
|
- Go 1.22+ (сборка проверена на 1.26)
|
||||||
- Node.js 20+ (только для сборки фронтенда)
|
- Node.js 20+ (только для сборки фронтенда)
|
||||||
- Доступ к `pg.localnet:5432` (PostgreSQL) и `s3.localnet:3900` (Garage)
|
- Доступ к `pg.localnet:5432` (PostgreSQL) и `s3.localnet:3900` (Garage)
|
||||||
|
- `ffmpeg` и `ffprobe` на хосте — только для видео; без них приём видео отключается,
|
||||||
|
а изображения продолжают работать (см. «Видео»)
|
||||||
- `make`
|
- `make`
|
||||||
|
|
||||||
## Конфигурация
|
## Конфигурация
|
||||||
@@ -68,6 +70,13 @@ cp .env.example .env
|
|||||||
| `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач |
|
| `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач |
|
||||||
| `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач |
|
| `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач |
|
||||||
| `WORKER_PENDING_TTL` | `10m` | TTL задачи в статусе `pending` до повторной попытки |
|
| `WORKER_PENDING_TTL` | `10m` | TTL задачи в статусе `pending` до повторной попытки |
|
||||||
|
| **Загрузка и видео** | | |
|
||||||
|
| `IMAGE_MAX_UPLOAD_SIZE` | `100MB` | Максимальный размер изображения (байты или суффикс `KB`/`MB`/`GB`) |
|
||||||
|
| `VIDEO_MAX_UPLOAD_SIZE` | `2GB` | Максимальный размер видео |
|
||||||
|
| `FFMPEG_PATH` | `ffmpeg` | Путь к `ffmpeg` (нужен для приёма видео) |
|
||||||
|
| `FFPROBE_PATH` | `ffprobe` | Путь к `ffprobe` |
|
||||||
|
| `VIDEO_TRANSCODE_CONCURRENCY` | `1` | Параллельных транскодов (отдельно от воркера изображений) |
|
||||||
|
| `VIDEO_TEMP_DIR` | *(системный tmp)* | Каталог для временных файлов загрузки/транскода |
|
||||||
| **Логирование** | | |
|
| **Логирование** | | |
|
||||||
| `LOG_LEVEL` | `info` | Уровень логов: `debug`, `info`, `warn`, `error` |
|
| `LOG_LEVEL` | `info` | Уровень логов: `debug`, `info`, `warn`, `error` |
|
||||||
| `LOG_FORMAT` | `json` | Формат логов: `json` или `text` |
|
| `LOG_FORMAT` | `json` | Формат логов: `json` или `text` |
|
||||||
@@ -89,6 +98,25 @@ cp .env.example .env
|
|||||||
**только для одного экземпляра приложения на файл** (без горизонтального
|
**только для одного экземпляра приложения на файл** (без горизонтального
|
||||||
масштабирования).
|
масштабирования).
|
||||||
|
|
||||||
|
### Видео
|
||||||
|
|
||||||
|
Видео загружается в те же альбомы, что и фото (`kind=video`), и готовится для
|
||||||
|
воспроизведения в браузере фоновым воркером:
|
||||||
|
|
||||||
|
- **Постер**: из видео извлекается кадр, из которого генерируются обычные варианты
|
||||||
|
`thumb`/`medium`/`preview` (WebP) — сетка и обложка показывают постер.
|
||||||
|
- **Воспроизведение**: web-совместимый источник (h264/yuv420p, профиль Baseline/Main/High,
|
||||||
|
звук aac или без звука, контейнер mp4/mov/mkv, разрешение ≤ 1080p) ремуксится без
|
||||||
|
перекодирования; иначе выполняется транскод в h264/yuv420p/aac с `+faststart`. Готовый
|
||||||
|
вариант хранится как объект роли `playback` и отдаётся с поддержкой `Range` (перемотка).
|
||||||
|
- **Оригинал** сохраняется отдельно и используется для скачивания.
|
||||||
|
- Транскод идёт в отдельном малом пуле (`VIDEO_TRANSCODE_CONCURRENCY`) с длинным лизингом и
|
||||||
|
не вытесняет генерацию вариантов изображений. Роль `playback` появляется по готовности и
|
||||||
|
транслируется подписчикам альбома (`photo.updated`).
|
||||||
|
|
||||||
|
Без `ffmpeg`/`ffprobe` приём видео отклоняется, а `/api/health` сообщает
|
||||||
|
`"video": "unavailable"`; изображения работают как прежде.
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -162,7 +190,7 @@ GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
|||||||
|
|
||||||
Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы
|
Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы
|
||||||
параметры включаются значением `1`: `TAGS`, `COMMENTS`, `LIKES`, `SHARE`, `INVITES`,
|
параметры включаются значением `1`: `TAGS`, `COMMENTS`, `LIKES`, `SHARE`, `INVITES`,
|
||||||
`WAIT`, `DRY`. Дополнительные флаги передаются через `ARGS`.
|
`WAIT`, `DRY`, `VIDEO`. Дополнительные флаги передаются через `ARGS`.
|
||||||
|
|
||||||
Переменные окружения:
|
Переменные окружения:
|
||||||
|
|
||||||
@@ -185,6 +213,7 @@ GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \
|
|||||||
| `--share-links` | выкл. | Создавать share-ссылки на альбомы |
|
| `--share-links` | выкл. | Создавать share-ссылки на альбомы |
|
||||||
| `--invites` | выкл. | Создавать инвайты (роль `member`) |
|
| `--invites` | выкл. | Создавать инвайты (роль `member`) |
|
||||||
| `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото |
|
| `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото |
|
||||||
|
| `--video` | выкл. | Загружать видео-фикстуры (требует `ffmpeg`; иначе пропускаются) |
|
||||||
| `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек |
|
| `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек |
|
||||||
| `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов |
|
| `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов |
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"galo/internal/sse"
|
"galo/internal/sse"
|
||||||
"galo/internal/storage"
|
"galo/internal/storage"
|
||||||
"galo/internal/store"
|
"galo/internal/store"
|
||||||
|
"galo/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
@@ -42,6 +43,8 @@ type Server struct {
|
|||||||
photos *photos.Service
|
photos *photos.Service
|
||||||
worker *photos.Worker
|
worker *photos.Worker
|
||||||
|
|
||||||
|
videoTools *video.Toolchain
|
||||||
|
|
||||||
social *social.Service
|
social *social.Service
|
||||||
|
|
||||||
notifications *notifications.Service
|
notifications *notifications.Service
|
||||||
@@ -62,6 +65,12 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
|
|||||||
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
|
photosSvc := photos.NewService(repos.Photos, albumsSvc, st, hub)
|
||||||
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
|
photosWorker := photos.NewWorker(photosSvc, cfg.Worker.Interval, cfg.Worker.Batch, cfg.Worker.PendingTTL, cfg.Worker.Concurrency)
|
||||||
|
|
||||||
|
videoTools := video.Detect(cfg.Video)
|
||||||
|
photosSvc.SetUploadLimits(cfg.Upload.ImageMaxBytes, cfg.Upload.VideoMaxBytes)
|
||||||
|
photosSvc.SetVideoEnabled(videoTools.Available())
|
||||||
|
photosSvc.SetTempDir(cfg.Video.TempDir)
|
||||||
|
photosWorker.SetVideo(videoTools, cfg.Video.TranscodeConcurrency)
|
||||||
|
|
||||||
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
|
socialSvc := social.NewService(repos.Social, albumsSvc, repos.Photos, hub, notifSvc)
|
||||||
|
|
||||||
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
|
shareSvc := sharelinks.NewService(repos.ShareLinks, albumsSvc)
|
||||||
@@ -77,6 +86,7 @@ func New(cfg *config.Config, repos store.Repos, ping func(ctx context.Context) e
|
|||||||
membersSvc: membersSvc,
|
membersSvc: membersSvc,
|
||||||
photos: photosSvc,
|
photos: photosSvc,
|
||||||
worker: photosWorker,
|
worker: photosWorker,
|
||||||
|
videoTools: videoTools,
|
||||||
social: socialSvc,
|
social: socialSvc,
|
||||||
notifications: notifSvc,
|
notifications: notifSvc,
|
||||||
share: shareSvc,
|
share: shareSvc,
|
||||||
@@ -188,13 +198,19 @@ func (s *Server) health(c *gin.Context) {
|
|||||||
if err := s.storage.Ping(ctx); err != nil {
|
if err := s.storage.Ping(ctx); err != nil {
|
||||||
storeStatus = "error"
|
storeStatus = "error"
|
||||||
}
|
}
|
||||||
|
// Video tooling is optional: its absence does not degrade the service, but
|
||||||
|
// it is reported so operators can see why video upload is disabled.
|
||||||
|
videoStatus := "ok"
|
||||||
|
if !s.videoTools.Available() {
|
||||||
|
videoStatus = "unavailable"
|
||||||
|
}
|
||||||
status := http.StatusOK
|
status := http.StatusOK
|
||||||
overall := "ok"
|
overall := "ok"
|
||||||
if dbStatus != "ok" || storeStatus != "ok" {
|
if dbStatus != "ok" || storeStatus != "ok" {
|
||||||
status = http.StatusServiceUnavailable
|
status = http.StatusServiceUnavailable
|
||||||
overall = "degraded"
|
overall = "degraded"
|
||||||
}
|
}
|
||||||
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus})
|
c.JSON(status, gin.H{"status": overall, "db": dbStatus, storageLabel: storeStatus, "video": videoStatus})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) serveObject(c *gin.Context) {
|
func (s *Server) serveObject(c *gin.Context) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -12,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"galo/internal/config"
|
"galo/internal/config"
|
||||||
"galo/internal/storage"
|
"galo/internal/storage"
|
||||||
|
"galo/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
func mustSigner(t *testing.T) *storage.Signer {
|
func mustSigner(t *testing.T) *storage.Signer {
|
||||||
@@ -58,4 +60,23 @@ func TestHealthStorageLabel(t *testing.T) {
|
|||||||
if body := call(); body["s3"] != "ok" {
|
if body := call(); body["s3"] != "ok" {
|
||||||
t.Fatalf("expected default s3 label, got %v", body)
|
t.Fatalf("expected default s3 label, got %v", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A missing toolchain reports video unavailable without degrading the service.
|
||||||
|
if body := call(); body["video"] != "unavailable" {
|
||||||
|
t.Fatalf("expected video unavailable, got %v", body)
|
||||||
|
}
|
||||||
|
if body := call(); body["status"] != "ok" {
|
||||||
|
t.Fatalf("video unavailability must not degrade status, got %v", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A resolvable toolchain reports video ok. The test binary is a known
|
||||||
|
// executable on every platform.
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executable: %v", err)
|
||||||
|
}
|
||||||
|
srv.videoTools = video.Detect(config.Video{FFmpegPath: self, FFprobePath: self})
|
||||||
|
if body := call(); body["video"] != "ok" {
|
||||||
|
t.Fatalf("expected video ok, got %v", body)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"galo/internal/logging"
|
"galo/internal/logging"
|
||||||
@@ -54,6 +55,23 @@ type Worker struct {
|
|||||||
Concurrency int
|
Concurrency int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Upload holds the per-kind upload size limits. Images and video are bounded
|
||||||
|
// separately because video files are an order of magnitude larger.
|
||||||
|
type Upload struct {
|
||||||
|
ImageMaxBytes int64
|
||||||
|
VideoMaxBytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video holds the external tooling used to prepare uploaded video for playback.
|
||||||
|
// Availability is detected at startup; an absent toolchain disables video
|
||||||
|
// ingestion without failing the server.
|
||||||
|
type Video struct {
|
||||||
|
FFmpegPath string
|
||||||
|
FFprobePath string
|
||||||
|
TranscodeConcurrency int
|
||||||
|
TempDir string
|
||||||
|
}
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
Backend string
|
Backend string
|
||||||
FSRoot string
|
FSRoot string
|
||||||
@@ -82,6 +100,8 @@ type Config struct {
|
|||||||
Server Server
|
Server Server
|
||||||
SSE SSE
|
SSE SSE
|
||||||
Worker Worker
|
Worker Worker
|
||||||
|
Upload Upload
|
||||||
|
Video Video
|
||||||
Log Log
|
Log Log
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +171,19 @@ func Load() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("invalid WORKER_CONCURRENCY: %w", err)
|
return nil, fmt.Errorf("invalid WORKER_CONCURRENCY: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
imageMaxUpload, err := getSizeEnv("IMAGE_MAX_UPLOAD_SIZE", 100<<20)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
videoMaxUpload, err := getSizeEnv("VIDEO_MAX_UPLOAD_SIZE", 2<<30)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transcodeConcurrency, err := strconv.Atoi(getEnv("VIDEO_TRANSCODE_CONCURRENCY", "1"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid VIDEO_TRANSCODE_CONCURRENCY: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
logLevel := getEnv("LOG_LEVEL", "info")
|
logLevel := getEnv("LOG_LEVEL", "info")
|
||||||
if _, err := logging.ParseLevel(logLevel); err != nil {
|
if _, err := logging.ParseLevel(logLevel); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -207,6 +240,16 @@ func Load() (*Config, error) {
|
|||||||
PendingTTL: workerPendingTTL,
|
PendingTTL: workerPendingTTL,
|
||||||
Concurrency: workerConcurrency,
|
Concurrency: workerConcurrency,
|
||||||
},
|
},
|
||||||
|
Upload: Upload{
|
||||||
|
ImageMaxBytes: imageMaxUpload,
|
||||||
|
VideoMaxBytes: videoMaxUpload,
|
||||||
|
},
|
||||||
|
Video: Video{
|
||||||
|
FFmpegPath: getEnv("FFMPEG_PATH", "ffmpeg"),
|
||||||
|
FFprobePath: getEnv("FFPROBE_PATH", "ffprobe"),
|
||||||
|
TranscodeConcurrency: transcodeConcurrency,
|
||||||
|
TempDir: getEnv("VIDEO_TEMP_DIR", ""),
|
||||||
|
},
|
||||||
Log: Log{
|
Log: Log{
|
||||||
Level: logLevel,
|
Level: logLevel,
|
||||||
Format: logFormat,
|
Format: logFormat,
|
||||||
@@ -245,3 +288,40 @@ func getEnv(key, fallback string) string {
|
|||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getSizeEnv reads a byte-size environment variable, accepting a plain integer
|
||||||
|
// or a human suffix (KB/MB/GB, binary). An empty value yields the fallback.
|
||||||
|
func getSizeEnv(key string, fallback int64) (int64, error) {
|
||||||
|
raw := getEnv(key, "")
|
||||||
|
if raw == "" {
|
||||||
|
return fallback, nil
|
||||||
|
}
|
||||||
|
n, err := parseSize(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSize(s string) (int64, error) {
|
||||||
|
s = strings.TrimSpace(strings.ToUpper(s))
|
||||||
|
mult := int64(1)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(s, "GB"):
|
||||||
|
mult, s = 1<<30, strings.TrimSuffix(s, "GB")
|
||||||
|
case strings.HasSuffix(s, "MB"):
|
||||||
|
mult, s = 1<<20, strings.TrimSuffix(s, "MB")
|
||||||
|
case strings.HasSuffix(s, "KB"):
|
||||||
|
mult, s = 1<<10, strings.TrimSuffix(s, "KB")
|
||||||
|
case strings.HasSuffix(s, "B"):
|
||||||
|
mult, s = 1, strings.TrimSuffix(s, "B")
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return 0, fmt.Errorf("must be non-negative")
|
||||||
|
}
|
||||||
|
return n * mult, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -176,3 +176,59 @@ func TestLoadLog(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadUploadAndVideo(t *testing.T) {
|
||||||
|
t.Run("defaults", func(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Upload.ImageMaxBytes != 100<<20 {
|
||||||
|
t.Fatalf("image max = %d, want 100MiB", cfg.Upload.ImageMaxBytes)
|
||||||
|
}
|
||||||
|
if cfg.Upload.VideoMaxBytes != 2<<30 {
|
||||||
|
t.Fatalf("video max = %d, want 2GiB", cfg.Upload.VideoMaxBytes)
|
||||||
|
}
|
||||||
|
if cfg.Video.FFmpegPath != "ffmpeg" || cfg.Video.FFprobePath != "ffprobe" {
|
||||||
|
t.Fatalf("tool paths = %+v", cfg.Video)
|
||||||
|
}
|
||||||
|
if cfg.Video.TranscodeConcurrency != 1 {
|
||||||
|
t.Fatalf("transcode concurrency = %d, want 1", cfg.Video.TranscodeConcurrency)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("custom values", func(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("IMAGE_MAX_UPLOAD_SIZE", "50MB")
|
||||||
|
t.Setenv("VIDEO_MAX_UPLOAD_SIZE", "3GB")
|
||||||
|
t.Setenv("FFMPEG_PATH", "/usr/bin/ffmpeg")
|
||||||
|
t.Setenv("FFPROBE_PATH", "/usr/bin/ffprobe")
|
||||||
|
t.Setenv("VIDEO_TRANSCODE_CONCURRENCY", "2")
|
||||||
|
t.Setenv("VIDEO_TEMP_DIR", "/var/tmp/galo")
|
||||||
|
cfg, err := Load()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Upload.ImageMaxBytes != 50<<20 {
|
||||||
|
t.Fatalf("image max = %d, want 50MiB", cfg.Upload.ImageMaxBytes)
|
||||||
|
}
|
||||||
|
if cfg.Upload.VideoMaxBytes != 3<<30 {
|
||||||
|
t.Fatalf("video max = %d, want 3GiB", cfg.Upload.VideoMaxBytes)
|
||||||
|
}
|
||||||
|
if cfg.Video.FFmpegPath != "/usr/bin/ffmpeg" || cfg.Video.TranscodeConcurrency != 2 {
|
||||||
|
t.Fatalf("video = %+v", cfg.Video)
|
||||||
|
}
|
||||||
|
if cfg.Video.TempDir != "/var/tmp/galo" {
|
||||||
|
t.Fatalf("temp dir = %q", cfg.Video.TempDir)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("invalid size", func(t *testing.T) {
|
||||||
|
setRequiredEnv(t)
|
||||||
|
t.Setenv("VIDEO_MAX_UPLOAD_SIZE", "lots")
|
||||||
|
if _, err := Load(); err == nil {
|
||||||
|
t.Fatal("expected error for invalid VIDEO_MAX_UPLOAD_SIZE")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package db
|
package db
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
assets "galo"
|
assets "galo"
|
||||||
@@ -34,4 +36,38 @@ func TestMigrateSQLite(t *testing.T) {
|
|||||||
if n != 1 {
|
if n != 1 {
|
||||||
t.Fatalf("objects table missing after migration")
|
t.Fatalf("objects table missing after migration")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assertColumn(t, db, "photos", "kind")
|
||||||
|
assertColumn(t, db, "photos", "duration_ms")
|
||||||
|
assertColumn(t, db, "variant_jobs", "kind")
|
||||||
|
|
||||||
|
var ddl string
|
||||||
|
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type='table' AND name='photo_objects'`).Scan(&ddl); err != nil {
|
||||||
|
t.Fatalf("photo_objects ddl: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(ddl, "playback") {
|
||||||
|
t.Fatalf("photo_objects role constraint missing playback: %s", ddl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertColumn(t *testing.T, db *sql.DB, table, column string) {
|
||||||
|
t.Helper()
|
||||||
|
rows, err := db.Query(`PRAGMA table_info(` + table + `)`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("table_info %s: %v", table, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var cid int
|
||||||
|
var name, typ string
|
||||||
|
var notNull, pk int
|
||||||
|
var dflt any
|
||||||
|
if err := rows.Scan(&cid, &name, &typ, ¬Null, &dflt, &pk); err != nil {
|
||||||
|
t.Fatalf("scan table_info %s: %v", table, err)
|
||||||
|
}
|
||||||
|
if name == column {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("column %s.%s missing", table, column)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"galo/internal/albums"
|
||||||
|
"galo/internal/config"
|
||||||
|
"galo/internal/photos"
|
||||||
|
"galo/internal/video"
|
||||||
|
)
|
||||||
|
|
||||||
|
// makeMP4 generates a tiny h264/yuv420p clip so the web-compatible remux path
|
||||||
|
// can be exercised. It skips when ffmpeg is unavailable.
|
||||||
|
func makeMP4(t *testing.T, ffmpeg string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
out := filepath.Join(t.TempDir(), "clip.mp4")
|
||||||
|
cmd := exec.Command(ffmpeg,
|
||||||
|
"-v", "error", "-y",
|
||||||
|
"-f", "lavfi", "-i", "testsrc=size=160x120:rate=10",
|
||||||
|
"-t", "1",
|
||||||
|
"-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast",
|
||||||
|
out)
|
||||||
|
if b, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
t.Skipf("ffmpeg fixture failed: %v: %s", err, b)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(out)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read fixture: %v", err)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVideoUploadPreparedForPlayback(t *testing.T) {
|
||||||
|
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("ffmpeg not available")
|
||||||
|
}
|
||||||
|
ffprobe, err := exec.LookPath("ffprobe")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("ffprobe not available")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
path := filepath.Join(t.TempDir(), "galo.db")
|
||||||
|
st, closeDB := openStack(t, path, t.TempDir())
|
||||||
|
defer closeDB()
|
||||||
|
|
||||||
|
user, _, err := st.auth.Register(ctx, "video@test.local", "password123", "V")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Video"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create album: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||||
|
AlbumID: album.ID,
|
||||||
|
UserID: user.ID,
|
||||||
|
Filename: "clip.mp4",
|
||||||
|
MimeType: "video/mp4",
|
||||||
|
Data: makeMP4(t, ffmpeg),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload video: %v", err)
|
||||||
|
}
|
||||||
|
if p.Kind != photos.KindVideo {
|
||||||
|
t.Fatalf("kind = %q, want video", p.Kind)
|
||||||
|
}
|
||||||
|
if p.PlaybackURL != "" {
|
||||||
|
t.Fatalf("playback url should be empty before processing")
|
||||||
|
}
|
||||||
|
|
||||||
|
tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe})
|
||||||
|
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 1)
|
||||||
|
w.SetVideo(tools, 1)
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PlaybackURL == "" {
|
||||||
|
t.Fatalf("playback url not set after processing")
|
||||||
|
}
|
||||||
|
if got.ThumbURL == "" {
|
||||||
|
t.Fatalf("poster variants not generated")
|
||||||
|
}
|
||||||
|
if got.DurationMS <= 0 {
|
||||||
|
t.Fatalf("duration not probed: %d", got.DurationMS)
|
||||||
|
}
|
||||||
|
if got.Width != 160 || got.Height != 120 {
|
||||||
|
t.Fatalf("dimensions = %dx%d, want 160x120", got.Width, got.Height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public access (share-link path) exposes the same signed playback URL.
|
||||||
|
pub, err := st.photos.GetPublic(ctx, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get public: %v", err)
|
||||||
|
}
|
||||||
|
if pub.PlaybackURL == "" {
|
||||||
|
t.Fatalf("public playback url not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permanently deleting the video collects every object, including playback.
|
||||||
|
rows, err := st.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = ?`, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("object ids: %v", err)
|
||||||
|
}
|
||||||
|
var objectIDs []string
|
||||||
|
for rows.Next() {
|
||||||
|
var id string
|
||||||
|
if err := rows.Scan(&id); err != nil {
|
||||||
|
t.Fatalf("scan object id: %v", err)
|
||||||
|
}
|
||||||
|
objectIDs = append(objectIDs, id)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if len(objectIDs) < 5 {
|
||||||
|
t.Fatalf("bound objects = %d, want original+thumb+medium+preview+playback", len(objectIDs))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
|
||||||
|
t.Fatalf("permanent delete: %v", err)
|
||||||
|
}
|
||||||
|
w.ProcessOnce(ctx)
|
||||||
|
|
||||||
|
for _, id := range objectIDs {
|
||||||
|
var n int
|
||||||
|
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects WHERE id = ?`, id).Scan(&n); err != nil {
|
||||||
|
t.Fatalf("count object: %v", err)
|
||||||
|
}
|
||||||
|
if n != 0 {
|
||||||
|
t.Fatalf("object %s not collected after delete", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -71,10 +71,13 @@ type Photo struct {
|
|||||||
ThumbKey string `json:"-"`
|
ThumbKey string `json:"-"`
|
||||||
MediumKey string `json:"-"`
|
MediumKey string `json:"-"`
|
||||||
PreviewKey string `json:"-"`
|
PreviewKey string `json:"-"`
|
||||||
|
PlaybackKey string `json:"-"`
|
||||||
OriginalFilename string `json:"original_filename"`
|
OriginalFilename string `json:"original_filename"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
DurationMS int64 `json:"duration_ms,omitempty"`
|
||||||
MimeType string `json:"mime_type"`
|
MimeType string `json:"mime_type"`
|
||||||
TakenAt *time.Time `json:"taken_at,omitempty"`
|
TakenAt *time.Time `json:"taken_at,omitempty"`
|
||||||
Latitude *float64 `json:"latitude,omitempty"`
|
Latitude *float64 `json:"latitude,omitempty"`
|
||||||
@@ -92,6 +95,7 @@ type Photo struct {
|
|||||||
ThumbURL string `json:"thumb_url,omitempty"`
|
ThumbURL string `json:"thumb_url,omitempty"`
|
||||||
MediumURL string `json:"medium_url,omitempty"`
|
MediumURL string `json:"medium_url,omitempty"`
|
||||||
PreviewURL string `json:"preview_url,omitempty"`
|
PreviewURL string `json:"preview_url,omitempty"`
|
||||||
|
PlaybackURL string `json:"playback_url,omitempty"`
|
||||||
OriginalURL string `json:"original_url,omitempty"`
|
OriginalURL string `json:"original_url,omitempty"`
|
||||||
DownloadURL string `json:"download_url,omitempty"`
|
DownloadURL string `json:"download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package photos
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
"time"
|
||||||
@@ -13,8 +12,6 @@ import (
|
|||||||
"galo/internal/httpx"
|
"galo/internal/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxUploadSize = 100 << 20
|
|
||||||
|
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
}
|
}
|
||||||
@@ -41,12 +38,12 @@ func (h *Handler) List(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) Upload(c *gin.Context) {
|
func (h *Handler) Upload(c *gin.Context) {
|
||||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxUploadSize)
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.svc.MaxUploadSize())
|
||||||
form, err := c.MultipartForm()
|
form, err := c.MultipartForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
var maxErr *http.MaxBytesError
|
var maxErr *http.MaxBytesError
|
||||||
if errors.As(err, &maxErr) {
|
if errors.As(err, &maxErr) {
|
||||||
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size (100 MB)"))
|
httpx.RespondError(c, httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large", "files exceed the maximum upload size"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
|
httpx.RespondError(c, httpx.BadRequest("invalid_request", "invalid multipart form"))
|
||||||
@@ -68,19 +65,15 @@ func (h *Handler) Upload(c *gin.Context) {
|
|||||||
httpx.RespondError(c, err)
|
httpx.RespondError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
data, err := io.ReadAll(io.LimitReader(f, maxUploadSize))
|
|
||||||
f.Close()
|
|
||||||
if err != nil {
|
|
||||||
httpx.RespondError(c, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
p, err := h.svc.Upload(c.Request.Context(), UploadInput{
|
p, err := h.svc.Upload(c.Request.Context(), UploadInput{
|
||||||
AlbumID: c.Param("id"),
|
AlbumID: c.Param("id"),
|
||||||
UserID: auth.UserID(c),
|
UserID: auth.UserID(c),
|
||||||
Filename: fh.Filename,
|
Filename: fh.Filename,
|
||||||
MimeType: fh.Header.Get("Content-Type"),
|
MimeType: fh.Header.Get("Content-Type"),
|
||||||
Data: data,
|
Reader: f,
|
||||||
|
Size: fh.Size,
|
||||||
})
|
})
|
||||||
|
f.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpx.RespondError(c, err)
|
httpx.RespondError(c, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package photos
|
||||||
|
|
||||||
|
// Media kinds. A photo row may describe either a still image or a video.
|
||||||
|
const (
|
||||||
|
KindImage = "image"
|
||||||
|
KindVideo = "video"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mediaKind normalizes a photo kind, defaulting to image. It is used before
|
||||||
|
// persisting so the column and the variant job always carry a valid value.
|
||||||
|
func mediaKind(kind string) string {
|
||||||
|
if kind == KindVideo {
|
||||||
|
return KindVideo
|
||||||
|
}
|
||||||
|
return KindImage
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package photos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galo/internal/models"
|
||||||
|
"galo/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeBackend satisfies storage.Backend by embedding the interface; only the
|
||||||
|
// methods exercised by the code under test are implemented.
|
||||||
|
type fakeBackend struct {
|
||||||
|
storage.Backend
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeBackend) URL(_ context.Context, key string) (string, error) {
|
||||||
|
return "signed:" + key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeBackend) Open(_ context.Context, _ string) (io.ReadCloser, error) {
|
||||||
|
return io.NopCloser(bytes.NewReader([]byte("fake-video-bytes"))), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeBackend) Put(context.Context, string, io.Reader, int64, string) error { return nil }
|
||||||
|
|
||||||
|
func TestAttachURLsPlayback(t *testing.T) {
|
||||||
|
svc := &Service{storage: fakeBackend{}}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
p := &models.Photo{
|
||||||
|
StorageKey: "objects/a/original.mp4",
|
||||||
|
ThumbKey: "objects/a/thumb.webp",
|
||||||
|
PreviewKey: "objects/a/preview.webp",
|
||||||
|
PlaybackKey: "objects/a/playback.mp4",
|
||||||
|
}
|
||||||
|
if err := svc.attachURLs(ctx, p); err != nil {
|
||||||
|
t.Fatalf("attachURLs: %v", err)
|
||||||
|
}
|
||||||
|
if p.PlaybackURL != "signed:objects/a/playback.mp4" {
|
||||||
|
t.Fatalf("playback url = %q", p.PlaybackURL)
|
||||||
|
}
|
||||||
|
if p.ThumbURL != "signed:objects/a/thumb.webp" {
|
||||||
|
t.Fatalf("thumb url = %q", p.ThumbURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An image without a playback binding exposes no playback URL.
|
||||||
|
img := &models.Photo{StorageKey: "objects/b/original.jpg", ThumbKey: "objects/b/thumb.webp"}
|
||||||
|
if err := svc.attachURLs(ctx, img); err != nil {
|
||||||
|
t.Fatalf("attachURLs image: %v", err)
|
||||||
|
}
|
||||||
|
if img.PlaybackURL != "" {
|
||||||
|
t.Fatalf("image playback url = %q, want empty", img.PlaybackURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMediaKind(t *testing.T) {
|
||||||
|
if got := mediaKind(""); got != KindImage {
|
||||||
|
t.Fatalf("empty kind = %q, want image", got)
|
||||||
|
}
|
||||||
|
if got := mediaKind(KindVideo); got != KindVideo {
|
||||||
|
t.Fatalf("video kind = %q", got)
|
||||||
|
}
|
||||||
|
if got := mediaKind("bogus"); got != KindImage {
|
||||||
|
t.Fatalf("bogus kind = %q, want image", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveMedia(t *testing.T) {
|
||||||
|
pngHead := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
declared string
|
||||||
|
filename string
|
||||||
|
head []byte
|
||||||
|
wantKind string
|
||||||
|
wantMimePref string
|
||||||
|
}{
|
||||||
|
{"image header", "image/png", "x.png", nil, KindImage, "image/png"},
|
||||||
|
{"video header", "video/mp4", "clip.mp4", nil, KindVideo, "video/mp4"},
|
||||||
|
{"video by extension", "", "clip.mp4", nil, KindVideo, "video/mp4"},
|
||||||
|
{"image by extension", "", "x.jpg", nil, KindImage, "image/jpeg"},
|
||||||
|
{"sniff png", "", "", pngHead, KindImage, "image/png"},
|
||||||
|
{"generic mime by extension", "application/octet-stream", "movie.mkv", nil, KindVideo, ""},
|
||||||
|
{"unsupported", "text/plain", "notes.txt", nil, "", ""},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
kind, mime := resolveMedia(tc.declared, tc.filename, tc.head)
|
||||||
|
if kind != tc.wantKind {
|
||||||
|
t.Fatalf("kind = %q, want %q", kind, tc.wantKind)
|
||||||
|
}
|
||||||
|
if tc.wantMimePref != "" && mime != tc.wantMimePref {
|
||||||
|
t.Fatalf("mime = %q, want %q", mime, tc.wantMimePref)
|
||||||
|
}
|
||||||
|
if kind != "" && !strings.HasPrefix(mime, kind+"/") {
|
||||||
|
t.Fatalf("mime %q does not match kind %q", mime, kind)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRejectsUnsupportedAndDisabledVideo(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc := &Service{albums: stubAlbums{}, imageMax: 1 << 20, videoMax: 1 << 30, videoEnabled: false}
|
||||||
|
|
||||||
|
_, err := svc.Upload(ctx, UploadInput{
|
||||||
|
AlbumID: "a", UserID: "u", Filename: "notes.txt", MimeType: "text/plain",
|
||||||
|
Reader: bytes.NewReader([]byte("hello")),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "image or video") {
|
||||||
|
t.Fatalf("unsupported err = %v, want invalid_file", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = svc.Upload(ctx, UploadInput{
|
||||||
|
AlbumID: "a", UserID: "u", Filename: "clip.mp4", MimeType: "video/mp4",
|
||||||
|
Reader: bytes.NewReader([]byte("....")),
|
||||||
|
})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "not available") {
|
||||||
|
t.Fatalf("disabled video err = %v, want video_disabled", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ const (
|
|||||||
RoleThumb = "thumb"
|
RoleThumb = "thumb"
|
||||||
RoleMedium = "medium"
|
RoleMedium = "medium"
|
||||||
RolePreview = "preview"
|
RolePreview = "preview"
|
||||||
|
RolePlayback = "playback"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -285,8 +286,9 @@ func (s *Store) ClaimStalePending(ctx context.Context, olderThan time.Duration,
|
|||||||
|
|
||||||
// ClaimVariantJobs leases due variant jobs and returns their photo ids. The
|
// ClaimVariantJobs leases due variant jobs and returns their photo ids. The
|
||||||
// lease pushes next_attempt_at forward so the same job is not picked up twice
|
// lease pushes next_attempt_at forward so the same job is not picked up twice
|
||||||
// while it is being processed.
|
// while it is being processed. A non-empty kind restricts claims to that media
|
||||||
func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
|
// kind so long video work cannot starve image jobs.
|
||||||
|
func (s *Store) ClaimVariantJobs(ctx context.Context, kind string, limit int, lease time.Duration) ([]string, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
@@ -300,12 +302,13 @@ func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Dura
|
|||||||
`SELECT vj.photo_id FROM variant_jobs vj
|
`SELECT vj.photo_id FROM variant_jobs vj
|
||||||
JOIN photos p ON p.id = vj.photo_id
|
JOIN photos p ON p.id = vj.photo_id
|
||||||
WHERE vj.next_attempt_at <= now()
|
WHERE vj.next_attempt_at <= now()
|
||||||
|
AND ($2 = '' OR vj.kind = $2)
|
||||||
ORDER BY (vj.attempts > 0) ASC,
|
ORDER BY (vj.attempts > 0) ASC,
|
||||||
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
|
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
|
||||||
vj.next_attempt_at ASC,
|
vj.next_attempt_at ASC,
|
||||||
vj.photo_id DESC
|
vj.photo_id DESC
|
||||||
LIMIT $1
|
LIMIT $1
|
||||||
FOR UPDATE OF vj SKIP LOCKED`, limit)
|
FOR UPDATE OF vj SKIP LOCKED`, limit, kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/disintegration/imaging"
|
||||||
exif "github.com/dsoprea/go-exif/v3"
|
exif "github.com/dsoprea/go-exif/v3"
|
||||||
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
exifcommon "github.com/dsoprea/go-exif/v3/common"
|
||||||
"github.com/disintegration/imaging"
|
|
||||||
"github.com/gen2brain/webp"
|
"github.com/gen2brain/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -44,9 +44,13 @@ type Repository interface {
|
|||||||
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
|
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
|
||||||
ClaimGarbage(ctx context.Context, limit int) ([]Object, error)
|
ClaimGarbage(ctx context.Context, limit int) ([]Object, error)
|
||||||
ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error)
|
ClaimStalePending(ctx context.Context, olderThan time.Duration, limit int) ([]Object, error)
|
||||||
ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error)
|
// ClaimVariantJobs leases due jobs of the given kind ("" claims all kinds).
|
||||||
|
ClaimVariantJobs(ctx context.Context, kind string, limit int, lease time.Duration) ([]string, error)
|
||||||
CompleteVariantJob(ctx context.Context, photoID string) error
|
CompleteVariantJob(ctx context.Context, photoID string) error
|
||||||
FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error
|
FailVariantJob(ctx context.Context, photoID, errMsg string, maxAttempts int) error
|
||||||
|
// SetMediaMeta records probed video facts: duration on the photo and frame
|
||||||
|
// dimensions on the original object.
|
||||||
|
SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error
|
||||||
|
|
||||||
// AllStorageKeys returns every key registered in the object journal.
|
// AllStorageKeys returns every key registered in the object journal.
|
||||||
AllStorageKeys(ctx context.Context) ([]string, error)
|
AllStorageKeys(ctx context.Context) ([]string, error)
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package photos
|
package photos
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -26,10 +33,49 @@ type Service struct {
|
|||||||
albums AlbumAccess
|
albums AlbumAccess
|
||||||
storage storage.Backend
|
storage storage.Backend
|
||||||
hub *sse.Hub
|
hub *sse.Hub
|
||||||
|
|
||||||
|
imageMax int64
|
||||||
|
videoMax int64
|
||||||
|
videoEnabled bool
|
||||||
|
tempDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
|
||||||
return &Service{store: store, albums: albums, storage: storage, hub: hub}
|
return &Service{
|
||||||
|
store: store,
|
||||||
|
albums: albums,
|
||||||
|
storage: storage,
|
||||||
|
hub: hub,
|
||||||
|
imageMax: 100 << 20,
|
||||||
|
videoMax: 2 << 30,
|
||||||
|
videoEnabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUploadLimits overrides the per-kind upload size limits.
|
||||||
|
func (s *Service) SetUploadLimits(imageMax, videoMax int64) {
|
||||||
|
if imageMax > 0 {
|
||||||
|
s.imageMax = imageMax
|
||||||
|
}
|
||||||
|
if videoMax > 0 {
|
||||||
|
s.videoMax = videoMax
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVideoEnabled controls whether video uploads are accepted. It is false when
|
||||||
|
// the ffmpeg/ffprobe toolchain is unavailable.
|
||||||
|
func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled }
|
||||||
|
|
||||||
|
// SetTempDir sets where upload spool files are written (empty = system temp).
|
||||||
|
func (s *Service) SetTempDir(dir string) { s.tempDir = dir }
|
||||||
|
|
||||||
|
// MaxUploadSize returns the largest per-file limit across media kinds, for the
|
||||||
|
// request body cap.
|
||||||
|
func (s *Service) MaxUploadSize() int64 {
|
||||||
|
if s.videoMax > s.imageMax {
|
||||||
|
return s.videoMax
|
||||||
|
}
|
||||||
|
return s.imageMax
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadInput struct {
|
type UploadInput struct {
|
||||||
@@ -37,9 +83,28 @@ type UploadInput struct {
|
|||||||
UserID string
|
UserID string
|
||||||
Filename string
|
Filename string
|
||||||
MimeType string
|
MimeType string
|
||||||
|
// Reader is the upload stream. When nil, Data is used instead.
|
||||||
|
Reader io.Reader
|
||||||
|
// Size is the declared payload size when known (0 when unknown).
|
||||||
|
Size int64
|
||||||
|
// Data is an optional in-memory payload for callers that already hold it.
|
||||||
Data []byte
|
Data []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (in UploadInput) reader() io.Reader {
|
||||||
|
if in.Reader != nil {
|
||||||
|
return in.Reader
|
||||||
|
}
|
||||||
|
return bytes.NewReader(in.Data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (in UploadInput) declaredSize() int64 {
|
||||||
|
if in.Size > 0 {
|
||||||
|
return in.Size
|
||||||
|
}
|
||||||
|
return int64(len(in.Data))
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
|
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
|
||||||
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
|
_, role, err := s.albums.Access(ctx, in.AlbumID, in.UserID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -49,19 +114,66 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
|||||||
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
|
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
|
||||||
}
|
}
|
||||||
|
|
||||||
mime := in.MimeType
|
br := bufio.NewReader(in.reader())
|
||||||
if mime == "" {
|
head, _ := br.Peek(512)
|
||||||
mime = http.DetectContentType(in.Data)
|
kind, mime := resolveMedia(in.MimeType, in.Filename, head)
|
||||||
|
if kind == "" {
|
||||||
|
return nil, httpx.BadRequest("invalid_file", "file is not an image or video")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(mime, "image/") {
|
if kind == KindVideo && !s.videoEnabled {
|
||||||
return nil, httpx.BadRequest("invalid_file", "file is not an image")
|
return nil, httpx.BadRequest("video_disabled", "video upload is not available")
|
||||||
|
}
|
||||||
|
limit := s.imageMax
|
||||||
|
if kind == KindVideo {
|
||||||
|
limit = s.videoMax
|
||||||
|
}
|
||||||
|
if sz := in.declaredSize(); sz > limit {
|
||||||
|
return nil, uploadTooLarge(kind, limit)
|
||||||
}
|
}
|
||||||
|
|
||||||
width, height, ok := inspect(in.Data)
|
// Spool to a temporary file, computing size and checksum without holding the
|
||||||
|
// whole payload in memory. LimitReader enforces the cap even when the
|
||||||
|
// declared size is unknown.
|
||||||
|
tmp, err := os.CreateTemp(s.tempDir, "upload-*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer func() { _ = os.Remove(tmpName) }()
|
||||||
|
|
||||||
|
hasher := sha256.New()
|
||||||
|
written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(br, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if written > limit {
|
||||||
|
return nil, uploadTooLarge(kind, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
var width, height int
|
||||||
|
var durationMS int64
|
||||||
|
attrs := map[string]any{}
|
||||||
|
var meta exifInfo
|
||||||
|
if kind == KindImage {
|
||||||
|
data, err := os.ReadFile(tmpName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
w, h, ok := inspect(data)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
return nil, httpx.BadRequest("invalid_image", "could not decode image")
|
||||||
}
|
}
|
||||||
meta := extractEXIF(in.Data)
|
width, height = w, h
|
||||||
|
meta = extractEXIF(data)
|
||||||
|
attrs = meta.Attributes
|
||||||
|
if attrs == nil {
|
||||||
|
attrs = map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ext := strings.ToLower(filepath.Ext(in.Filename))
|
ext := strings.ToLower(filepath.Ext(in.Filename))
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
@@ -78,23 +190,25 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
|||||||
UploaderID: in.UserID,
|
UploaderID: in.UserID,
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
OriginalFilename: in.Filename,
|
OriginalFilename: in.Filename,
|
||||||
Size: int64(len(in.Data)),
|
Size: written,
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
|
Kind: kind,
|
||||||
|
DurationMS: durationMS,
|
||||||
MimeType: mime,
|
MimeType: mime,
|
||||||
TakenAt: meta.TakenAt,
|
TakenAt: meta.TakenAt,
|
||||||
Latitude: meta.Latitude,
|
Latitude: meta.Latitude,
|
||||||
Longitude: meta.Longitude,
|
Longitude: meta.Longitude,
|
||||||
EXIF: meta.Attributes,
|
EXIF: attrs,
|
||||||
}
|
}
|
||||||
obj := &Object{
|
obj := &Object{
|
||||||
ID: objectID,
|
ID: objectID,
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
MimeType: mime,
|
MimeType: mime,
|
||||||
Size: int64(len(in.Data)),
|
Size: written,
|
||||||
Width: width,
|
Width: width,
|
||||||
Height: height,
|
Height: height,
|
||||||
Checksum: checksum(in.Data),
|
Checksum: hex.EncodeToString(hasher.Sum(nil)),
|
||||||
}
|
}
|
||||||
|
|
||||||
// The metadata layer is the ledger of record: the row is written first,
|
// The metadata layer is the ledger of record: the row is written first,
|
||||||
@@ -102,10 +216,17 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
|||||||
if err := s.store.CreateWithOriginal(ctx, p, obj); err != nil {
|
if err := s.store.CreateWithOriginal(ctx, p, obj); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := s.storage.Put(ctx, key, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
|
file, err := os.Open(tmpName)
|
||||||
|
if err != nil {
|
||||||
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
putErr := s.storage.Put(ctx, key, file, written, mime)
|
||||||
|
file.Close()
|
||||||
|
if putErr != nil {
|
||||||
|
_ = s.store.DeleteUpload(ctx, photoID, objectID)
|
||||||
|
return nil, putErr
|
||||||
|
}
|
||||||
if err := s.store.ActivateObject(ctx, objectID); err != nil {
|
if err := s.store.ActivateObject(ctx, objectID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -119,6 +240,72 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
|
|||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveMedia determines the media kind and effective MIME type of an upload.
|
||||||
|
// It prefers the declared MIME type, falls back to sniffing the leading bytes,
|
||||||
|
// then to the file extension.
|
||||||
|
func resolveMedia(declared, filename string, head []byte) (kind, outMime string) {
|
||||||
|
kind = kindFor(declared, filename)
|
||||||
|
if kind == "" && len(head) > 0 {
|
||||||
|
detected := http.DetectContentType(head)
|
||||||
|
kind = kindFor(detected, filename)
|
||||||
|
if declared == "" {
|
||||||
|
declared = detected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
outMime = declared
|
||||||
|
if !strings.HasPrefix(outMime, kind+"/") {
|
||||||
|
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
|
||||||
|
outMime = byExt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if outMime == "" || !strings.HasPrefix(outMime, kind+"/") {
|
||||||
|
if kind == KindVideo {
|
||||||
|
outMime = "video/mp4"
|
||||||
|
} else {
|
||||||
|
outMime = "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return kind, outMime
|
||||||
|
}
|
||||||
|
|
||||||
|
// kindFor classifies a MIME type or file extension as image or video.
|
||||||
|
func kindFor(mimeType, filename string) string {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(mimeType, "image/"):
|
||||||
|
return KindImage
|
||||||
|
case strings.HasPrefix(mimeType, "video/"):
|
||||||
|
return KindVideo
|
||||||
|
}
|
||||||
|
switch strings.ToLower(filepath.Ext(filename)) {
|
||||||
|
case ".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".mpg", ".mpeg", ".3gp":
|
||||||
|
return KindVideo
|
||||||
|
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".tiff", ".tif", ".bmp", ".avif":
|
||||||
|
return KindImage
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadTooLarge(kind string, limit int64) error {
|
||||||
|
return httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
|
||||||
|
fmt.Sprintf("file exceeds the maximum %s upload size (%s)", kind, humanSize(limit)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func humanSize(n int64) string {
|
||||||
|
switch {
|
||||||
|
case n >= 1<<30:
|
||||||
|
return fmt.Sprintf("%d GB", n>>30)
|
||||||
|
case n >= 1<<20:
|
||||||
|
return fmt.Sprintf("%d MB", n>>20)
|
||||||
|
case n >= 1<<10:
|
||||||
|
return fmt.Sprintf("%d KB", n>>10)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
||||||
if p.StorageKey != "" {
|
if p.StorageKey != "" {
|
||||||
url, err := s.storage.URL(ctx, p.StorageKey)
|
url, err := s.storage.URL(ctx, p.StorageKey)
|
||||||
@@ -143,6 +330,11 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
|
|||||||
p.PreviewURL = url
|
p.PreviewURL = url
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if p.PlaybackKey != "" {
|
||||||
|
if url, err := s.storage.URL(ctx, p.PlaybackKey); err == nil {
|
||||||
|
p.PlaybackURL = url
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -226,8 +226,9 @@ func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Dura
|
|||||||
return objs, nil
|
return objs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaimVariantJobs leases due variant jobs and returns their photo ids.
|
// ClaimVariantJobs leases due variant jobs and returns their photo ids. A
|
||||||
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
|
// non-empty kind restricts claims to that media kind.
|
||||||
|
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, kind string, limit int, lease time.Duration) ([]string, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 10
|
limit = 10
|
||||||
}
|
}
|
||||||
@@ -243,11 +244,12 @@ func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease tim
|
|||||||
`SELECT vj.photo_id FROM variant_jobs vj
|
`SELECT vj.photo_id FROM variant_jobs vj
|
||||||
JOIN photos p ON p.id = vj.photo_id
|
JOIN photos p ON p.id = vj.photo_id
|
||||||
WHERE vj.next_attempt_at <= $1
|
WHERE vj.next_attempt_at <= $1
|
||||||
|
AND ($3 = '' OR vj.kind = $3)
|
||||||
ORDER BY (vj.attempts > 0) ASC,
|
ORDER BY (vj.attempts > 0) ASC,
|
||||||
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
|
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
|
||||||
vj.next_attempt_at ASC,
|
vj.next_attempt_at ASC,
|
||||||
vj.photo_id DESC
|
vj.photo_id DESC
|
||||||
LIMIT $2`, now, limit)
|
LIMIT $2`, now, limit, kind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
|||||||
COALESCE(o_t.storage_key, ''),
|
COALESCE(o_t.storage_key, ''),
|
||||||
COALESCE(o_m.storage_key, ''),
|
COALESCE(o_m.storage_key, ''),
|
||||||
COALESCE(o_p.storage_key, ''),
|
COALESCE(o_p.storage_key, ''),
|
||||||
|
COALESCE(o_pb.storage_key, ''),
|
||||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||||
|
p.kind, COALESCE(p.duration_ms, 0),
|
||||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||||
@@ -44,7 +46,9 @@ const sqlitePhotoJoins = `
|
|||||||
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
||||||
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
||||||
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
||||||
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
|
LEFT JOIN objects o_p ON o_p.id = po_p.object_id
|
||||||
|
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback'
|
||||||
|
LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id`
|
||||||
|
|
||||||
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
@@ -53,8 +57,9 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
|
|||||||
var lat, long sql.NullFloat64
|
var lat, long sql.NullFloat64
|
||||||
var liked int
|
var liked int
|
||||||
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
|
&p.Kind, &p.DurationMS,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &tagsText); err != nil {
|
||||||
@@ -94,11 +99,12 @@ func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, o
|
|||||||
}
|
}
|
||||||
var createdAt string
|
var createdAt string
|
||||||
if err := tx.QueryRowContext(ctx,
|
if err := tx.QueryRowContext(ctx,
|
||||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
RETURNING created_at`,
|
RETURNING created_at`,
|
||||||
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
|
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
|
||||||
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
|
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
|
||||||
|
mediaKind(p.Kind), p.DurationMS,
|
||||||
).Scan(&createdAt); err != nil {
|
).Scan(&createdAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -109,7 +115,8 @@ func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, o
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
|
`INSERT INTO variant_jobs (photo_id, kind) VALUES ($1, $2) ON CONFLICT (photo_id) DO NOTHING`,
|
||||||
|
p.ID, mediaKind(p.Kind)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
@@ -221,10 +228,11 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
||||||
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
|
id, nullIfEmpty(albumID), uploaderID, src.OriginalFilename,
|
||||||
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON)); err != nil {
|
nullTime(src.TakenAt), nullFloat(src.Latitude), nullFloat(src.Longitude), src.Caption, string(exifJSON),
|
||||||
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx,
|
if _, err := tx.ExecContext(ctx,
|
||||||
@@ -274,8 +282,9 @@ func (s *SQLiteStore) ListTrash(ctx context.Context, userID string) ([]models.Ph
|
|||||||
var lat, long sql.NullFloat64
|
var lat, long sql.NullFloat64
|
||||||
var liked int
|
var liked int
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
|
&p.Kind, &p.DurationMS,
|
||||||
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
&takenAt, &lat, &long, &p.Caption, &createdAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
|
&p.LikesCount, &p.CommentsCount, &liked, &exifText, &deletedAt, &albumTitle, &tagsText); err != nil {
|
||||||
@@ -425,6 +434,23 @@ func (s *SQLiteStore) UpdateFilename(ctx context.Context, id, filename string) e
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaMeta records probed video facts: duration on the photo and frame
|
||||||
|
// dimensions on the original object.
|
||||||
|
func (s *SQLiteStore) SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error {
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE photos SET duration_ms = $2 WHERE id = $1`, photoID, durationMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE objects SET width = $2, height = $3 WHERE id = $1`, objectID, width, height); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
|
// BackfillEXIF mirrors the PostgreSQL implementation, reading the original blob
|
||||||
// of every photo with empty exif.
|
// of every photo with empty exif.
|
||||||
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) {
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id,
|
|||||||
COALESCE(o_t.storage_key, ''),
|
COALESCE(o_t.storage_key, ''),
|
||||||
COALESCE(o_m.storage_key, ''),
|
COALESCE(o_m.storage_key, ''),
|
||||||
COALESCE(o_p.storage_key, ''),
|
COALESCE(o_p.storage_key, ''),
|
||||||
|
COALESCE(o_pb.storage_key, ''),
|
||||||
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
p.original_filename, COALESCE(o_o.size, 0), COALESCE(o_o.width, 0), COALESCE(o_o.height, 0), COALESCE(o_o.mime_type, ''),
|
||||||
|
p.kind, COALESCE(p.duration_ms, 0),
|
||||||
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
|
||||||
u.id, u.name, COALESCE(u.avatar_url, ''),
|
u.id, u.name, COALESCE(u.avatar_url, ''),
|
||||||
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
(SELECT count(*) FROM likes l WHERE l.photo_id = p.id),
|
||||||
@@ -50,14 +52,17 @@ const photoJoins = `
|
|||||||
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
LEFT JOIN photo_objects po_m ON po_m.photo_id = p.id AND po_m.role = 'medium'
|
||||||
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
LEFT JOIN objects o_m ON o_m.id = po_m.object_id
|
||||||
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
LEFT JOIN photo_objects po_p ON po_p.photo_id = p.id AND po_p.role = 'preview'
|
||||||
LEFT JOIN objects o_p ON o_p.id = po_p.object_id`
|
LEFT JOIN objects o_p ON o_p.id = po_p.object_id
|
||||||
|
LEFT JOIN photo_objects po_pb ON po_pb.photo_id = p.id AND po_pb.role = 'playback'
|
||||||
|
LEFT JOIN objects o_pb ON o_pb.id = po_pb.object_id`
|
||||||
|
|
||||||
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
func scanPhoto(row pgx.Row) (*models.Photo, error) {
|
||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
err := row.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
|
&p.Kind, &p.DurationMS,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText)
|
||||||
@@ -114,10 +119,11 @@ func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Ob
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.QueryRow(ctx,
|
if err := tx.QueryRow(ctx,
|
||||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)
|
||||||
RETURNING created_at`,
|
RETURNING created_at`,
|
||||||
p.ID, p.AlbumID, p.UploaderID, p.OriginalFilename, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
p.ID, p.AlbumID, p.UploaderID, p.OriginalFilename, p.TakenAt, p.Latitude, p.Longitude, p.Caption, string(exifJSON),
|
||||||
|
mediaKind(p.Kind), p.DurationMS,
|
||||||
).Scan(&p.CreatedAt); err != nil {
|
).Scan(&p.CreatedAt); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -127,7 +133,8 @@ func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Ob
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`INSERT INTO variant_jobs (photo_id) VALUES ($1) ON CONFLICT (photo_id) DO NOTHING`, p.ID); err != nil {
|
`INSERT INTO variant_jobs (photo_id, kind) VALUES ($1, $2) ON CONFLICT (photo_id) DO NOTHING`,
|
||||||
|
p.ID, mediaKind(p.Kind)); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Commit(ctx)
|
return tx.Commit(ctx)
|
||||||
@@ -189,8 +196,9 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
|
|||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
|
&p.Kind, &p.DurationMS,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &tagsText); err != nil {
|
||||||
@@ -248,9 +256,10 @@ func (s *Store) Clone(ctx context.Context, src *models.Photo, albumID, uploaderI
|
|||||||
defer func() { _ = tx.Rollback(ctx) }()
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
|
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif, kind, duration_ms)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11)`,
|
||||||
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil {
|
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON),
|
||||||
|
mediaKind(src.Kind), src.DurationMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.Exec(ctx,
|
if _, err := tx.Exec(ctx,
|
||||||
@@ -302,8 +311,9 @@ func (s *Store) ListTrash(ctx context.Context, userID string) ([]models.Photo, e
|
|||||||
var p models.Photo
|
var p models.Photo
|
||||||
var exifText, tagsText string
|
var exifText, tagsText string
|
||||||
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
if err := rows.Scan(&p.ID, &p.AlbumID, &p.UploaderID, &p.StorageKey,
|
||||||
&p.ThumbKey, &p.MediumKey, &p.PreviewKey,
|
&p.ThumbKey, &p.MediumKey, &p.PreviewKey, &p.PlaybackKey,
|
||||||
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
&p.OriginalFilename, &p.Size, &p.Width, &p.Height, &p.MimeType,
|
||||||
|
&p.Kind, &p.DurationMS,
|
||||||
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
|
||||||
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
|
||||||
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
|
&p.LikesCount, &p.CommentsCount, &p.LikedByMe, &exifText, &p.DeletedAt, &p.AlbumTitle, &tagsText); err != nil {
|
||||||
@@ -446,6 +456,23 @@ func (s *Store) UpdateFilename(ctx context.Context, id, filename string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMediaMeta records probed video facts: duration on the photo and frame
|
||||||
|
// dimensions on the original object.
|
||||||
|
func (s *Store) SetMediaMeta(ctx context.Context, photoID, objectID string, durationMS int64, width, height int) error {
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback(ctx) }()
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE photos SET duration_ms = $2 WHERE id = $1`, photoID, durationMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, `UPDATE objects SET width = $2, height = $3 WHERE id = $1`, objectID, width, height); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
type backfillRow struct {
|
type backfillRow struct {
|
||||||
ID string
|
ID string
|
||||||
StorageKey string
|
StorageKey string
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package photos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galo/internal/config"
|
||||||
|
"galo/internal/models"
|
||||||
|
"galo/internal/video"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeStore implements the slice of Repository used by generateVideo.
|
||||||
|
type fakeStore struct {
|
||||||
|
Repository
|
||||||
|
orig *Object
|
||||||
|
siblings []string
|
||||||
|
objects map[string]*Object
|
||||||
|
bindings map[string]string
|
||||||
|
completed bool
|
||||||
|
failmsgs []string
|
||||||
|
meta [3]int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFakeStore(orig *Object, siblings []string) *fakeStore {
|
||||||
|
return &fakeStore{orig: orig, siblings: siblings, objects: map[string]*Object{}, bindings: map[string]string{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *fakeStore) OriginalObject(context.Context, string) (*Object, error) { return s.orig, nil }
|
||||||
|
func (s *fakeStore) PhotosByOriginalObject(context.Context, string) ([]string, error) {
|
||||||
|
return s.siblings, nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) CreateObject(_ context.Context, o *Object) error { s.objects[o.ID] = o; return nil }
|
||||||
|
func (s *fakeStore) ActivateObject(context.Context, string) error { return nil }
|
||||||
|
func (s *fakeStore) DeleteObjectRow(context.Context, string) error { return nil }
|
||||||
|
func (s *fakeStore) MarkObjectDeleting(context.Context, string) error { return nil }
|
||||||
|
func (s *fakeStore) BindObject(_ context.Context, _, objectID, role string) error {
|
||||||
|
s.bindings[role] = objectID
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) CompleteVariantJob(context.Context, string) error { s.completed = true; return nil }
|
||||||
|
func (s *fakeStore) FailVariantJob(_ context.Context, _, msg string, _ int) error {
|
||||||
|
s.failmsgs = append(s.failmsgs, msg)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) SetMediaMeta(_ context.Context, _, _ string, durationMS int64, width, height int) error {
|
||||||
|
s.meta = [3]int64{durationMS, int64(width), int64(height)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *fakeStore) GetByID(context.Context, string, string) (*models.Photo, error) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFramePNG writes a small valid PNG the fake ffmpeg copies as its output.
|
||||||
|
func writeFramePNG(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||||
|
for x := 0; x < 8; x++ {
|
||||||
|
for y := 0; y < 8; y++ {
|
||||||
|
img.Set(x, y, color.RGBA{R: uint8(x * 20), G: uint8(y * 20), B: 200, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, img); err != nil {
|
||||||
|
t.Fatalf("encode png: %v", err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "frame.png")
|
||||||
|
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||||
|
t.Fatalf("write frame: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeScript(t *testing.T, name, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), name)
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateVideoHappyPath(t *testing.T) {
|
||||||
|
frame := writeFramePNG(t)
|
||||||
|
t.Setenv("FAKE_FRAME", frame)
|
||||||
|
|
||||||
|
// A fake ffmpeg copies the frame to its output path (the last argument);
|
||||||
|
// a fake ffprobe reports a web-compatible h264/aac source.
|
||||||
|
ffmpeg := writeScript(t, "ffmpeg", "#!/bin/sh\nout=\"\"\nfor a in \"$@\"; do out=\"$a\"; done\ncp \"$FAKE_FRAME\" \"$out\"\n")
|
||||||
|
ffprobe := writeScript(t, "ffprobe", "#!/bin/sh\ncat <<'JSON'\n{\"streams\":[{\"codec_type\":\"video\",\"codec_name\":\"h264\",\"profile\":\"High\",\"pix_fmt\":\"yuv420p\",\"width\":1280,\"height\":720},{\"codec_type\":\"audio\",\"codec_name\":\"aac\"}],\"format\":{\"format_name\":\"mov,mp4,m4a,3gp,3g2,mj2\",\"duration\":\"5.5\"}}\nJSON\n")
|
||||||
|
|
||||||
|
tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe})
|
||||||
|
if !tools.Available() {
|
||||||
|
t.Fatalf("fake toolchain not detected: %+v", tools)
|
||||||
|
}
|
||||||
|
|
||||||
|
orig := &Object{ID: "obj1", StorageKey: "objects/obj1/original.mp4", State: ObjectReady}
|
||||||
|
store := newFakeStore(orig, []string{"photo1"})
|
||||||
|
svc := &Service{store: store, storage: fakeBackend{}}
|
||||||
|
w := NewWorker(svc, 0, 0, 0, 1)
|
||||||
|
w.SetVideo(tools, 1)
|
||||||
|
|
||||||
|
w.generateVideo(context.Background(), "photo1")
|
||||||
|
|
||||||
|
if len(store.failmsgs) != 0 {
|
||||||
|
t.Fatalf("unexpected failures: %v", store.failmsgs)
|
||||||
|
}
|
||||||
|
if !store.completed {
|
||||||
|
t.Fatalf("job not completed")
|
||||||
|
}
|
||||||
|
for _, role := range []string{RoleThumb, RoleMedium, RolePreview, RolePlayback} {
|
||||||
|
if store.bindings[role] == "" {
|
||||||
|
t.Fatalf("role %q not bound; bindings=%v", role, store.bindings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if store.objects[store.bindings[RolePlayback]].MimeType != "video/mp4" {
|
||||||
|
t.Fatalf("playback mime = %q", store.objects[store.bindings[RolePlayback]].MimeType)
|
||||||
|
}
|
||||||
|
if store.meta != [3]int64{5500, 1280, 720} {
|
||||||
|
t.Fatalf("media meta = %v, want duration/width/height", store.meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateVideoProbeFailureFails(t *testing.T) {
|
||||||
|
ffmpeg := writeScript(t, "ffmpeg", "#!/bin/sh\nexit 0\n")
|
||||||
|
ffprobe := writeScript(t, "ffprobe", "#!/bin/sh\necho boom >&2\nexit 1\n")
|
||||||
|
tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe})
|
||||||
|
|
||||||
|
store := newFakeStore(&Object{ID: "obj1", StorageKey: "objects/obj1/original.mp4", State: ObjectReady}, []string{"photo1"})
|
||||||
|
svc := &Service{store: store, storage: fakeBackend{}}
|
||||||
|
w := NewWorker(svc, 0, 0, 0, 1)
|
||||||
|
w.SetVideo(tools, 1)
|
||||||
|
|
||||||
|
w.generateVideo(context.Background(), "photo1")
|
||||||
|
if len(store.failmsgs) == 0 {
|
||||||
|
t.Fatal("expected job failure on probe error")
|
||||||
|
}
|
||||||
|
if store.completed {
|
||||||
|
t.Fatal("job must not complete after failure")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,16 +3,29 @@ package photos
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"galo/internal/auth"
|
"galo/internal/auth"
|
||||||
|
"galo/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
const maxVariantAttempts = 5
|
const maxVariantAttempts = 5
|
||||||
|
|
||||||
|
// videoJobLease is the processing lease for a video job. It is far longer than
|
||||||
|
// the image lease because a transcode can run for minutes, and must outlast the
|
||||||
|
// worst-case processing so a job is never claimed twice concurrently.
|
||||||
|
const videoJobLease = 30 * time.Minute
|
||||||
|
|
||||||
|
// videoJobTimeout bounds a single ffmpeg/ffprobe invocation.
|
||||||
|
const videoJobTimeout = 20 * time.Minute
|
||||||
|
|
||||||
// Worker is the background reconciler. It drives the variant generation queue
|
// Worker is the background reconciler. It drives the variant generation queue
|
||||||
// and the object garbage collector entirely from the state recorded in the
|
// and the object garbage collector entirely from the state recorded in the
|
||||||
// metadata layer, so it resumes correctly after a restart.
|
// metadata layer, so it resumes correctly after a restart.
|
||||||
@@ -23,6 +36,11 @@ type Worker struct {
|
|||||||
pendingTTL time.Duration
|
pendingTTL time.Duration
|
||||||
lease time.Duration
|
lease time.Duration
|
||||||
concurrency int
|
concurrency int
|
||||||
|
|
||||||
|
tools *video.Toolchain
|
||||||
|
videoConc int
|
||||||
|
videoLease time.Duration
|
||||||
|
tempDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWorker builds a worker. Parameters are clamped to sane defaults.
|
// NewWorker builds a worker. Parameters are clamped to sane defaults.
|
||||||
@@ -46,9 +64,23 @@ func NewWorker(svc *Service, interval time.Duration, batch int, pendingTTL time.
|
|||||||
pendingTTL: pendingTTL,
|
pendingTTL: pendingTTL,
|
||||||
lease: interval * 4,
|
lease: interval * 4,
|
||||||
concurrency: concurrency,
|
concurrency: concurrency,
|
||||||
|
videoConc: 1,
|
||||||
|
videoLease: videoJobLease,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetVideo enables video processing with the given toolchain and a separate,
|
||||||
|
// small transcode concurrency. A nil or unavailable toolchain leaves video jobs
|
||||||
|
// untouched.
|
||||||
|
func (w *Worker) SetVideo(tools *video.Toolchain, concurrency int) {
|
||||||
|
w.tools = tools
|
||||||
|
if concurrency > 0 {
|
||||||
|
w.videoConc = concurrency
|
||||||
|
}
|
||||||
|
w.videoLease = videoJobLease
|
||||||
|
w.tempDir = tools.TempDir()
|
||||||
|
}
|
||||||
|
|
||||||
// Run loops until the context is cancelled.
|
// Run loops until the context is cancelled.
|
||||||
func (w *Worker) Run(ctx context.Context) {
|
func (w *Worker) Run(ctx context.Context) {
|
||||||
slog.Info("background worker started", "interval", w.interval)
|
slog.Info("background worker started", "interval", w.interval)
|
||||||
@@ -65,14 +97,21 @@ func (w *Worker) Run(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ProcessOnce runs a single round of all queues. It is used by tests and manual
|
||||||
|
// reconciliation and does not block.
|
||||||
|
func (w *Worker) ProcessOnce(ctx context.Context) {
|
||||||
|
w.tick(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Worker) tick(ctx context.Context) {
|
func (w *Worker) tick(ctx context.Context) {
|
||||||
w.processVariantJobs(ctx)
|
w.processVariantJobs(ctx)
|
||||||
|
w.processVideoJobs(ctx)
|
||||||
w.collectGarbage(ctx)
|
w.collectGarbage(ctx)
|
||||||
w.reapStalePending(ctx)
|
w.reapStalePending(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *Worker) processVariantJobs(ctx context.Context) {
|
func (w *Worker) processVariantJobs(ctx context.Context) {
|
||||||
ids, err := w.svc.store.ClaimVariantJobs(ctx, w.batch, w.lease)
|
ids, err := w.svc.store.ClaimVariantJobs(ctx, KindImage, w.batch, w.lease)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("claim variant jobs", "error", err)
|
slog.Error("claim variant jobs", "error", err)
|
||||||
return
|
return
|
||||||
@@ -82,6 +121,26 @@ func (w *Worker) processVariantJobs(ctx context.Context) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processVideoJobs claims and processes video jobs on a separate, small pool
|
||||||
|
// with a long lease so transcoding does not starve image variant generation.
|
||||||
|
func (w *Worker) processVideoJobs(ctx context.Context) {
|
||||||
|
if w.tools == nil || !w.tools.Available() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids, err := w.svc.store.ClaimVariantJobs(ctx, KindVideo, w.batch, w.videoLease)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("claim video jobs", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pool := &Worker{concurrency: w.videoConc}
|
||||||
|
pool.forEachConcurrent(ids, func(id string) {
|
||||||
|
w.generateVideo(ctx, id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// forEachConcurrent runs fn over ids with at most w.concurrency functions in
|
// forEachConcurrent runs fn over ids with at most w.concurrency functions in
|
||||||
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
|
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
|
||||||
// scales with cores; a concurrency of 1 stays strictly sequential.
|
// scales with cores; a concurrency of 1 stays strictly sequential.
|
||||||
@@ -152,22 +211,28 @@ func (w *Worker) generateVariants(ctx context.Context, photoID string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createVariant stores one derived object and binds it to every photo sharing
|
// createVariant stores one derived image object and binds it to every photo
|
||||||
// the original, so copies reuse the same physical variant.
|
// sharing the original, so copies reuse the same physical variant.
|
||||||
func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role string, data []byte) error {
|
func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role string, data []byte) error {
|
||||||
|
return w.createObject(ctx, photoIDs, role, ".webp", "image/webp", int64(len(data)), checksum(data), bytes.NewReader(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// createObject stores one derived object and binds it to every photo sharing
|
||||||
|
// the original.
|
||||||
|
func (w *Worker) createObject(ctx context.Context, photoIDs []string, role, ext, mimeType string, size int64, sum string, body io.Reader) error {
|
||||||
objID := auth.NewID()
|
objID := auth.NewID()
|
||||||
key := objectKey(objID, role, ".webp")
|
key := objectKey(objID, role, ext)
|
||||||
obj := &Object{
|
obj := &Object{
|
||||||
ID: objID,
|
ID: objID,
|
||||||
StorageKey: key,
|
StorageKey: key,
|
||||||
MimeType: "image/webp",
|
MimeType: mimeType,
|
||||||
Size: int64(len(data)),
|
Size: size,
|
||||||
Checksum: checksum(data),
|
Checksum: sum,
|
||||||
}
|
}
|
||||||
if err := w.svc.store.CreateObject(ctx, obj); err != nil {
|
if err := w.svc.store.CreateObject(ctx, obj); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := w.svc.storage.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "image/webp"); err != nil {
|
if err := w.svc.storage.Put(ctx, key, body, size, mimeType); err != nil {
|
||||||
_ = w.svc.store.DeleteObjectRow(ctx, objID)
|
_ = w.svc.store.DeleteObjectRow(ctx, objID)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -191,6 +256,193 @@ func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role stri
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// generateVideo prepares a video job: probe, poster frame (reusing the image
|
||||||
|
// variant pipeline) and a web-friendly playback variant, then records the
|
||||||
|
// probed facts and notifies subscribers.
|
||||||
|
func (w *Worker) generateVideo(ctx context.Context, photoID string) {
|
||||||
|
orig, err := w.svc.store.OriginalObject(ctx, photoID)
|
||||||
|
if err != nil {
|
||||||
|
_ = w.svc.store.CompleteVariantJob(ctx, photoID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if orig.State != ObjectReady {
|
||||||
|
w.fail(ctx, photoID, "original object is not ready")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
inPath, cleanup, err := w.spoolOriginal(ctx, orig.StorageKey)
|
||||||
|
if err != nil {
|
||||||
|
w.fail(ctx, photoID, "spool original: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
info, err := video.Probe(ctx, w.tools.FFprobePath(), inPath)
|
||||||
|
if err != nil {
|
||||||
|
w.fail(ctx, photoID, "probe: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siblings, err := w.svc.store.PhotosByOriginalObject(ctx, orig.ID)
|
||||||
|
if err != nil || len(siblings) == 0 {
|
||||||
|
siblings = []string{photoID}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.storePoster(ctx, inPath, siblings); err != nil {
|
||||||
|
w.fail(ctx, photoID, "poster: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.storePlayback(ctx, inPath, info, siblings); err != nil {
|
||||||
|
w.fail(ctx, photoID, "playback: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := w.svc.store.SetMediaMeta(ctx, photoID, orig.ID, info.DurationMS, info.Width, info.Height); err != nil {
|
||||||
|
w.fail(ctx, photoID, "save media meta: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = w.svc.store.CompleteVariantJob(ctx, photoID)
|
||||||
|
for _, pid := range siblings {
|
||||||
|
w.notify(ctx, pid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// spoolOriginal streams the original blob into a temporary file so ffmpeg reads
|
||||||
|
// a seekable input. The returned cleanup removes the file.
|
||||||
|
func (w *Worker) spoolOriginal(ctx context.Context, key string) (string, func(), error) {
|
||||||
|
src, err := w.svc.storage.Open(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
f, err := os.CreateTemp(w.tempDir, "video-in-*")
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
name := f.Name()
|
||||||
|
cleanup := func() { _ = os.Remove(name) }
|
||||||
|
if _, err := io.Copy(f, src); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
return name, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storePoster extracts a frame, retrying at the start for videos whose first
|
||||||
|
// second is unavailable, then generates the image variants from it.
|
||||||
|
func (w *Worker) storePoster(ctx context.Context, inPath string, siblings []string) error {
|
||||||
|
pf, err := os.CreateTemp(w.tempDir, "video-poster-*.jpg")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
posterPath := pf.Name()
|
||||||
|
_ = pf.Close()
|
||||||
|
defer func() { _ = os.Remove(posterPath) }()
|
||||||
|
|
||||||
|
// Seek to 1s for a representative frame, falling back to the first frame
|
||||||
|
// when that yields no image (for example a clip shorter than one second).
|
||||||
|
if err := w.runPoster(ctx, inPath, posterPath, 1); err != nil {
|
||||||
|
if err := w.runPoster(ctx, inPath, posterPath, 0); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
poster, err := os.ReadFile(posterPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(poster) == 0 {
|
||||||
|
return errors.New("poster frame is empty")
|
||||||
|
}
|
||||||
|
thumb, medium, preview, err := buildVariants(poster)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.createVariant(ctx, siblings, RoleThumb, thumb); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := w.createVariant(ctx, siblings, RoleMedium, medium); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.createVariant(ctx, siblings, RolePreview, preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPoster extracts a poster frame at the given second and reports an error
|
||||||
|
// when ffmpeg fails or produces no image.
|
||||||
|
func (w *Worker) runPoster(ctx context.Context, inPath, outPath string, atSecond int) error {
|
||||||
|
if err := w.runFFmpeg(ctx, video.PosterArgs(inPath, outPath, atSecond)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
info, err := os.Stat(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Size() == 0 {
|
||||||
|
return errors.New("poster frame is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storePlayback produces the web-friendly mp4 and binds it as the playback
|
||||||
|
// role. Web-compatible sources are remuxed; everything else is transcoded.
|
||||||
|
func (w *Worker) storePlayback(ctx context.Context, inPath string, info *video.Info, siblings []string) error {
|
||||||
|
of, err := os.CreateTemp(w.tempDir, "video-out-*.mp4")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
outPath := of.Name()
|
||||||
|
_ = of.Close()
|
||||||
|
defer func() { _ = os.Remove(outPath) }()
|
||||||
|
|
||||||
|
remuxable := info.CanRemux(video.MaxLongSide)
|
||||||
|
if remuxable {
|
||||||
|
if err := w.runFFmpeg(ctx, video.RemuxArgs(inPath, outPath)); err != nil {
|
||||||
|
// Some remux-eligible files still fail; fall back to a transcode.
|
||||||
|
if err2 := w.runFFmpeg(ctx, video.TranscodeArgs(inPath, outPath, video.MaxLongSide)); err2 != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if err := w.runFFmpeg(ctx, video.TranscodeArgs(inPath, outPath, video.MaxLongSide)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(outPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
stat, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sum, err := hashReader(f)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := f.Seek(0, io.SeekStart); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return w.createObject(ctx, siblings, RolePlayback, ".mp4", "video/mp4", stat.Size(), sum, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runFFmpeg runs ffmpeg with a bounded timeout.
|
||||||
|
func (w *Worker) runFFmpeg(ctx context.Context, args []string) error {
|
||||||
|
tctx, cancel := context.WithTimeout(ctx, videoJobTimeout)
|
||||||
|
defer cancel()
|
||||||
|
return video.Run(tctx, w.tools.FFmpegPath(), args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hashReader(r io.Reader) (string, error) {
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, r); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Worker) notify(ctx context.Context, photoID string) {
|
func (w *Worker) notify(ctx context.Context, photoID string) {
|
||||||
p, err := w.svc.store.GetByID(ctx, photoID, "")
|
p, err := w.svc.store.GetByID(ctx, photoID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"galo/internal/config"
|
||||||
"galo/internal/storage"
|
"galo/internal/storage"
|
||||||
|
"galo/internal/video"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
||||||
@@ -140,6 +142,28 @@ func TestWorkerConcurrencySequentialAtOne(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProcessVideoJobsSkipsWithoutTools(t *testing.T) {
|
||||||
|
// With no toolchain the video pass must return before touching the store,
|
||||||
|
// so a nil service is safe here.
|
||||||
|
w := NewWorker(&Service{}, time.Second, 10, time.Minute, 2)
|
||||||
|
w.processVideoJobs(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetVideoConfiguresPool(t *testing.T) {
|
||||||
|
tools := video.Detect(config.Video{FFmpegPath: "definitely-missing-xyz", FFprobePath: "definitely-missing-xyz"})
|
||||||
|
w := &Worker{}
|
||||||
|
w.SetVideo(tools, 2)
|
||||||
|
if w.videoConc != 2 {
|
||||||
|
t.Fatalf("video concurrency = %d, want 2", w.videoConc)
|
||||||
|
}
|
||||||
|
if w.videoLease != videoJobLease {
|
||||||
|
t.Fatalf("video lease = %v, want %v", w.videoLease, videoJobLease)
|
||||||
|
}
|
||||||
|
if tools.Available() {
|
||||||
|
t.Fatal("missing toolchain should be unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
|
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
var n int
|
var n int
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
|||||||
{"Social", testSocial},
|
{"Social", testSocial},
|
||||||
{"Photos", testPhotos},
|
{"Photos", testPhotos},
|
||||||
{"Objects", testObjects},
|
{"Objects", testObjects},
|
||||||
|
{"VariantJobKinds", testVariantJobKinds},
|
||||||
{"Settings", testSettings},
|
{"Settings", testSettings},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
@@ -94,6 +95,19 @@ func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID s
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func seedVideoPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo {
|
||||||
|
t.Helper()
|
||||||
|
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
|
||||||
|
p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "v.mp4", Kind: photos.KindVideo}
|
||||||
|
if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
|
||||||
|
t.Fatalf("create video photo: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||||
|
t.Fatalf("activate object: %v", err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func testAuth(t *testing.T, ctx context.Context, r Repos) {
|
func testAuth(t *testing.T, ctx context.Context, r Repos) {
|
||||||
u := seedUser(t, ctx, r, "Tester")
|
u := seedUser(t, ctx, r, "Tester")
|
||||||
|
|
||||||
@@ -498,6 +512,19 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
t.Fatalf("bind medium: %v", err)
|
t.Fatalf("bind medium: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The playback role is a first-class binding introduced for video, so it
|
||||||
|
// must be accepted by the role constraint on every backend.
|
||||||
|
playback := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "video/mp4", State: photos.ObjectPending}
|
||||||
|
if err := r.Photos.CreateObject(ctx, playback); err != nil {
|
||||||
|
t.Fatalf("create playback object: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.ActivateObject(ctx, playback.ID); err != nil {
|
||||||
|
t.Fatalf("activate playback: %v", err)
|
||||||
|
}
|
||||||
|
if err := r.Photos.BindObject(ctx, p.ID, playback.ID, photos.RolePlayback); err != nil {
|
||||||
|
t.Fatalf("bind playback: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
// After the photo is gone, its objects have no bindings and are collectible.
|
// After the photo is gone, its objects have no bindings and are collectible.
|
||||||
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
||||||
t.Fatalf("hard delete: %v", err)
|
t.Fatalf("hard delete: %v", err)
|
||||||
@@ -510,8 +537,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
for _, o := range claimed {
|
for _, o := range claimed {
|
||||||
got[o.ID] = true
|
got[o.ID] = true
|
||||||
}
|
}
|
||||||
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] {
|
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] || !got[playback.ID] {
|
||||||
t.Fatalf("claimed = %+v, want original, thumb and medium", got)
|
t.Fatalf("claimed = %+v, want original, thumb, medium and playback", got)
|
||||||
}
|
}
|
||||||
for _, o := range claimed {
|
for _, o := range claimed {
|
||||||
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
||||||
@@ -536,7 +563,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
|
|
||||||
// Variant queue: a fresh photo has one job.
|
// Variant queue: a fresh photo has one job.
|
||||||
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
jobs, err := r.Photos.ClaimVariantJobs(ctx, 10, time.Minute)
|
jobs, err := r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("claim jobs: %v", err)
|
t.Fatalf("claim jobs: %v", err)
|
||||||
}
|
}
|
||||||
@@ -546,7 +573,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
|
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
|
||||||
t.Fatalf("complete job: %v", err)
|
t.Fatalf("complete job: %v", err)
|
||||||
}
|
}
|
||||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p2.ID) {
|
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p2.ID) {
|
||||||
t.Fatalf("completed job still claimed: %+v", jobs)
|
t.Fatalf("completed job still claimed: %+v", jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -555,7 +582,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
|
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
|
||||||
t.Fatalf("fail job: %v", err)
|
t.Fatalf("fail job: %v", err)
|
||||||
}
|
}
|
||||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
|
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) {
|
||||||
t.Fatalf("failed job retried immediately: %+v", jobs)
|
t.Fatalf("failed job retried immediately: %+v", jobs)
|
||||||
}
|
}
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
@@ -563,7 +590,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
t.Fatalf("fail job again: %v", err)
|
t.Fatalf("fail job again: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
|
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) {
|
||||||
t.Fatalf("abandoned job still present: %+v", jobs)
|
t.Fatalf("abandoned job still present: %+v", jobs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -572,7 +599,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
time.Sleep(5 * time.Millisecond)
|
time.Sleep(5 * time.Millisecond)
|
||||||
pNewer := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
pNewer := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
order, err := r.Photos.ClaimVariantJobs(ctx, 2, time.Minute)
|
order, err := r.Photos.ClaimVariantJobs(ctx, "", 2, time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("claim ordered jobs: %v", err)
|
t.Fatalf("claim ordered jobs: %v", err)
|
||||||
}
|
}
|
||||||
@@ -581,6 +608,29 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) {
|
||||||
|
owner := seedUser(t, ctx, r, "Owner")
|
||||||
|
a := seedAlbum(t, ctx, r, owner.ID)
|
||||||
|
img := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
|
vid := seedVideoPhoto(t, ctx, r, owner.ID, a.ID)
|
||||||
|
|
||||||
|
imageJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindImage, 10, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim image jobs: %v", err)
|
||||||
|
}
|
||||||
|
if !contains(imageJobs, img.ID) || contains(imageJobs, vid.ID) {
|
||||||
|
t.Fatalf("image claim = %+v, want only %s", imageJobs, img.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
videoJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindVideo, 10, time.Minute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("claim video jobs: %v", err)
|
||||||
|
}
|
||||||
|
if !contains(videoJobs, vid.ID) || contains(videoJobs, img.ID) {
|
||||||
|
t.Fatalf("video claim = %+v, want only %s", videoJobs, vid.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func testSettings(t *testing.T, ctx context.Context, r Repos) {
|
func testSettings(t *testing.T, ctx context.Context, r Repos) {
|
||||||
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
|
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
|
||||||
if err != nil || v != "generated-1" {
|
if err != nil || v != "generated-1" {
|
||||||
|
|||||||
@@ -122,6 +122,22 @@ func (f *FS) Get(_ context.Context, key string) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open streams a blob from disk for processing without buffering it fully.
|
||||||
|
func (f *FS) Open(_ context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
p, err := f.objectPath(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
file, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, ErrObjectNotFound
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *FS) Delete(_ context.Context, keys ...string) error {
|
func (f *FS) Delete(_ context.Context, keys ...string) error {
|
||||||
var errs []error
|
var errs []error
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
|
|||||||
@@ -51,6 +51,29 @@ func TestFSRoundTrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFSOpen(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fs := newTestFS(t)
|
||||||
|
key := "objects/abc/original.mp4"
|
||||||
|
data := []byte("streamed video bytes")
|
||||||
|
|
||||||
|
if err := fs.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "video/mp4"); err != nil {
|
||||||
|
t.Fatalf("put: %v", err)
|
||||||
|
}
|
||||||
|
rc, err := fs.Open(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(rc)
|
||||||
|
rc.Close()
|
||||||
|
if err != nil || !bytes.Equal(got, data) {
|
||||||
|
t.Fatalf("open read = %q err=%v, want %q", got, err, data)
|
||||||
|
}
|
||||||
|
if _, err := fs.Open(ctx, "objects/missing/original.mp4"); !errors.Is(err, ErrObjectNotFound) {
|
||||||
|
t.Fatalf("open missing err = %v, want ErrObjectNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFSPathTraversalRejected(t *testing.T) {
|
func TestFSPathTraversalRejected(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
fs := newTestFS(t)
|
fs := newTestFS(t)
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ type Backend interface {
|
|||||||
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
|
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
|
||||||
Delete(ctx context.Context, keys ...string) error
|
Delete(ctx context.Context, keys ...string) error
|
||||||
Get(ctx context.Context, key string) ([]byte, error)
|
Get(ctx context.Context, key string) ([]byte, error)
|
||||||
|
// Open returns a streaming reader over a blob, for processing objects too
|
||||||
|
// large to buffer in memory (for example video). The caller closes it.
|
||||||
|
Open(ctx context.Context, key string) (io.ReadCloser, error)
|
||||||
// URL returns a time-limited signed app URL the client can use to fetch the blob.
|
// URL returns a time-limited signed app URL the client can use to fetch the blob.
|
||||||
URL(ctx context.Context, key string) (string, error)
|
URL(ctx context.Context, key string) (string, error)
|
||||||
// ServeObject validates the signed URL and writes the blob, including
|
// ServeObject validates the signed URL and writes the blob, including
|
||||||
@@ -191,6 +194,21 @@ func (c *S3) Get(ctx context.Context, key string) ([]byte, error) {
|
|||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Open streams a blob from S3 for processing without buffering it fully.
|
||||||
|
func (c *S3) Open(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||||
|
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||||
|
Bucket: aws.String(c.bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if isNoSuchKey(err) {
|
||||||
|
return nil, ErrObjectNotFound
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("open object %s: %w", key, err)
|
||||||
|
}
|
||||||
|
return out.Body, nil
|
||||||
|
}
|
||||||
|
|
||||||
// URL returns a signed app URL. The blob itself is streamed by ServeObject, so
|
// URL returns a signed app URL. The blob itself is streamed by ServeObject, so
|
||||||
// the S3 endpoint is never exposed to the client.
|
// the S3 endpoint is never exposed to the client.
|
||||||
func (c *S3) URL(_ context.Context, key string) (string, error) {
|
func (c *S3) URL(_ context.Context, key string) (string, error) {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemuxArgs builds the ffmpeg arguments for a lossless copy into a faststart
|
||||||
|
// mp4. Streams are limited to the first video and optional first audio track;
|
||||||
|
// subtitles and extra tracks are dropped so players do not stumble on them.
|
||||||
|
func RemuxArgs(in, out string) []string {
|
||||||
|
return []string{
|
||||||
|
"-hide_banner", "-nostdin", "-y",
|
||||||
|
"-i", in,
|
||||||
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
|
"-c", "copy",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
out,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TranscodeArgs builds the ffmpeg arguments for a browser-safe h264/aac mp4.
|
||||||
|
// The video is scaled to fit maxLongSide without upscaling and forced to
|
||||||
|
// even dimensions; ffmpeg applies display rotation during re-encoding.
|
||||||
|
func TranscodeArgs(in, out string, maxLongSide int) []string {
|
||||||
|
scale := fmt.Sprintf(
|
||||||
|
"scale=w='min(%d,iw)':h='min(%d,ih)':force_original_aspect_ratio=decrease:force_divisible_by=2",
|
||||||
|
maxLongSide, maxLongSide)
|
||||||
|
return []string{
|
||||||
|
"-hide_banner", "-nostdin", "-y",
|
||||||
|
"-i", in,
|
||||||
|
"-map", "0:v:0", "-map", "0:a:0?",
|
||||||
|
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
|
||||||
|
"-pix_fmt", "yuv420p", "-vf", scale,
|
||||||
|
"-c:a", "aac", "-b:a", "128k",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
out,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PosterArgs builds the ffmpeg arguments to extract a single poster frame at
|
||||||
|
// atSecond, already scaled so downstream image pipelines decode a bounded frame.
|
||||||
|
func PosterArgs(in, out string, atSecond int) []string {
|
||||||
|
scale := fmt.Sprintf("scale=w='min(%d,iw)':h=-2", MaxLongSide)
|
||||||
|
return []string{
|
||||||
|
"-hide_banner", "-nostdin", "-y",
|
||||||
|
"-ss", strconv.Itoa(atSecond),
|
||||||
|
"-i", in,
|
||||||
|
"-frames:v", "1",
|
||||||
|
"-vf", scale,
|
||||||
|
"-f", "image2",
|
||||||
|
out,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes ffmpeg with the given arguments and returns an error carrying
|
||||||
|
// the captured stderr on failure.
|
||||||
|
func Run(ctx context.Context, ffmpegPath string, args ...string) error {
|
||||||
|
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||||||
|
var errb bytes.Buffer
|
||||||
|
cmd.Stderr = &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return fmt.Errorf("ffmpeg %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(errb.String()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func argsContain(args []string, want ...string) bool {
|
||||||
|
for _, w := range want {
|
||||||
|
found := false
|
||||||
|
for _, a := range args {
|
||||||
|
if a == w {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemuxArgs(t *testing.T) {
|
||||||
|
args := RemuxArgs("in.mkv", "out.mp4")
|
||||||
|
if !argsContain(args, "-c", "copy", "-movflags", "+faststart", "-map", "0:a:0?") {
|
||||||
|
t.Fatalf("remux args = %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTranscodeArgs(t *testing.T) {
|
||||||
|
args := TranscodeArgs("in.mov", "out.mp4", MaxLongSide)
|
||||||
|
if !argsContain(args, "libx264", "yuv420p", "aac", "-movflags", "+faststart") {
|
||||||
|
t.Fatalf("transcode args = %v", args)
|
||||||
|
}
|
||||||
|
var scale string
|
||||||
|
for i, a := range args {
|
||||||
|
if a == "-vf" && i+1 < len(args) {
|
||||||
|
scale = args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(scale, "min(1920,iw)") || !strings.Contains(scale, "force_divisible_by=2") {
|
||||||
|
t.Fatalf("scale filter = %q", scale)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPosterArgs(t *testing.T) {
|
||||||
|
args := PosterArgs("in.mp4", "poster.jpg", 1)
|
||||||
|
if !argsContain(args, "-ss", "1", "-frames:v", "1", "-f", "image2") {
|
||||||
|
t.Fatalf("poster args = %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
ok := filepath.Join(dir, "ok")
|
||||||
|
if err := os.WriteFile(ok, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write ok: %v", err)
|
||||||
|
}
|
||||||
|
if err := Run(context.Background(), ok, "-i", "x"); err != nil {
|
||||||
|
t.Fatalf("run ok: %v", err)
|
||||||
|
}
|
||||||
|
bad := filepath.Join(dir, "bad")
|
||||||
|
if err := os.WriteFile(bad, []byte("#!/bin/sh\necho nope >&2\nexit 3\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write bad: %v", err)
|
||||||
|
}
|
||||||
|
if err := Run(context.Background(), bad); err == nil || !strings.Contains(err.Error(), "nope") {
|
||||||
|
t.Fatalf("run bad err = %v, want stderr captured", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MaxLongSide is the maximum long side of a prepared playback variant. Sources
|
||||||
|
// larger than this are scaled down; smaller sources are never upscaled.
|
||||||
|
const MaxLongSide = 1920
|
||||||
|
|
||||||
|
// Info describes the media facts ffprobe reports for a video file.
|
||||||
|
type Info struct {
|
||||||
|
DurationMS int64
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Rotation int
|
||||||
|
VideoCodec string
|
||||||
|
PixelFormat string
|
||||||
|
Profile string
|
||||||
|
AudioCodec string
|
||||||
|
Container string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ffprobeStream struct {
|
||||||
|
CodecType string `json:"codec_type"`
|
||||||
|
CodecName string `json:"codec_name"`
|
||||||
|
Profile string `json:"profile"`
|
||||||
|
PixFmt string `json:"pix_fmt"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Tags map[string]string `json:"tags"`
|
||||||
|
SideDataList []struct {
|
||||||
|
Rotation float64 `json:"rotation"`
|
||||||
|
} `json:"side_data_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ffprobeOutput struct {
|
||||||
|
Streams []ffprobeStream `json:"streams"`
|
||||||
|
Format struct {
|
||||||
|
FormatName string `json:"format_name"`
|
||||||
|
Duration string `json:"duration"`
|
||||||
|
} `json:"format"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe runs ffprobe and returns the parsed media facts.
|
||||||
|
func Probe(ctx context.Context, ffprobePath, path string) (*Info, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, ffprobePath,
|
||||||
|
"-v", "error", "-print_format", "json", "-show_format", "-show_streams", path)
|
||||||
|
var out, errb bytes.Buffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
cmd.Stderr = &errb
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, fmt.Errorf("ffprobe: %w: %s", err, strings.TrimSpace(errb.String()))
|
||||||
|
}
|
||||||
|
var parsed ffprobeOutput
|
||||||
|
if err := json.Unmarshal(out.Bytes(), &parsed); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse ffprobe output: %w", err)
|
||||||
|
}
|
||||||
|
info := &Info{Container: parsed.Format.FormatName}
|
||||||
|
for _, st := range parsed.Streams {
|
||||||
|
switch st.CodecType {
|
||||||
|
case "video":
|
||||||
|
if info.VideoCodec == "" {
|
||||||
|
info.VideoCodec = st.CodecName
|
||||||
|
info.Profile = st.Profile
|
||||||
|
info.PixelFormat = st.PixFmt
|
||||||
|
info.Width = st.Width
|
||||||
|
info.Height = st.Height
|
||||||
|
info.Rotation = rotationOf(st.SideDataList, st.Tags)
|
||||||
|
}
|
||||||
|
case "audio":
|
||||||
|
if info.AudioCodec == "" {
|
||||||
|
info.AudioCodec = st.CodecName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info.DurationMS = durationMS(parsed.Format.Duration)
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationMS(seconds string) int64 {
|
||||||
|
f, err := strconv.ParseFloat(strings.TrimSpace(seconds), 64)
|
||||||
|
if err != nil || f < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return int64(f*1000 + 0.5)
|
||||||
|
}
|
||||||
|
|
||||||
|
func rotationOf(sd []struct {
|
||||||
|
Rotation float64 `json:"rotation"`
|
||||||
|
}, tags map[string]string) int {
|
||||||
|
for _, s := range sd {
|
||||||
|
if s.Rotation != 0 {
|
||||||
|
return normalizeDegrees(int(s.Rotation))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tags != nil {
|
||||||
|
if v, ok := tags["rotate"]; ok {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
|
return normalizeDegrees(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDegrees(n int) int {
|
||||||
|
r := n % 360
|
||||||
|
if r < 0 {
|
||||||
|
r += 360
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
var allowedProfiles = map[string]bool{
|
||||||
|
"baseline": true,
|
||||||
|
"constrained baseline": true,
|
||||||
|
"main": true,
|
||||||
|
"high": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanRemux reports whether the source can be copied into a web-friendly mp4
|
||||||
|
// unchanged. It requires h264/yuv420p with a browser-supported profile, aac or
|
||||||
|
// no audio, a remuxable container, and a resolution within the cap.
|
||||||
|
func (i *Info) CanRemux(maxLongSide int) bool {
|
||||||
|
if i == nil || i.VideoCodec != "h264" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i.PixelFormat != "yuv420p" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i.Profile != "" && !allowedProfiles[strings.ToLower(i.Profile)] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if i.AudioCodec != "" && i.AudioCodec != "aac" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !containerRemuxable(i.Container) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if maxLongSide > 0 && i.LongSide() > maxLongSide {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LongSide returns the larger of width and height.
|
||||||
|
func (i *Info) LongSide() int {
|
||||||
|
if i.Width > i.Height {
|
||||||
|
return i.Width
|
||||||
|
}
|
||||||
|
return i.Height
|
||||||
|
}
|
||||||
|
|
||||||
|
func containerRemuxable(name string) bool {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
for _, c := range []string{"mp4", "mov", "matroska", "webm"} {
|
||||||
|
if strings.Contains(name, c) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCanRemux(t *testing.T) {
|
||||||
|
base := func() *Info {
|
||||||
|
return &Info{
|
||||||
|
VideoCodec: "h264",
|
||||||
|
PixelFormat: "yuv420p",
|
||||||
|
Profile: "High",
|
||||||
|
AudioCodec: "aac",
|
||||||
|
Container: "mov,mp4,m4a,3gp,3g2,mj2",
|
||||||
|
Width: 1920,
|
||||||
|
Height: 1080,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mut func(*Info)
|
||||||
|
cap int
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"web compatible", func(*Info) {}, MaxLongSide, true},
|
||||||
|
{"no audio", func(i *Info) { i.AudioCodec = "" }, MaxLongSide, true},
|
||||||
|
{"empty profile accepted", func(i *Info) { i.Profile = "" }, MaxLongSide, true},
|
||||||
|
{"hevc", func(i *Info) { i.VideoCodec = "hevc" }, MaxLongSide, false},
|
||||||
|
{"vp9", func(i *Info) { i.VideoCodec = "vp9" }, MaxLongSide, false},
|
||||||
|
{"yuv444p", func(i *Info) { i.PixelFormat = "yuv444p" }, MaxLongSide, false},
|
||||||
|
{"ten bit profile", func(i *Info) { i.Profile = "High 10" }, MaxLongSide, false},
|
||||||
|
{"mp3 audio", func(i *Info) { i.AudioCodec = "mp3" }, MaxLongSide, false},
|
||||||
|
{"mpegts container", func(i *Info) { i.Container = "mpegts" }, MaxLongSide, false},
|
||||||
|
{"mkv h264 remuxable", func(i *Info) { i.Container = "matroska,webm" }, MaxLongSide, true},
|
||||||
|
{"over cap", func(i *Info) { i.Width = 3840; i.Height = 2160 }, MaxLongSide, false},
|
||||||
|
{"cap disabled", func(i *Info) { i.Width = 3840; i.Height = 2160 }, 0, true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
i := base()
|
||||||
|
tc.mut(i)
|
||||||
|
if got := i.CanRemux(tc.cap); got != tc.want {
|
||||||
|
t.Fatalf("CanRemux = %v, want %v (%+v)", got, tc.want, i)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFakeTool(t *testing.T, body string) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "ffprobe")
|
||||||
|
script := "#!/bin/sh\ncat <<'JSON'\n" + body + "\nJSON\n"
|
||||||
|
if err := os.WriteFile(path, []byte(script), 0o755); err != nil {
|
||||||
|
t.Fatalf("write fake tool: %v", err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeParsesOutput(t *testing.T) {
|
||||||
|
probe := writeFakeTool(t, `{"streams":[{"codec_type":"video","codec_name":"h264","profile":"High","pix_fmt":"yuv420p","width":1920,"height":1080,"side_data_list":[{"rotation":-90}]},{"codec_type":"audio","codec_name":"aac"}],"format":{"format_name":"mov,mp4,m4a,3gp,3g2,mj2","duration":"12.345678"}}`)
|
||||||
|
info, err := Probe(context.Background(), probe, "input.mp4")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("probe: %v", err)
|
||||||
|
}
|
||||||
|
if info.VideoCodec != "h264" || info.PixelFormat != "yuv420p" || info.AudioCodec != "aac" {
|
||||||
|
t.Fatalf("codecs = %+v", info)
|
||||||
|
}
|
||||||
|
if info.Width != 1920 || info.Height != 1080 || info.LongSide() != 1920 {
|
||||||
|
t.Fatalf("dimensions = %+v", info)
|
||||||
|
}
|
||||||
|
if info.Rotation != 270 {
|
||||||
|
t.Fatalf("rotation = %d, want 270", info.Rotation)
|
||||||
|
}
|
||||||
|
if info.DurationMS != 12346 {
|
||||||
|
t.Fatalf("duration = %d, want 12346", info.DurationMS)
|
||||||
|
}
|
||||||
|
if !info.CanRemux(MaxLongSide) {
|
||||||
|
t.Fatalf("expected remuxable, got %+v", info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeFailsOnNonZeroExit(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "ffprobe")
|
||||||
|
if err := os.WriteFile(path, []byte("#!/bin/sh\necho boom >&2\nexit 1\n"), 0o755); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := Probe(context.Background(), path, "x"); err == nil {
|
||||||
|
t.Fatal("expected probe error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// Package video prepares uploaded video for browser playback using the external
|
||||||
|
// ffmpeg/ffprobe toolchain. Availability is detected at startup; when the tools
|
||||||
|
// are absent the rest of the application keeps working and video upload is
|
||||||
|
// disabled.
|
||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"galo/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Toolchain locates the external video tools and reports whether they are
|
||||||
|
// usable. A nil Toolchain is treated as unavailable.
|
||||||
|
type Toolchain struct {
|
||||||
|
ffmpegPath string
|
||||||
|
ffprobePath string
|
||||||
|
tempDir string
|
||||||
|
available bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect resolves the configured tool paths and records whether both binaries
|
||||||
|
// are present. Missing tools are not an error: video is simply unavailable.
|
||||||
|
func Detect(cfg config.Video) *Toolchain {
|
||||||
|
t := &Toolchain{tempDir: cfg.TempDir}
|
||||||
|
if p, err := exec.LookPath(cfg.FFmpegPath); err == nil {
|
||||||
|
t.ffmpegPath = p
|
||||||
|
} else {
|
||||||
|
t.ffmpegPath = cfg.FFmpegPath
|
||||||
|
}
|
||||||
|
if p, err := exec.LookPath(cfg.FFprobePath); err == nil {
|
||||||
|
t.ffprobePath = p
|
||||||
|
} else {
|
||||||
|
t.ffprobePath = cfg.FFprobePath
|
||||||
|
}
|
||||||
|
t.available = t.ffmpegPath != "" && t.ffprobePath != "" &&
|
||||||
|
isExecutable(t.ffmpegPath) && isExecutable(t.ffprobePath)
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func isExecutable(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath(path); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available reports whether both ffmpeg and ffprobe are usable.
|
||||||
|
func (t *Toolchain) Available() bool { return t != nil && t.available }
|
||||||
|
|
||||||
|
// FFmpegPath returns the resolved ffmpeg binary path.
|
||||||
|
func (t *Toolchain) FFmpegPath() string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.ffmpegPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// FFprobePath returns the resolved ffprobe binary path.
|
||||||
|
func (t *Toolchain) FFprobePath() string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.ffprobePath
|
||||||
|
}
|
||||||
|
|
||||||
|
// TempDir returns the configured temporary directory (empty means the system
|
||||||
|
// default).
|
||||||
|
func (t *Toolchain) TempDir() string {
|
||||||
|
if t == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.tempDir
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package video
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"galo/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectUnavailable(t *testing.T) {
|
||||||
|
tc := Detect(config.Video{
|
||||||
|
FFmpegPath: "definitely-missing-ffmpeg-xyz",
|
||||||
|
FFprobePath: "definitely-missing-ffprobe-xyz",
|
||||||
|
})
|
||||||
|
if tc.Available() {
|
||||||
|
t.Fatalf("expected unavailable toolchain, got %+v", tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectAvailable(t *testing.T) {
|
||||||
|
self, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("executable: %v", err)
|
||||||
|
}
|
||||||
|
tc := Detect(config.Video{FFmpegPath: self, FFprobePath: self, TempDir: "/tmp"})
|
||||||
|
if !tc.Available() {
|
||||||
|
t.Fatalf("expected available toolchain, got %+v", tc)
|
||||||
|
}
|
||||||
|
if tc.FFmpegPath() != self || tc.FFprobePath() != self {
|
||||||
|
t.Fatalf("paths = %q %q", tc.FFmpegPath(), tc.FFprobePath())
|
||||||
|
}
|
||||||
|
if tc.TempDir() != "/tmp" {
|
||||||
|
t.Fatalf("temp dir = %q", tc.TempDir())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNilToolchain(t *testing.T) {
|
||||||
|
var tc *Toolchain
|
||||||
|
if tc.Available() {
|
||||||
|
t.Fatal("nil toolchain must be unavailable")
|
||||||
|
}
|
||||||
|
if tc.FFmpegPath() != "" || tc.FFprobePath() != "" || tc.TempDir() != "" {
|
||||||
|
t.Fatal("nil toolchain accessors must be empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
DELETE FROM photo_objects WHERE role = 'playback';
|
||||||
|
|
||||||
|
ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check;
|
||||||
|
ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check
|
||||||
|
CHECK (role IN ('original', 'thumb', 'medium', 'preview'));
|
||||||
|
|
||||||
|
ALTER TABLE variant_jobs DROP COLUMN IF EXISTS kind;
|
||||||
|
ALTER TABLE photos DROP COLUMN IF EXISTS duration_ms;
|
||||||
|
ALTER TABLE photos DROP COLUMN IF EXISTS kind;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Video support: media kind and duration on photos, a playback object role, and
|
||||||
|
-- a job kind distinguishing short image work from long transcoding work.
|
||||||
|
|
||||||
|
ALTER TABLE photos ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'image'
|
||||||
|
CHECK (kind IN ('image', 'video'));
|
||||||
|
ALTER TABLE photos ADD COLUMN IF NOT EXISTS duration_ms bigint;
|
||||||
|
|
||||||
|
ALTER TABLE photo_objects DROP CONSTRAINT IF EXISTS photo_objects_role_check;
|
||||||
|
ALTER TABLE photo_objects ADD CONSTRAINT photo_objects_role_check
|
||||||
|
CHECK (role IN ('original', 'thumb', 'medium', 'preview', 'playback'));
|
||||||
|
|
||||||
|
ALTER TABLE variant_jobs ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'image'
|
||||||
|
CHECK (kind IN ('image', 'video'));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
DELETE FROM photo_objects WHERE role = 'playback';
|
||||||
|
|
||||||
|
CREATE TABLE photo_objects_new (
|
||||||
|
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'medium', 'preview')),
|
||||||
|
PRIMARY KEY (photo_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO photo_objects_new (photo_id, object_id, role)
|
||||||
|
SELECT photo_id, object_id, role FROM photo_objects;
|
||||||
|
|
||||||
|
DROP TABLE photo_objects;
|
||||||
|
|
||||||
|
ALTER TABLE photo_objects_new RENAME TO photo_objects;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
|
||||||
|
|
||||||
|
ALTER TABLE variant_jobs DROP COLUMN kind;
|
||||||
|
ALTER TABLE photos DROP COLUMN duration_ms;
|
||||||
|
ALTER TABLE photos DROP COLUMN kind;
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Video support for SQLite: media kind and duration on photos, the playback
|
||||||
|
-- object role (photo_objects is rebuilt because SQLite cannot alter a CHECK
|
||||||
|
-- constraint) and a job kind distinguishing image work from video transcoding.
|
||||||
|
|
||||||
|
ALTER TABLE photos ADD COLUMN kind TEXT NOT NULL DEFAULT 'image';
|
||||||
|
ALTER TABLE photos ADD COLUMN duration_ms INTEGER;
|
||||||
|
|
||||||
|
CREATE TABLE photo_objects_new (
|
||||||
|
photo_id TEXT NOT NULL REFERENCES photos(id) ON DELETE CASCADE,
|
||||||
|
object_id TEXT NOT NULL REFERENCES objects(id) ON DELETE RESTRICT,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('original', 'thumb', 'medium', 'preview', 'playback')),
|
||||||
|
PRIMARY KEY (photo_id, role)
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO photo_objects_new (photo_id, object_id, role)
|
||||||
|
SELECT photo_id, object_id, role FROM photo_objects;
|
||||||
|
|
||||||
|
DROP TABLE photo_objects;
|
||||||
|
|
||||||
|
ALTER TABLE photo_objects_new RENAME TO photo_objects;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS photo_objects_object_idx ON photo_objects (object_id);
|
||||||
|
|
||||||
|
ALTER TABLE variant_jobs ADD COLUMN kind TEXT NOT NULL DEFAULT 'image';
|
||||||
@@ -50,6 +50,14 @@
|
|||||||
|
|
||||||
const isSelected = (id: string) => selectedIds.includes(id);
|
const isSelected = (id: string) => selectedIds.includes(id);
|
||||||
const isCursor = (i: number) => cursorIndex === i;
|
const isCursor = (i: number) => cursorIndex === i;
|
||||||
|
|
||||||
|
function formatDuration(ms?: number): string {
|
||||||
|
if (!ms || ms <= 0) return '';
|
||||||
|
const total = Math.round(ms / 1000);
|
||||||
|
const m = Math.floor(total / 60);
|
||||||
|
const s = total % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
const tabIndexFor = (i: number) =>
|
const tabIndexFor = (i: number) =>
|
||||||
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1;
|
||||||
|
|
||||||
@@ -117,6 +125,21 @@
|
|||||||
360
|
360
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if photo.kind === 'video'}
|
||||||
|
<span
|
||||||
|
class="absolute top-1 left-1 inline-flex items-center justify-center rounded bg-black/60 p-1 text-white"
|
||||||
|
aria-label="Видео"
|
||||||
|
>
|
||||||
|
<Icon name="play_arrow" class="size-4" />
|
||||||
|
</span>
|
||||||
|
{#if formatDuration(photo.duration_ms)}
|
||||||
|
<span
|
||||||
|
class="absolute bottom-1 left-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white"
|
||||||
|
>
|
||||||
|
{formatDuration(photo.duration_ms)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
<span
|
<span
|
||||||
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
|
class="bg-bg/70 text-text absolute right-1 bottom-1 flex items-center gap-1.5 rounded px-1.5 py-0.5 text-xs"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,7 +14,12 @@
|
|||||||
let progress = $state(0);
|
let progress = $state(0);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
|
|
||||||
const MAX_SIZE = 100 * 1024 * 1024;
|
const IMAGE_MAX = 100 * 1024 * 1024;
|
||||||
|
const VIDEO_MAX = 2 * 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isVideoFile(f: File): boolean {
|
||||||
|
return f.type.startsWith('video/') || /\.(mp4|m4v|mov|mkv|webm|avi|3gp)$/i.test(f.name);
|
||||||
|
}
|
||||||
|
|
||||||
export function pick() {
|
export function pick() {
|
||||||
fileInput?.click();
|
fileInput?.click();
|
||||||
@@ -23,14 +28,14 @@
|
|||||||
export function upload(files: FileList | File[]) {
|
export function upload(files: FileList | File[]) {
|
||||||
const list = Array.from(files);
|
const list = Array.from(files);
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
const tooBig = list.filter((f) => f.size > MAX_SIZE);
|
const tooBig = list.filter((f) => f.size > (isVideoFile(f) ? VIDEO_MAX : IMAGE_MAX));
|
||||||
if (tooBig.length > 0) {
|
if (tooBig.length > 0) {
|
||||||
error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Максимум 100 МБ.`;
|
error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Видео до 2 ГБ, изображения до 100 МБ.`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const total = list.reduce((sum, f) => sum + f.size, 0);
|
const total = list.reduce((sum, f) => sum + f.size, 0);
|
||||||
if (total > MAX_SIZE) {
|
if (total > VIDEO_MAX) {
|
||||||
error = 'Суммарный размер файлов превышает 100 МБ. Загрузите меньшими порциями.';
|
error = 'Суммарный размер файлов превышает 2 ГБ. Загрузите меньшими порциями.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error = '';
|
error = '';
|
||||||
@@ -80,7 +85,7 @@
|
|||||||
<input
|
<input
|
||||||
bind:this={fileInput}
|
bind:this={fileInput}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*,video/*"
|
||||||
multiple
|
multiple
|
||||||
class="hidden"
|
class="hidden"
|
||||||
onchange={onChange}
|
onchange={onChange}
|
||||||
|
|||||||
@@ -60,6 +60,11 @@
|
|||||||
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
const panoTag = $derived(isPanoTag(current) || isPanoTag(fresh));
|
||||||
const pano = $derived(panoTag && !panoFailed);
|
const pano = $derived(panoTag && !panoFailed);
|
||||||
const panoSrc = $derived(fullSrc || previewSrc);
|
const panoSrc = $derived(fullSrc || previewSrc);
|
||||||
|
const isVideo = $derived((current?.kind ?? fresh?.kind) === 'video');
|
||||||
|
const playbackSrc = $derived(fresh?.playback_url ?? current?.playback_url ?? '');
|
||||||
|
const videoPoster = $derived(
|
||||||
|
fresh?.preview_url ?? fresh?.thumb_url ?? current?.preview_url ?? current?.thumb_url ?? ''
|
||||||
|
);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const id = current?.id;
|
const id = current?.id;
|
||||||
@@ -268,6 +273,10 @@
|
|||||||
|
|
||||||
function onKey(ev: KeyboardEvent) {
|
function onKey(ev: KeyboardEvent) {
|
||||||
if (isTyping()) return;
|
if (isTyping()) return;
|
||||||
|
// Let the native video player own its playback keys.
|
||||||
|
if (isVideo && (ev.code === 'Space' || ev.key === 'ArrowLeft' || ev.key === 'ArrowRight')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (ev.key === 'Escape') {
|
if (ev.key === 'Escape') {
|
||||||
if (!document.fullscreenElement) onClose();
|
if (!document.fullscreenElement) onClose();
|
||||||
return;
|
return;
|
||||||
@@ -396,6 +405,41 @@
|
|||||||
onFallback={() => (panoFailed = true)}
|
onFallback={() => (panoFailed = true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{:else if isVideo}
|
||||||
|
<div
|
||||||
|
class="bg-canvas flex min-h-0 flex-1 items-center justify-center overflow-hidden"
|
||||||
|
role="group"
|
||||||
|
aria-label="Просмотр видео"
|
||||||
|
oncontextmenu={onContextMenu}
|
||||||
|
>
|
||||||
|
{#if playbackSrc}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
class="m-auto block max-h-full max-w-full"
|
||||||
|
src={playbackSrc}
|
||||||
|
poster={videoPoster}
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
preload="metadata"
|
||||||
|
></video>
|
||||||
|
{:else}
|
||||||
|
<div class="relative flex items-center justify-center">
|
||||||
|
{#if videoPoster}
|
||||||
|
<img
|
||||||
|
src={videoPoster}
|
||||||
|
alt={current?.original_filename ?? ''}
|
||||||
|
class="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
<span
|
||||||
|
class="absolute rounded bg-black/60 px-2 py-1 text-xs text-white"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
Обработка видео…
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div
|
<div
|
||||||
bind:this={scroller}
|
bind:this={scroller}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const icons = {
|
|||||||
image: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>' },
|
image: { path: '<path fill="currentColor" d="M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm1-4h12l-3.75-5l-3 4L9 13z"/>' },
|
||||||
add: { path: '<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>' },
|
add: { path: '<path fill="currentColor" d="M11 13H5v-2h6V5h2v6h6v2h-6v6h-2z"/>' },
|
||||||
remove: { path: '<path fill="currentColor" d="M5 13v-2h14v2z"/>' },
|
remove: { path: '<path fill="currentColor" d="M5 13v-2h14v2z"/>' },
|
||||||
|
play_arrow: { path: '<path fill="currentColor" d="M8 19V5l11 7z"/>' },
|
||||||
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
|
photo_library: { path: '<path fill="currentColor" d="M9 14h10l-3.45-4.5l-2.3 3l-1.55-2zm-1 4q-.825 0-1.412-.587T6 16V4q0-.825.588-1.412T8 2h12q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18zm-4 4q-.825 0-1.412-.587T2 20V6h2v14h14v2z"/>' },
|
||||||
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
|
group: { path: '<path fill="currentColor" d="M1 20v-2.8q0-.85.438-1.562T2.6 14.55q1.55-.775 3.15-1.162T9 13t3.25.388t3.15 1.162q.725.375 1.163 1.088T17 17.2V20zm18 0v-3q0-1.1-.612-2.113T16.65 13.15q1.275.15 2.4.513t2.1.887q.9.5 1.375 1.112T23 17v3zM6.175 10.825Q5 9.65 5 8t1.175-2.825T9 4t2.825 1.175T13 8t-1.175 2.825T9 12t-2.825-1.175m11.65 0Q16.65 12 15 12q-.275 0-.7-.062t-.7-.138q.675-.8 1.038-1.775T15 8t-.362-2.025T13.6 4.2q.35-.125.7-.163T15 4q1.65 0 2.825 1.175T19 8t-1.175 2.825"/>' },
|
||||||
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
|
settings: { path: '<path fill="currentColor" d="m9.25 22l-.4-3.2q-.325-.125-.612-.3t-.563-.375L4.7 19.375l-2.75-4.75l2.575-1.95Q4.5 12.5 4.5 12.338v-.675q0-.163.025-.338L1.95 9.375l2.75-4.75l2.975 1.25q.275-.2.575-.375t.6-.3l.4-3.2h5.5l.4 3.2q.325.125.613.3t.562.375l2.975-1.25l2.75 4.75l-2.575 1.95q.025.175.025.338v.674q0 .163-.05.338l2.575 1.95l-2.75 4.75l-2.95-1.25q-.275.2-.575.375t-.6.3l-.4 3.2zm2.8-6.5q1.45 0 2.475-1.025T15.55 12t-1.025-2.475T12.05 8.5q-1.475 0-2.488 1.025T8.55 12t1.013 2.475T12.05 15.5"/>' },
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ export interface Photo {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
mime_type: string;
|
mime_type: string;
|
||||||
|
kind: 'image' | 'video';
|
||||||
|
duration_ms?: number;
|
||||||
taken_at?: string | null;
|
taken_at?: string | null;
|
||||||
latitude?: number | null;
|
latitude?: number | null;
|
||||||
longitude?: number | null;
|
longitude?: number | null;
|
||||||
@@ -67,6 +69,7 @@ export interface Photo {
|
|||||||
thumb_url?: string;
|
thumb_url?: string;
|
||||||
medium_url?: string;
|
medium_url?: string;
|
||||||
preview_url?: string;
|
preview_url?: string;
|
||||||
|
playback_url?: string;
|
||||||
original_url?: string;
|
original_url?: string;
|
||||||
download_url?: string;
|
download_url?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-10-11
|
||||||
@@ -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).
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Proposal
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Альбом задуман как общее пространство для медиа семьи или команды, но сегодня принимает
|
||||||
|
только изображения: загрузка отклоняет всё, что не `image/*`, генерация вариантов и
|
||||||
|
вьюер работают исключительно с картинками. Видео с телефонов и камер остаётся вне
|
||||||
|
системы — теряется в мессенджерах и разрывает контекст альбома. Пользователю нужно
|
||||||
|
загружать видео в те же альбомы и гарантированно воспроизводить его в браузере.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Загрузка видео в альбом наравне с фото: стриминг multipart, отдельный лимит 2 ГБ
|
||||||
|
(картинки остаются 100 МБ).
|
||||||
|
- Сущность фото становится медиа-записью: `kind` (`image` | `video`), `duration_ms`,
|
||||||
|
`playback_url`; набор ролей объекта пополняется ролью `playback`.
|
||||||
|
- Асинхронная подготовка видео в фоне: извлечение метаданных (ffprobe), постер-кадр
|
||||||
|
(переиспользует существующую генерацию WebP-вариантов thumb/medium/preview) и
|
||||||
|
воспроизводимый mp4 — remux без перекодирования для web-совместимых источников,
|
||||||
|
иначе транскод в h264/yuv420p/aac с `faststart`.
|
||||||
|
- Очередь заданий различает вид задачи и отделяет длительные (транскод) задания от
|
||||||
|
коротких (картинки): длительный лизинг, таймаут и отдельный малый пул, чтобы
|
||||||
|
транскод не вытеснял генерацию вариантов изображений.
|
||||||
|
- Внешняя зависимость `ffmpeg`/`ffprobe` (без Docker — бинарник на хосте): путь
|
||||||
|
конфигурируется, доступность отражается в health, при отсутствии видео-функции
|
||||||
|
отключаются.
|
||||||
|
- UI: плеер `<video>` во вьюере, постер и признак видео с длительностью в сетке,
|
||||||
|
приём видео в загрузчике.
|
||||||
|
- **BREAKING**: изменяются схема `photos` и ограничение ролей `photo_objects` на обоих
|
||||||
|
бэкендах метаданных (PostgreSQL и SQLite), с пересборкой таблицы в SQLite.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `videos`: обработка и воспроизведение видео в альбоме — приём файла, извлечение
|
||||||
|
метаданных, постер-кадр, remux/транскод в web-формат, контракт воспроизведения и
|
||||||
|
готовности.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- `photos`: сущность фото становится медиа-записью (`kind`, `duration_ms`,
|
||||||
|
`playback_url`); расширяется допустимый ввод и лимиты размера.
|
||||||
|
- `media-objects`: набор ролей объекта включает `playback`; очередь заданий различает
|
||||||
|
виды и поддерживает длительные задания с длительным лизингом.
|
||||||
|
- `ui-workspace`: вьюер, сетка и загрузчик получают поддержку видео.
|
||||||
|
- `dev-seed-data`: набор seed-данных включает видео-фикстуры.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- **Backend**: `photos` (service, handlers, pipeline, worker, objects, keys), новые
|
||||||
|
миграции PostgreSQL и SQLite, `storage` (стриминговое чтение больших объектов),
|
||||||
|
`app` (роуты/health), `config` (VIDEO-*, FFMPEG_PATH, лимиты).
|
||||||
|
- **Frontend**: `types.ts`, `Viewer.svelte`, `PhotoGrid.svelte`, `Uploader.svelte`.
|
||||||
|
- **Зависимости**: `ffmpeg`/`ffprobe` на хосте; целевой remux/транскод в mp4 h264/aac.
|
||||||
|
- **Без изменений**: `albums`, `share-links`, `social`, `realtime` — работают через
|
||||||
|
роль `playback` и постер-картинку без правок поведения.
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Создание альбомов и фотографий
|
||||||
|
Скрипт SHALL создавать заданное параметрами число альбомов и фотографий в каждом от имени
|
||||||
|
аутентифицированного владельца через `POST /api/albums` и
|
||||||
|
`POST /api/albums/:id/photos`. Названия альбомов SHALL содержать метку прогона. При
|
||||||
|
включённом флаге видео скрипт SHALL дополнительно загружать видео-фикстуры.
|
||||||
|
|
||||||
|
#### Scenario: Наполнение альбомами и фото
|
||||||
|
- **WHEN** скрипт запущен с числом альбомов и фотографий на альбом
|
||||||
|
- **THEN** создаются указанные альбомы и фотографии, а имена альбомов помечены меткой прогона
|
||||||
|
|
||||||
|
#### Scenario: Воспроизводимость
|
||||||
|
- **WHEN** задан числовой seed
|
||||||
|
- **THEN** структура и параметры генерируемых данных детерминированы для этого seed
|
||||||
|
|
||||||
|
#### Scenario: Флаг видео
|
||||||
|
- **WHEN** скрипт запущен с включённым флагом видео
|
||||||
|
- **THEN** помимо изображений часть альбомов получает видео-фикстуры
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Наполнение видео
|
||||||
|
При включённом флаге видео скрипт SHALL загружать видео-фикстуру через общий эндпоинт
|
||||||
|
загрузки; если генерация видео недоступна, скрипт SHALL пропускать видео и продолжать без
|
||||||
|
ошибки.
|
||||||
|
|
||||||
|
#### Scenario: Видео включено
|
||||||
|
- **WHEN** включён флаг видео
|
||||||
|
- **THEN** в часть альбомов загружаются видео-фикстуры, различимые по метке прогона
|
||||||
|
|
||||||
|
#### Scenario: Генерация недоступна
|
||||||
|
- **WHEN** видео-инструменты недоступны, а флаг видео включён
|
||||||
|
- **THEN** скрипт пропускает видео и продолжает наполнение
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Журнал объектов
|
||||||
|
|
||||||
|
Каждый физический объект SHALL иметь ровно одну запись в слое метаданных с ключом,
|
||||||
|
MIME-типом, размером, габаритами и состоянием; фото SHALL ссылаться на объекты по
|
||||||
|
ролям original, thumb, medium, preview и playback.
|
||||||
|
|
||||||
|
#### Scenario: Привязка ролей
|
||||||
|
|
||||||
|
- **WHEN** фото загружено и обработано
|
||||||
|
- **THEN** фото ссылается на объект-оригинал и, когда они готовы, на объекты thumb, medium и preview
|
||||||
|
|
||||||
|
#### Scenario: Оригинал без вариантов
|
||||||
|
|
||||||
|
- **WHEN** варианты ещё не сгенерированы
|
||||||
|
- **THEN** фото доступно с оригиналом, а thumb, medium и preview отсутствуют
|
||||||
|
|
||||||
|
#### Scenario: Привязка роли воспроизведения
|
||||||
|
|
||||||
|
- **WHEN** видео обработано
|
||||||
|
- **THEN** медиа ссылается на объект playback наряду с оригиналом и постером
|
||||||
|
|
||||||
|
### Requirement: Генерация вариантов
|
||||||
|
Генерация вариантов SHALL выполняться через надёжную очередь заданий с
|
||||||
|
однократным выполнением и повтором при сбое; готовый результат SHALL
|
||||||
|
транслироваться подписчикам альбома. Очередь SHALL поддерживать настраиваемую
|
||||||
|
пропускную способность, SHALL обрабатывать более свежие фото раньше остальных и
|
||||||
|
SHALL различать короткие задания изображений и длительные задания видео.
|
||||||
|
|
||||||
|
#### Scenario: Повтор после рестарта
|
||||||
|
- **WHEN** процесс перезапускается с незавершёнными заданиями
|
||||||
|
- **THEN** генерация вариантов продолжается
|
||||||
|
|
||||||
|
#### Scenario: Событие после генерации
|
||||||
|
- **WHEN** варианты фото готовы
|
||||||
|
- **THEN** подписчикам альбома публикуется событие обновления этого фото
|
||||||
|
|
||||||
|
#### Scenario: Приоритет свежих фото
|
||||||
|
- **WHEN** в очереди есть готовые к обработке задания разного возраста
|
||||||
|
- **THEN** система обрабатывает более свежие фото раньше
|
||||||
|
|
||||||
|
#### Scenario: Настраиваемая пропускная способность
|
||||||
|
- **WHEN** задан уровень параллелизма генерации
|
||||||
|
- **THEN** система обрабатывает варианты с этим уровнем параллелизма
|
||||||
|
|
||||||
|
#### Scenario: Длительное задание видео
|
||||||
|
- **WHEN** выполняется длительная подготовка видео
|
||||||
|
- **THEN** задание удерживается на весь срок выполнения и не вытесняет генерацию вариантов изображений
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Загрузка фото
|
||||||
|
Пользователь с правом загрузки SHALL иметь возможность загружать медиа (изображения и
|
||||||
|
видео) по одному и массово; система SHALL принимать ввод `image/*` и `video/*`.
|
||||||
|
|
||||||
|
#### Scenario: Успешная загрузка
|
||||||
|
- **WHEN** участник с правом загрузки отправляет файл изображения
|
||||||
|
- **THEN** система сохраняет оригинал и создаёт запись фото с метаданными
|
||||||
|
|
||||||
|
#### Scenario: Успешная загрузка видео
|
||||||
|
- **WHEN** участник с правом загрузки отправляет видеофайл
|
||||||
|
- **THEN** система сохраняет оригинал и создаёт медиа-запись с признаком видео
|
||||||
|
|
||||||
|
#### Scenario: Загрузка без права
|
||||||
|
- **WHEN** наблюдатель пытается загрузить фото
|
||||||
|
- **THEN** система отклоняет запрос
|
||||||
|
|
||||||
|
#### Scenario: Неподдерживаемый файл
|
||||||
|
- **WHEN** загружается файл, не являющийся изображением или видео
|
||||||
|
- **THEN** система отклоняет загрузку
|
||||||
|
|
||||||
|
### Requirement: Ограничение размера загрузки
|
||||||
|
Система SHALL ограничивать размер загружаемых файлов отдельно для изображений и видео и
|
||||||
|
SHALL отвечать понятной ошибкой при превышении лимита, а не ошибкой разбора формы.
|
||||||
|
|
||||||
|
#### Scenario: Превышение лимита
|
||||||
|
- **WHEN** загружаемый файл превышает максимальный размер
|
||||||
|
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||||
|
|
||||||
|
#### Scenario: Превышение лимита видео
|
||||||
|
- **WHEN** загружаемое видео превышает лимит видео
|
||||||
|
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||||
|
|
||||||
|
#### Scenario: Файл в пределах лимита
|
||||||
|
- **WHEN** файл не превышает лимит
|
||||||
|
- **THEN** система принимает multipart-загрузку и обрабатывает фото
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Медиа-запись фото
|
||||||
|
Данные фото SHALL включать вид медиа (`image` | `video`), длительность для видео и адрес
|
||||||
|
воспроизведения, когда видео-вариант готов; для изображений адрес воспроизведения
|
||||||
|
отсутствует.
|
||||||
|
|
||||||
|
#### Scenario: Запись изображения
|
||||||
|
- **WHEN** клиент получает данные изображения
|
||||||
|
- **THEN** вид медиа — изображение, длительность и адрес воспроизведения отсутствуют
|
||||||
|
|
||||||
|
#### Scenario: Запись видео без готового варианта
|
||||||
|
- **WHEN** клиент получает данные видео, а вариант ещё не готов
|
||||||
|
- **THEN** вид медиа — видео, длительность присутствует, адрес воспроизведения отсутствует
|
||||||
|
|
||||||
|
#### Scenario: Запись видео с готовым вариантом
|
||||||
|
- **WHEN** видео-вариант готов
|
||||||
|
- **THEN** данные медиа содержат адрес воспроизведения
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## MODIFIED Requirements
|
||||||
|
|
||||||
|
### Requirement: Загрузка фотографий из рабочего стола
|
||||||
|
Загрузка SHALL запускаться действием в тулбаре; перетаскивание файлов над альбомом SHALL
|
||||||
|
показывать оверлей и загружать их; пустой альбом SHALL показывать подсказку-зону загрузки.
|
||||||
|
Загрузка SHALL быть доступна только ролям с правом загрузки; принимать SHALL изображения и
|
||||||
|
видео.
|
||||||
|
|
||||||
|
#### Scenario: Перетаскивание
|
||||||
|
- **WHEN** пользователь с правом загрузки перетаскивает файлы над альбомом
|
||||||
|
- **THEN** показывается оверлей загрузки, и файлы загружаются
|
||||||
|
|
||||||
|
#### Scenario: Пустой альбом
|
||||||
|
- **WHEN** пользователь открывает альбом без фото и имеет право загрузки
|
||||||
|
- **THEN** в центре показана зона-подсказка загрузки
|
||||||
|
|
||||||
|
#### Scenario: Загрузка видео
|
||||||
|
- **WHEN** пользователь с правом загрузки выбирает или перетаскивает видеофайл
|
||||||
|
- **THEN** видео загружается в альбом наравне с изображениями
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Воспроизведение видео в просмотре
|
||||||
|
Видео в режиме просмотра SHALL воспроизводиться встроенным плеером с нативными контролами и
|
||||||
|
постером до готовности варианта; зум, панорамирование и режим панорамы SHALL к видео не
|
||||||
|
применяться, а действия скачивания, переноса, копирования и удаления SHALL оставаться доступными.
|
||||||
|
|
||||||
|
#### Scenario: Плеер
|
||||||
|
- **WHEN** пользователь открывает видео в режиме просмотра
|
||||||
|
- **THEN** показывается встроенный плеер, а до готовности варианта — постер
|
||||||
|
|
||||||
|
#### Scenario: Перемотка
|
||||||
|
- **WHEN** пользователь перематывает воспроизведение
|
||||||
|
- **THEN** плеер показывает запрошенный момент видео
|
||||||
|
|
||||||
|
#### Scenario: Готовность на лету
|
||||||
|
- **WHEN** вариант видео становится готов, пока просмотр открыт
|
||||||
|
- **THEN** плеер становится доступен без перезагрузки страницы
|
||||||
|
|
||||||
|
#### Scenario: Действия над видео
|
||||||
|
- **WHEN** открыто видео в режиме просмотра
|
||||||
|
- **THEN** доступны скачивание оригинала, перенос, копирование и удаление по правам
|
||||||
|
|
||||||
|
### Requirement: Плитка видео в сетке
|
||||||
|
Плитка видео в сетке SHALL показывать постер, признак видео и длительность; открытие плитки
|
||||||
|
SHALL открывать режим просмотра.
|
||||||
|
|
||||||
|
#### Scenario: Постер и признак
|
||||||
|
- **WHEN** видео показано в сетке
|
||||||
|
- **THEN** плитка показывает постер, значок воспроизведения и длительность
|
||||||
|
|
||||||
|
#### Scenario: Постер не готов
|
||||||
|
- **WHEN** постер ещё не готов
|
||||||
|
- **THEN** плитка показывает плейсхолдер и сетка отображается корректно
|
||||||
|
|
||||||
|
#### Scenario: Открытие из сетки
|
||||||
|
- **WHEN** пользователь открывает плитку видео
|
||||||
|
- **THEN** открывается режим просмотра видео
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Spec Delta
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Поддержка видео в альбомах: приём файла, фоновая подготовка web-совместимого варианта,
|
||||||
|
постер-кадр и воспроизведение в браузере.
|
||||||
|
|
||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Приём видео
|
||||||
|
Участник с правом загрузки SHALL загружать видео в альбом; система SHALL принимать ввод
|
||||||
|
`video/*` и SHALL отклонять файлы сверх лимита видео понятной ошибкой о превышении размера.
|
||||||
|
|
||||||
|
#### Scenario: Успешная загрузка видео
|
||||||
|
- **WHEN** участник с правом загрузки отправляет видеофайл в пределах лимита
|
||||||
|
- **THEN** система сохраняет оригинал и создаёт медиа-запись с признаком видео
|
||||||
|
|
||||||
|
#### Scenario: Превышение лимита видео
|
||||||
|
- **WHEN** загружается файл больше лимита видео
|
||||||
|
- **THEN** система отклоняет загрузку с явной ошибкой о превышении размера
|
||||||
|
|
||||||
|
#### Scenario: Не медиафайл
|
||||||
|
- **WHEN** загружается файл, не являющийся изображением или видео
|
||||||
|
- **THEN** система отклоняет загрузку
|
||||||
|
|
||||||
|
### Requirement: Метаданные видео
|
||||||
|
При обработке система SHALL извлекать длительность, размеры кадра и поворот и SHALL
|
||||||
|
возвращать длительность в данных медиа.
|
||||||
|
|
||||||
|
#### Scenario: Длительность доступна
|
||||||
|
- **WHEN** видео успешно обработано
|
||||||
|
- **THEN** данные медиа содержат длительность видео
|
||||||
|
|
||||||
|
#### Scenario: Метаданные недоступны
|
||||||
|
- **WHEN** из видео не удаётся извлечь метаданные
|
||||||
|
- **THEN** запись сохраняется, а длительность пуста
|
||||||
|
|
||||||
|
### Requirement: Постер видео
|
||||||
|
Система SHALL извлекать кадр-постер и SHALL генерировать из него варианты thumb, medium и
|
||||||
|
preview тем же способом, что для изображений, чтобы сетка и обложка отображали видео постером.
|
||||||
|
|
||||||
|
#### Scenario: Генерация постера
|
||||||
|
- **WHEN** видео обработано
|
||||||
|
- **THEN** система создаёт варианты thumb, medium и preview из кадра-постера
|
||||||
|
|
||||||
|
#### Scenario: Обложка из видео
|
||||||
|
- **WHEN** видео становится обложкой альбома
|
||||||
|
- **THEN** альбом отображает постер видео
|
||||||
|
|
||||||
|
### Requirement: Гарантированное воспроизведение
|
||||||
|
Система SHALL готовить воспроизводимый вариант видео: SHALL выполнять remux без
|
||||||
|
перекодирования, когда источник web-совместим, и SHALL транскодировать в h264/yuv420p/aac
|
||||||
|
с faststart иначе; система SHALL NOT увеличивать разрешение и SHALL ограничивать длинную
|
||||||
|
сторону 1080p.
|
||||||
|
|
||||||
|
#### Scenario: Совместимый источник
|
||||||
|
- **WHEN** источник — h264/yuv420p с совместимым профилем, звуком aac или без звука, в подходящем контейнере и в пределах разрешения
|
||||||
|
- **THEN** система подготавливает вариант remux без перекодирования
|
||||||
|
|
||||||
|
#### Scenario: Несовместимый источник
|
||||||
|
- **WHEN** источник не является web-совместимым (например, hevc, 10-bit или yuv444)
|
||||||
|
- **THEN** система транскодирует видео в h264/yuv420p/aac
|
||||||
|
|
||||||
|
#### Scenario: Разрешение выше предела
|
||||||
|
- **WHEN** длинная сторона источника превышает 1080p
|
||||||
|
- **THEN** система масштабирует видео до 1080p, не увеличивая меньший источник
|
||||||
|
|
||||||
|
### Requirement: Готовность и воспроизведение видео
|
||||||
|
Данные медиа SHALL содержать адрес воспроизводимого варианта, когда он готов; клиент SHALL
|
||||||
|
воспроизводить его с перемоткой; до готовности система SHALL отдавать постер и признак
|
||||||
|
обработки, а обновление о готовности SHALL транслироваться подписчикам.
|
||||||
|
|
||||||
|
#### Scenario: Вариант не готов
|
||||||
|
- **WHEN** видео ещё обрабатывается
|
||||||
|
- **THEN** данные медиа содержат постер и не содержат адрес воспроизведения
|
||||||
|
|
||||||
|
#### Scenario: Вариант готов
|
||||||
|
- **WHEN** обработка завершена
|
||||||
|
- **THEN** данные медиа содержат адрес воспроизведения, а подписчики получают обновление
|
||||||
|
|
||||||
|
#### Scenario: Перемотка
|
||||||
|
- **WHEN** клиент перематывает воспроизведение
|
||||||
|
- **THEN** система отдаёт запрошенный диапазон байт
|
||||||
|
|
||||||
|
### Requirement: Фоновая подготовка видео
|
||||||
|
Задание подготовки видео SHALL выполняться в фоне с длительным лизингом, таймаутом и
|
||||||
|
повторами при сбое и SHALL NOT вытеснять генерацию вариантов изображений.
|
||||||
|
|
||||||
|
#### Scenario: Повтор при сбое
|
||||||
|
- **WHEN** подготовка видео завершается ошибкой
|
||||||
|
- **THEN** задание повторяется с задержкой до достижения предела попыток
|
||||||
|
|
||||||
|
#### Scenario: Изоляция от картинок
|
||||||
|
- **WHEN** длительное видео обрабатывается
|
||||||
|
- **THEN** генерация вариантов изображений продолжает выполняться
|
||||||
|
|
||||||
|
### Requirement: Оригинал видео
|
||||||
|
Система SHALL сохранять исходный видеофайл и SHALL использовать его для скачивания
|
||||||
|
независимо от подготовленного варианта.
|
||||||
|
|
||||||
|
#### Scenario: Скачивание оригинала
|
||||||
|
- **WHEN** участник скачивает видео
|
||||||
|
- **THEN** система возвращает адрес исходного файла
|
||||||
|
|
||||||
|
### Requirement: Отсутствие видео-инструментов
|
||||||
|
При отсутствии `ffmpeg`/`ffprobe` система SHALL отключать приём видео и SHALL отражать
|
||||||
|
недоступность видео-обработки в состоянии здоровья.
|
||||||
|
|
||||||
|
#### Scenario: Инструменты отсутствуют
|
||||||
|
- **WHEN** видео-инструменты недоступны
|
||||||
|
- **THEN** приём видео отклоняется, а health сообщает о недоступности видео
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
## 1. Схема и миграции (паритет PostgreSQL и SQLite)
|
||||||
|
|
||||||
|
- [x] 1.1 Добавить миграции PostgreSQL: `photos.kind` (default `image`) и `duration_ms`, роль `playback` в `photo_objects`, `variant_jobs.kind`; проверить `make migrate` и `go test ./internal/db/...`.
|
||||||
|
- [x] 1.2 Добавить парные миграции SQLite (пересборка `photo_objects` с ролью `playback`); проверить миграционные тесты SQLite (`go test ./internal/db/...`).
|
||||||
|
- [x] 1.3 Дополнить контрактные тесты паритета бэкендов (роль `playback`, колонки `kind`) и прогнать `go test ./internal/repo/...`.
|
||||||
|
|
||||||
|
## 2. Конфигурация и видео-инструменты
|
||||||
|
|
||||||
|
- [x] 2.1 Ввести `IMAGE_MAX_UPLOAD_SIZE`, `VIDEO_MAX_UPLOAD_SIZE`, `FFMPEG_PATH`, `FFPROBE_PATH`, `VIDEO_TRANSCODE_CONCURRENCY`, `VIDEO_TEMP_DIR` в `config` и `.env.example`; покрыть `go test ./internal/config/...`.
|
||||||
|
- [x] 2.2 Определять доступность `ffmpeg`/`ffprobe` на старте, выставлять `VIDEO_ENABLED` и отражать статус видео в `/api/health`; проверить `go test ./internal/app/...` (health).
|
||||||
|
|
||||||
|
## 3. Модель медиа и потоковое хранилище
|
||||||
|
|
||||||
|
- [x] 3.1 Добавить `Photo.Kind`, `Photo.DurationMS`, `Photo.PlaybackURL` и заполнение playback-URL в `attachURLs`; проверить юнит-тестом привязки URL.
|
||||||
|
- [x] 3.2 Добавить `Open(ctx, key) (io.ReadCloser, error)` в `storage.Backend` для fs и s3; проверить `go test ./internal/storage/...`.
|
||||||
|
|
||||||
|
## 4. Потоковая загрузка и лимиты
|
||||||
|
|
||||||
|
- [x] 4.1 Перевести загрузку на стриминг во временный файл с расчётом размера и checksum и лимитом по виду файла; проверить `go test ./internal/photos/...`.
|
||||||
|
- [x] 4.2 Расширить приём на `video/*` (запись с `kind=video`, длительность до обработки), сохранив отказ для прочих типов; проверить тестами загрузки image/video/неподдерживаемого файла.
|
||||||
|
|
||||||
|
## 5. Обработка видео в воркере
|
||||||
|
|
||||||
|
- [x] 5.1 Реализовать разбор `ffprobe` и матрицу совместимости; юнит-тесты на h264/yuv420p, yuv444, hevc, mkv, vp9 и rotation.
|
||||||
|
- [x] 5.2 Реализовать построение команд remux/транскод и извлечение постера (`-ss 1`, fallback `-ss 0`) с передачей в `buildVariants`; проверить тестом с подставным ffmpeg (пропуск без ffmpeg).
|
||||||
|
- [x] 5.3 Ввести `variant_jobs.kind`, длинный лизинг/таймаут и отдельный пул видео; проверить `go test ./internal/photos/...` (worker), включая изоляцию длительного задания от генерации картинок.
|
||||||
|
|
||||||
|
## 6. API-контракт и frontend
|
||||||
|
|
||||||
|
- [x] 6.1 Добавить `kind`, `duration_ms`, `playback_url` в `types.ts`; проверить `npm run check`.
|
||||||
|
- [x] 6.2 Добавить в `Viewer.svelte` ветку `<video controls poster>` без перехвата клавиш зума/панорамы; проверить `npm run check` и `npm run build`.
|
||||||
|
- [x] 6.3 Добавить в `PhotoGrid.svelte` постер, значок воспроизведения и длительность, а в `Uploader.svelte` — приём `video/*` с лимитом по виду; проверить `npm run check`/`npm run build`.
|
||||||
|
|
||||||
|
## 7. Dev-наполнение и документация
|
||||||
|
|
||||||
|
- [x] 7.1 Добавить флаг видео в seed-скрипт и Make-цель (`VIDEO=1`) с пропуском при отсутствии инструментов; проверить `make testdata DRY=1 VIDEO=1`.
|
||||||
|
- [x] 7.2 Обновить `README.md` и `.env.example`: зависимость ffmpeg/ffprobe, лимиты и видео-переменные; проверить запуск по инструкции на чистом окружении.
|
||||||
|
|
||||||
|
## 8. Интеграционные проверки
|
||||||
|
|
||||||
|
- [x] 8.1 E2E: загрузка видео приводит к постеру и `playback_url` на SQLite (пропуск без ffmpeg); `go test ./internal/e2e/...`.
|
||||||
|
- [x] 8.2 Проверить воспроизведение видео по share-ссылке и неизменность сборки мусора и корзины для видео (ручной прогон и профильные тесты).
|
||||||
|
|
||||||
|
## Workflow follow-up
|
||||||
|
|
||||||
|
- Заархивировать изменение после удовлетворения требований ревью проекта.
|
||||||
|
- Проверить результат архива.
|
||||||
+71
-2
@@ -23,8 +23,11 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
|
import shutil
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import time
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -40,6 +43,8 @@ try:
|
|||||||
except ImportError: # pragma: no cover - exercised only without Pillow
|
except ImportError: # pragma: no cover - exercised only without Pillow
|
||||||
HAVE_PIL = False
|
HAVE_PIL = False
|
||||||
|
|
||||||
|
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
||||||
|
|
||||||
DEFAULT_BASE_URL = "http://localhost:8080"
|
DEFAULT_BASE_URL = "http://localhost:8080"
|
||||||
|
|
||||||
COMMENT_TEXTS = [
|
COMMENT_TEXTS = [
|
||||||
@@ -240,6 +245,38 @@ def make_image(rng):
|
|||||||
return "seed-{}.png".format(uuid.uuid4().hex[:8]), "image/png", generate_png(rng)
|
return "seed-{}.png".format(uuid.uuid4().hex[:8]), "image/png", generate_png(rng)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_video(rng):
|
||||||
|
"""Return (filename, content_type, data) for one generated video.
|
||||||
|
|
||||||
|
Uses ffmpeg's synthetic source so the fixture is small and deterministic.
|
||||||
|
Returns None when ffmpeg is unavailable.
|
||||||
|
"""
|
||||||
|
if not HAVE_FFMPEG:
|
||||||
|
return None
|
||||||
|
seconds = rng.randint(1, 3)
|
||||||
|
with tempfile.TemporaryDirectory() as tmp:
|
||||||
|
path = os.path.join(tmp, "seed.mp4")
|
||||||
|
cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-v", "error",
|
||||||
|
"-y",
|
||||||
|
"-f", "lavfi",
|
||||||
|
"-i", "testsrc=size=320x240:rate=15",
|
||||||
|
"-t", str(seconds),
|
||||||
|
"-pix_fmt", "yuv420p",
|
||||||
|
"-c:v", "libx264",
|
||||||
|
"-preset", "veryfast",
|
||||||
|
path,
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True)
|
||||||
|
except (subprocess.CalledProcessError, OSError):
|
||||||
|
return None
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
data = fh.read()
|
||||||
|
return "seed-{}.mp4".format(uuid.uuid4().hex[:8]), "video/mp4", data
|
||||||
|
|
||||||
|
|
||||||
# --- seeding ----------------------------------------------------------------
|
# --- seeding ----------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@@ -247,6 +284,7 @@ class Stats:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.albums = 0
|
self.albums = 0
|
||||||
self.photos = 0
|
self.photos = 0
|
||||||
|
self.videos = 0
|
||||||
self.tags = 0
|
self.tags = 0
|
||||||
self.comments = 0
|
self.comments = 0
|
||||||
self.likes = 0
|
self.likes = 0
|
||||||
@@ -255,8 +293,8 @@ class Stats:
|
|||||||
|
|
||||||
def report(self):
|
def report(self):
|
||||||
return (
|
return (
|
||||||
"создано: альбомов={albums}, фото={photos}, тегов={tags}, "
|
"создано: альбомов={albums}, фото={photos}, видео={videos}, "
|
||||||
"комментариев={comments}, лайков={likes}, "
|
"тегов={tags}, комментариев={comments}, лайков={likes}, "
|
||||||
"share-ссылок={share_links}, инвайтов={invites}".format(
|
"share-ссылок={share_links}, инвайтов={invites}".format(
|
||||||
**self.__dict__
|
**self.__dict__
|
||||||
)
|
)
|
||||||
@@ -289,6 +327,20 @@ def upload_photo(client, album_id, rng):
|
|||||||
return resp["photos"][0]["id"]
|
return resp["photos"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
|
def upload_video(client, album_id, rng):
|
||||||
|
"""Upload one video fixture. Returns the photo id, or None when skipped."""
|
||||||
|
fixture = generate_video(rng)
|
||||||
|
if fixture is None:
|
||||||
|
return None
|
||||||
|
filename, content_type, data = fixture
|
||||||
|
resp = client.request(
|
||||||
|
"POST",
|
||||||
|
"/api/albums/{}/photos".format(album_id),
|
||||||
|
files=[("files", filename, content_type, data)],
|
||||||
|
)
|
||||||
|
return resp["photos"][0]["id"]
|
||||||
|
|
||||||
|
|
||||||
def wait_for_variants(client, photo_id, timeout, poll=1.0):
|
def wait_for_variants(client, photo_id, timeout, poll=1.0):
|
||||||
deadline = time.time() + timeout
|
deadline = time.time() + timeout
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
@@ -349,6 +401,10 @@ def build_plan(args):
|
|||||||
extras.append("инвайты")
|
extras.append("инвайты")
|
||||||
lines.append(" сопутствующие сущности: {}".format(", ".join(extras) or "нет"))
|
lines.append(" сопутствующие сущности: {}".format(", ".join(extras) or "нет"))
|
||||||
lines.append(" ожидание вариантов: {}".format("да" if args.wait_variants else "нет"))
|
lines.append(" ожидание вариантов: {}".format("да" if args.wait_variants else "нет"))
|
||||||
|
video_state = "нет"
|
||||||
|
if args.video:
|
||||||
|
video_state = "да" if HAVE_FFMPEG else "да, но ffmpeg не найден (пропуск)"
|
||||||
|
lines.append(" видео-фикстуры: {}".format(video_state))
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
@@ -365,6 +421,12 @@ def run(args, base_url, email, password):
|
|||||||
login(client, email, password)
|
login(client, email, password)
|
||||||
print("Вошел как {} @ {}".format(email, base_url))
|
print("Вошел как {} @ {}".format(email, base_url))
|
||||||
|
|
||||||
|
if args.video and not HAVE_FFMPEG:
|
||||||
|
print(
|
||||||
|
" ! ffmpeg не найден: видео-фикстуры пропускаются",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
for album_index in range(1, args.albums + 1):
|
for album_index in range(1, args.albums + 1):
|
||||||
album_id = create_album(
|
album_id = create_album(
|
||||||
client, "{} #{}".format(run_tag, album_index)
|
client, "{} #{}".format(run_tag, album_index)
|
||||||
@@ -402,6 +464,8 @@ def run(args, base_url, email, password):
|
|||||||
if args.invites:
|
if args.invites:
|
||||||
create_invite(client, album_id, "member")
|
create_invite(client, album_id, "member")
|
||||||
stats.invites += 1
|
stats.invites += 1
|
||||||
|
if args.video and upload_video(client, album_id, rng) is not None:
|
||||||
|
stats.videos += 1
|
||||||
|
|
||||||
print(stats.report())
|
print(stats.report())
|
||||||
return 0
|
return 0
|
||||||
@@ -424,6 +488,11 @@ def parse_args(argv=None):
|
|||||||
parser.add_argument("--likes", action="store_true", help="добавлять лайки")
|
parser.add_argument("--likes", action="store_true", help="добавлять лайки")
|
||||||
parser.add_argument("--share-links", action="store_true", help="создавать share-ссылки")
|
parser.add_argument("--share-links", action="store_true", help="создавать share-ссылки")
|
||||||
parser.add_argument("--invites", action="store_true", help="создавать инвайты (роль member)")
|
parser.add_argument("--invites", action="store_true", help="создавать инвайты (роль member)")
|
||||||
|
parser.add_argument(
|
||||||
|
"--video",
|
||||||
|
action="store_true",
|
||||||
|
help="загружать видео-фикстуры (требует ffmpeg; иначе пропускаются)",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--wait-variants",
|
"--wait-variants",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|||||||
Reference in New Issue
Block a user