From d38ede02011a40d8b3dd35a879cdb1950cc0416f Mon Sep 17 00:00:00 2001 From: Iu Date: Sun, 11 Oct 2026 20:37:53 +0300 Subject: [PATCH] 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. --- .env.example | 15 + Makefile | 5 +- README.md | 31 +- backend/internal/app/app.go | 18 +- backend/internal/app/health_test.go | 21 ++ backend/internal/config/config.go | 80 ++++++ backend/internal/config/config_test.go | 56 ++++ backend/internal/db/migrate_sqlite_test.go | 36 +++ backend/internal/e2e/video_test.go | 142 ++++++++++ backend/internal/models/models.go | 4 + backend/internal/photos/handlers.go | 17 +- backend/internal/photos/media.go | 16 ++ backend/internal/photos/media_test.go | 125 ++++++++ backend/internal/photos/objects.go | 9 +- backend/internal/photos/pipeline.go | 2 +- backend/internal/photos/repository.go | 6 +- backend/internal/photos/service.go | 224 +++++++++++++-- backend/internal/photos/sqlite_objects.go | 8 +- backend/internal/photos/sqlite_store.go | 44 ++- backend/internal/photos/store.go | 47 ++- backend/internal/photos/video_worker_test.go | 146 ++++++++++ backend/internal/photos/worker.go | 268 +++++++++++++++++- backend/internal/photos/worker_test.go | 24 ++ backend/internal/repo/contract/contract.go | 64 ++++- backend/internal/storage/fs.go | 16 ++ backend/internal/storage/fs_test.go | 23 ++ backend/internal/storage/storage.go | 18 ++ backend/internal/video/ffmpeg.go | 70 +++++ backend/internal/video/ffmpeg_test.go | 73 +++++ backend/internal/video/probe.go | 169 +++++++++++ backend/internal/video/probe_test.go | 95 +++++++ backend/internal/video/toolchain.go | 77 +++++ backend/internal/video/toolchain_test.go | 45 +++ .../migrations/000012_video_support.down.sql | 9 + .../migrations/000012_video_support.up.sql | 13 + .../sqlite/000004_video_support.down.sql | 21 ++ .../sqlite/000004_video_support.up.sql | 24 ++ frontend/src/lib/components/PhotoGrid.svelte | 23 ++ frontend/src/lib/components/Uploader.svelte | 17 +- frontend/src/lib/components/Viewer.svelte | 44 +++ frontend/src/lib/icons/registry.ts | 1 + frontend/src/lib/types.ts | 3 + .../changes/add-video-support/.openspec.yaml | 2 + openspec/changes/add-video-support/design.md | 153 ++++++++++ .../changes/add-video-support/proposal.md | 57 ++++ .../specs/dev-seed-data/spec.md | 36 +++ .../specs/media-objects/spec.md | 51 ++++ .../add-video-support/specs/photos/spec.md | 58 ++++ .../specs/ui-workspace/spec.md | 60 ++++ .../add-video-support/specs/videos/spec.md | 111 ++++++++ openspec/changes/add-video-support/tasks.md | 49 ++++ scripts/seed.py | 73 ++++- 52 files changed, 2717 insertions(+), 82 deletions(-) create mode 100644 backend/internal/e2e/video_test.go create mode 100644 backend/internal/photos/media.go create mode 100644 backend/internal/photos/media_test.go create mode 100644 backend/internal/photos/video_worker_test.go create mode 100644 backend/internal/video/ffmpeg.go create mode 100644 backend/internal/video/ffmpeg_test.go create mode 100644 backend/internal/video/probe.go create mode 100644 backend/internal/video/probe_test.go create mode 100644 backend/internal/video/toolchain.go create mode 100644 backend/internal/video/toolchain_test.go create mode 100644 backend/migrations/000012_video_support.down.sql create mode 100644 backend/migrations/000012_video_support.up.sql create mode 100644 backend/migrations/sqlite/000004_video_support.down.sql create mode 100644 backend/migrations/sqlite/000004_video_support.up.sql create mode 100644 openspec/changes/add-video-support/.openspec.yaml create mode 100644 openspec/changes/add-video-support/design.md create mode 100644 openspec/changes/add-video-support/proposal.md create mode 100644 openspec/changes/add-video-support/specs/dev-seed-data/spec.md create mode 100644 openspec/changes/add-video-support/specs/media-objects/spec.md create mode 100644 openspec/changes/add-video-support/specs/photos/spec.md create mode 100644 openspec/changes/add-video-support/specs/ui-workspace/spec.md create mode 100644 openspec/changes/add-video-support/specs/videos/spec.md create mode 100644 openspec/changes/add-video-support/tasks.md diff --git a/.env.example b/.env.example index 0e77923..381284f 100644 --- a/.env.example +++ b/.env.example @@ -55,6 +55,21 @@ WORKER_BATCH=100 WORKER_CONCURRENCY=4 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. # LOG_FORMAT selects the output encoding. LOG_LEVEL=info diff --git a/Makefile b/Makefile index 5be49d1..8ddbc8c 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,8 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) # Seed testdata parameters (see `make testdata`). Counts: ALBUMS, PHOTOS, SEED, TIMEOUT. -# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY. -# Extra passthrough flags: ARGS. +# Boolean (set to 1 to enable): TAGS, COMMENTS, LIKES, SHARE, INVITES, WAIT, DRY, VIDEO. +# VIDEO requires ffmpeg on PATH; it is skipped without it. Extra passthrough flags: ARGS. ALBUMS ?= 2 PHOTOS ?= 10 SEED ?= 0 @@ -76,6 +76,7 @@ testdata: $(if $(filter 1,$(LIKES)),--likes,) \ $(if $(filter 1,$(SHARE)),--share-links,) \ $(if $(filter 1,$(INVITES)),--invites,) \ + $(if $(filter 1,$(VIDEO)),--video,) \ $(if $(filter 1,$(WAIT)),--wait-variants,) \ $(if $(filter 1,$(DRY)),--dry-run,) \ $(ARGS) diff --git a/README.md b/README.md index 69599a3..603b8ff 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,8 @@ SvelteKit через `go:embed`, запуск через systemd. - Go 1.22+ (сборка проверена на 1.26) - Node.js 20+ (только для сборки фронтенда) - Доступ к `pg.localnet:5432` (PostgreSQL) и `s3.localnet:3900` (Garage) +- `ffmpeg` и `ffprobe` на хосте — только для видео; без них приём видео отключается, + а изображения продолжают работать (см. «Видео») - `make` ## Конфигурация @@ -68,6 +70,13 @@ cp .env.example .env | `WORKER_INTERVAL` | `5s` | Период проверки воркером очереди задач | | `WORKER_BATCH` | `10` | Размер батча обрабатываемых задач | | `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_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 @@ -162,7 +190,7 @@ GALO_EMAIL=owner@example.com GALO_PASSWORD=secret \ Значения по умолчанию: `ALBUMS=2`, `PHOTOS=10`, `SEED=0`, `TIMEOUT=30`. Булевы параметры включаются значением `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-ссылки на альбомы | | `--invites` | выкл. | Создавать инвайты (роль `member`) | | `--wait-variants` | выкл. | Дожидаться генерации превью каждого фото | +| `--video` | выкл. | Загружать видео-фикстуры (требует `ffmpeg`; иначе пропускаются) | | `--timeout` | `30` | Таймаут ожидания и сетевых запросов, сек | | `--dry-run` | выкл. | Показать план и не отправлять изменяющих запросов | diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index d990c36..cdd4504 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -24,6 +24,7 @@ import ( "galo/internal/sse" "galo/internal/storage" "galo/internal/store" + "galo/internal/video" ) type Server struct { @@ -42,6 +43,8 @@ type Server struct { photos *photos.Service worker *photos.Worker + videoTools *video.Toolchain + social *social.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) 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) 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, photos: photosSvc, worker: photosWorker, + videoTools: videoTools, social: socialSvc, notifications: notifSvc, share: shareSvc, @@ -188,13 +198,19 @@ func (s *Server) health(c *gin.Context) { if err := s.storage.Ping(ctx); err != nil { 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 overall := "ok" if dbStatus != "ok" || storeStatus != "ok" { status = http.StatusServiceUnavailable 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) { diff --git a/backend/internal/app/health_test.go b/backend/internal/app/health_test.go index 8d1db3a..b6b733f 100644 --- a/backend/internal/app/health_test.go +++ b/backend/internal/app/health_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" "time" @@ -12,6 +13,7 @@ import ( "galo/internal/config" "galo/internal/storage" + "galo/internal/video" ) func mustSigner(t *testing.T) *storage.Signer { @@ -58,4 +60,23 @@ func TestHealthStorageLabel(t *testing.T) { if body := call(); body["s3"] != "ok" { 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) + } } diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index d5888c0..af8d90a 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "strconv" + "strings" "time" "galo/internal/logging" @@ -54,6 +55,23 @@ type Worker struct { 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 { Backend string FSRoot string @@ -82,6 +100,8 @@ type Config struct { Server Server SSE SSE Worker Worker + Upload Upload + Video Video Log Log } @@ -151,6 +171,19 @@ func Load() (*Config, error) { 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") if _, err := logging.ParseLevel(logLevel); err != nil { return nil, err @@ -207,6 +240,16 @@ func Load() (*Config, error) { PendingTTL: workerPendingTTL, 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{ Level: logLevel, Format: logFormat, @@ -245,3 +288,40 @@ func getEnv(key, fallback string) string { } 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 +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go index 2de56c3..415a823 100644 --- a/backend/internal/config/config_test.go +++ b/backend/internal/config/config_test.go @@ -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") + } + }) +} diff --git a/backend/internal/db/migrate_sqlite_test.go b/backend/internal/db/migrate_sqlite_test.go index 3c0c48d..5ea1027 100644 --- a/backend/internal/db/migrate_sqlite_test.go +++ b/backend/internal/db/migrate_sqlite_test.go @@ -1,8 +1,10 @@ package db import ( + "database/sql" "io/fs" "path/filepath" + "strings" "testing" assets "galo" @@ -34,4 +36,38 @@ func TestMigrateSQLite(t *testing.T) { if n != 1 { 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) } diff --git a/backend/internal/e2e/video_test.go b/backend/internal/e2e/video_test.go new file mode 100644 index 0000000..54eaee0 --- /dev/null +++ b/backend/internal/e2e/video_test.go @@ -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) + } + } +} diff --git a/backend/internal/models/models.go b/backend/internal/models/models.go index e1c2045..f38610e 100644 --- a/backend/internal/models/models.go +++ b/backend/internal/models/models.go @@ -71,10 +71,13 @@ type Photo struct { ThumbKey string `json:"-"` MediumKey string `json:"-"` PreviewKey string `json:"-"` + PlaybackKey string `json:"-"` OriginalFilename string `json:"original_filename"` Size int64 `json:"size"` Width int `json:"width"` Height int `json:"height"` + Kind string `json:"kind"` + DurationMS int64 `json:"duration_ms,omitempty"` MimeType string `json:"mime_type"` TakenAt *time.Time `json:"taken_at,omitempty"` Latitude *float64 `json:"latitude,omitempty"` @@ -92,6 +95,7 @@ type Photo struct { ThumbURL string `json:"thumb_url,omitempty"` MediumURL string `json:"medium_url,omitempty"` PreviewURL string `json:"preview_url,omitempty"` + PlaybackURL string `json:"playback_url,omitempty"` OriginalURL string `json:"original_url,omitempty"` DownloadURL string `json:"download_url,omitempty"` } diff --git a/backend/internal/photos/handlers.go b/backend/internal/photos/handlers.go index 3a75acc..7bb6bd6 100644 --- a/backend/internal/photos/handlers.go +++ b/backend/internal/photos/handlers.go @@ -2,7 +2,6 @@ package photos import ( "errors" - "io" "net/http" "strconv" "time" @@ -13,8 +12,6 @@ import ( "galo/internal/httpx" ) -const maxUploadSize = 100 << 20 - type Handler struct { svc *Service } @@ -41,12 +38,12 @@ func (h *Handler) List(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() if err != nil { var maxErr *http.MaxBytesError 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 } 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) 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{ AlbumID: c.Param("id"), UserID: auth.UserID(c), Filename: fh.Filename, MimeType: fh.Header.Get("Content-Type"), - Data: data, + Reader: f, + Size: fh.Size, }) + f.Close() if err != nil { httpx.RespondError(c, err) return diff --git a/backend/internal/photos/media.go b/backend/internal/photos/media.go new file mode 100644 index 0000000..3572611 --- /dev/null +++ b/backend/internal/photos/media.go @@ -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 +} diff --git a/backend/internal/photos/media_test.go b/backend/internal/photos/media_test.go new file mode 100644 index 0000000..f9c4a52 --- /dev/null +++ b/backend/internal/photos/media_test.go @@ -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) + } +} diff --git a/backend/internal/photos/objects.go b/backend/internal/photos/objects.go index 2e3872b..f1a7ae8 100644 --- a/backend/internal/photos/objects.go +++ b/backend/internal/photos/objects.go @@ -22,6 +22,7 @@ const ( RoleThumb = "thumb" RoleMedium = "medium" RolePreview = "preview" + RolePlayback = "playback" ) 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 // lease pushes next_attempt_at forward so the same job is not picked up twice -// while it is being processed. -func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) { +// while it is being processed. A non-empty kind restricts claims to that media +// 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 { 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 JOIN photos p ON p.id = vj.photo_id WHERE vj.next_attempt_at <= now() + AND ($2 = '' OR vj.kind = $2) ORDER BY (vj.attempts > 0) ASC, CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST, vj.next_attempt_at ASC, vj.photo_id DESC LIMIT $1 - FOR UPDATE OF vj SKIP LOCKED`, limit) + FOR UPDATE OF vj SKIP LOCKED`, limit, kind) if err != nil { return nil, err } diff --git a/backend/internal/photos/pipeline.go b/backend/internal/photos/pipeline.go index a2fdf85..92bdea4 100644 --- a/backend/internal/photos/pipeline.go +++ b/backend/internal/photos/pipeline.go @@ -10,9 +10,9 @@ import ( "strings" "time" + "github.com/disintegration/imaging" exif "github.com/dsoprea/go-exif/v3" exifcommon "github.com/dsoprea/go-exif/v3/common" - "github.com/disintegration/imaging" "github.com/gen2brain/webp" ) diff --git a/backend/internal/photos/repository.go b/backend/internal/photos/repository.go index 66c0a36..8a0a3b8 100644 --- a/backend/internal/photos/repository.go +++ b/backend/internal/photos/repository.go @@ -44,9 +44,13 @@ type Repository interface { PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error) ClaimGarbage(ctx context.Context, 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 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(ctx context.Context) ([]string, error) diff --git a/backend/internal/photos/service.go b/backend/internal/photos/service.go index 7f587f6..06e0858 100644 --- a/backend/internal/photos/service.go +++ b/backend/internal/photos/service.go @@ -1,10 +1,17 @@ package photos import ( + "bufio" "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" + "fmt" + "io" + "mime" "net/http" + "os" "path/filepath" "strings" "time" @@ -26,10 +33,49 @@ type Service struct { albums AlbumAccess storage storage.Backend hub *sse.Hub + + imageMax int64 + videoMax int64 + videoEnabled bool + tempDir string } 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 { @@ -37,7 +83,26 @@ type UploadInput struct { UserID string Filename string MimeType string - Data []byte + // 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 +} + +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) { @@ -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") } - mime := in.MimeType - if mime == "" { - mime = http.DetectContentType(in.Data) + br := bufio.NewReader(in.reader()) + head, _ := br.Peek(512) + 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/") { - return nil, httpx.BadRequest("invalid_file", "file is not an image") + if kind == KindVideo && !s.videoEnabled { + 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) - if !ok { - return nil, httpx.BadRequest("invalid_image", "could not decode image") + // 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 { + return nil, httpx.BadRequest("invalid_image", "could not decode image") + } + width, height = w, h + meta = extractEXIF(data) + attrs = meta.Attributes + if attrs == nil { + attrs = map[string]any{} + } } - meta := extractEXIF(in.Data) ext := strings.ToLower(filepath.Ext(in.Filename)) if ext == "" { @@ -78,23 +190,25 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er UploaderID: in.UserID, StorageKey: key, OriginalFilename: in.Filename, - Size: int64(len(in.Data)), + Size: written, Width: width, Height: height, + Kind: kind, + DurationMS: durationMS, MimeType: mime, TakenAt: meta.TakenAt, Latitude: meta.Latitude, Longitude: meta.Longitude, - EXIF: meta.Attributes, + EXIF: attrs, } obj := &Object{ ID: objectID, StorageKey: key, MimeType: mime, - Size: int64(len(in.Data)), + Size: written, Width: width, 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, @@ -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 { 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) 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 { return nil, err } @@ -119,6 +240,72 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er 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 { if 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 } } + if p.PlaybackKey != "" { + if url, err := s.storage.URL(ctx, p.PlaybackKey); err == nil { + p.PlaybackURL = url + } + } return nil } diff --git a/backend/internal/photos/sqlite_objects.go b/backend/internal/photos/sqlite_objects.go index 120e730..c85a80f 100644 --- a/backend/internal/photos/sqlite_objects.go +++ b/backend/internal/photos/sqlite_objects.go @@ -226,8 +226,9 @@ func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Dura return objs, nil } -// ClaimVariantJobs leases due variant jobs and returns their photo ids. -func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) { +// ClaimVariantJobs leases due variant jobs and returns their photo ids. A +// 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 { 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 JOIN photos p ON p.id = vj.photo_id WHERE vj.next_attempt_at <= $1 + AND ($3 = '' OR vj.kind = $3) ORDER BY (vj.attempts > 0) ASC, CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST, vj.next_attempt_at ASC, vj.photo_id DESC - LIMIT $2`, now, limit) + LIMIT $2`, now, limit, kind) if err != nil { return nil, err } diff --git a/backend/internal/photos/sqlite_store.go b/backend/internal/photos/sqlite_store.go index b6ef20f..8b5180d 100644 --- a/backend/internal/photos/sqlite_store.go +++ b/backend/internal/photos/sqlite_store.go @@ -24,7 +24,9 @@ const sqlitePhotoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_t.storage_key, ''), COALESCE(o_m.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.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, u.id, u.name, COALESCE(u.avatar_url, ''), (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 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 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) { var p models.Photo @@ -53,8 +57,9 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) { var lat, long sql.NullFloat64 var liked int 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.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &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 if err := tx.QueryRowContext(ctx, - `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + `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, $10, $11) RETURNING created_at`, p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename, nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON), + mediaKind(p.Kind), p.DurationMS, ).Scan(&createdAt); err != nil { return err } @@ -109,7 +115,8 @@ func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, o return err } 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 tx.Commit() @@ -221,10 +228,11 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl defer func() { _ = tx.Rollback() }() if _, err := tx.ExecContext(ctx, - `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + `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, $10, $11)`, 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 } 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 liked int 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.Kind, &p.DurationMS, &takenAt, &lat, &long, &p.Caption, &createdAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &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 } +// 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 // of every photo with empty exif. func (s *SQLiteStore) BackfillEXIF(ctx context.Context, fetch func(context.Context, string) ([]byte, error)) (int, error) { diff --git a/backend/internal/photos/store.go b/backend/internal/photos/store.go index a377e8b..95d49f4 100644 --- a/backend/internal/photos/store.go +++ b/backend/internal/photos/store.go @@ -31,7 +31,9 @@ const photoColumns = `p.id, COALESCE(p.album_id, ''), p.uploader_id, COALESCE(o_t.storage_key, ''), COALESCE(o_m.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.kind, COALESCE(p.duration_ms, 0), p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at, u.id, u.name, COALESCE(u.avatar_url, ''), (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 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 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) { var p models.Photo var exifText, tagsText string 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.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &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 } if err := tx.QueryRow(ctx, - `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb) + `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, $10, $11) RETURNING created_at`, 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 { return err } @@ -127,7 +133,8 @@ func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Ob return err } 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 tx.Commit(ctx) @@ -189,8 +196,9 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error) var p models.Photo var exifText, tagsText string 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.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &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) }() if _, err := tx.Exec(ctx, - `INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`, - id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil { + `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, $10, $11)`, + id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON), + mediaKind(src.Kind), src.DurationMS); err != nil { return err } 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 exifText, tagsText string 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.Kind, &p.DurationMS, &p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt, &p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL, &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 } +// 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 { ID string StorageKey string diff --git a/backend/internal/photos/video_worker_test.go b/backend/internal/photos/video_worker_test.go new file mode 100644 index 0000000..c2a27ed --- /dev/null +++ b/backend/internal/photos/video_worker_test.go @@ -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") + } +} diff --git a/backend/internal/photos/worker.go b/backend/internal/photos/worker.go index c96d9f3..0bb8b70 100644 --- a/backend/internal/photos/worker.go +++ b/backend/internal/photos/worker.go @@ -3,16 +3,29 @@ package photos import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "errors" + "io" "log/slog" + "os" "sync" "time" "galo/internal/auth" + "galo/internal/video" ) 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 // and the object garbage collector entirely from the state recorded in the // metadata layer, so it resumes correctly after a restart. @@ -23,6 +36,11 @@ type Worker struct { pendingTTL time.Duration lease time.Duration concurrency int + + tools *video.Toolchain + videoConc int + videoLease time.Duration + tempDir string } // 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, lease: interval * 4, 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. func (w *Worker) Run(ctx context.Context) { 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) { w.processVariantJobs(ctx) + w.processVideoJobs(ctx) w.collectGarbage(ctx) w.reapStalePending(ctx) } 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 { slog.Error("claim variant jobs", "error", err) 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 // flight. Generation is CPU-bound (decode + resize + encode), so parallelism // 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 -// the original, so copies reuse the same physical variant. +// createVariant stores one derived image object and binds it to every photo +// sharing the original, so copies reuse the same physical variant. 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() - key := objectKey(objID, role, ".webp") + key := objectKey(objID, role, ext) obj := &Object{ ID: objID, StorageKey: key, - MimeType: "image/webp", - Size: int64(len(data)), - Checksum: checksum(data), + MimeType: mimeType, + Size: size, + Checksum: sum, } if err := w.svc.store.CreateObject(ctx, obj); err != nil { 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) return err } @@ -191,6 +256,193 @@ func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role stri 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) { p, err := w.svc.store.GetByID(ctx, photoID, "") if err != nil { diff --git a/backend/internal/photos/worker_test.go b/backend/internal/photos/worker_test.go index fe2efb0..d007d31 100644 --- a/backend/internal/photos/worker_test.go +++ b/backend/internal/photos/worker_test.go @@ -7,7 +7,9 @@ import ( "github.com/jackc/pgx/v5/pgxpool" + "galo/internal/config" "galo/internal/storage" + "galo/internal/video" ) 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 { t.Helper() var n int diff --git a/backend/internal/repo/contract/contract.go b/backend/internal/repo/contract/contract.go index 38b7f4d..9dd7df6 100644 --- a/backend/internal/repo/contract/contract.go +++ b/backend/internal/repo/contract/contract.go @@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) { {"Social", testSocial}, {"Photos", testPhotos}, {"Objects", testObjects}, + {"VariantJobKinds", testVariantJobKinds}, {"Settings", testSettings}, } for _, tc := range cases { @@ -94,6 +95,19 @@ func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID s 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) { 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) } + // 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. if err := r.Photos.HardDelete(ctx, p.ID); err != nil { t.Fatalf("hard delete: %v", err) @@ -510,8 +537,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { for _, o := range claimed { got[o.ID] = true } - if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] { - t.Fatalf("claimed = %+v, want original, thumb and medium", got) + if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] || !got[playback.ID] { + t.Fatalf("claimed = %+v, want original, thumb, medium and playback", got) } for _, o := range claimed { 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. 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 { 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 { 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) } @@ -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 { 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) } 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) } } - 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) } @@ -572,7 +599,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) { pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID) time.Sleep(5 * time.Millisecond) 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 { 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) { v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil }) if err != nil || v != "generated-1" { diff --git a/backend/internal/storage/fs.go b/backend/internal/storage/fs.go index e8737c9..227d2aa 100644 --- a/backend/internal/storage/fs.go +++ b/backend/internal/storage/fs.go @@ -122,6 +122,22 @@ func (f *FS) Get(_ context.Context, key string) ([]byte, error) { 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 { var errs []error for _, key := range keys { diff --git a/backend/internal/storage/fs_test.go b/backend/internal/storage/fs_test.go index 6c68f27..b75777b 100644 --- a/backend/internal/storage/fs_test.go +++ b/backend/internal/storage/fs_test.go @@ -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) { ctx := context.Background() fs := newTestFS(t) diff --git a/backend/internal/storage/storage.go b/backend/internal/storage/storage.go index fe56fa1..f4277e3 100644 --- a/backend/internal/storage/storage.go +++ b/backend/internal/storage/storage.go @@ -44,6 +44,9 @@ type Backend interface { Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error Delete(ctx context.Context, keys ...string) 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(ctx context.Context, key string) (string, error) // 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 } +// 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 // the S3 endpoint is never exposed to the client. func (c *S3) URL(_ context.Context, key string) (string, error) { diff --git a/backend/internal/video/ffmpeg.go b/backend/internal/video/ffmpeg.go new file mode 100644 index 0000000..d73c33b --- /dev/null +++ b/backend/internal/video/ffmpeg.go @@ -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 +} diff --git a/backend/internal/video/ffmpeg_test.go b/backend/internal/video/ffmpeg_test.go new file mode 100644 index 0000000..4292041 --- /dev/null +++ b/backend/internal/video/ffmpeg_test.go @@ -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) + } +} diff --git a/backend/internal/video/probe.go b/backend/internal/video/probe.go new file mode 100644 index 0000000..0e71899 --- /dev/null +++ b/backend/internal/video/probe.go @@ -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 +} diff --git a/backend/internal/video/probe_test.go b/backend/internal/video/probe_test.go new file mode 100644 index 0000000..b8f2846 --- /dev/null +++ b/backend/internal/video/probe_test.go @@ -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") + } +} diff --git a/backend/internal/video/toolchain.go b/backend/internal/video/toolchain.go new file mode 100644 index 0000000..0f70baf --- /dev/null +++ b/backend/internal/video/toolchain.go @@ -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 +} diff --git a/backend/internal/video/toolchain_test.go b/backend/internal/video/toolchain_test.go new file mode 100644 index 0000000..b0a52b1 --- /dev/null +++ b/backend/internal/video/toolchain_test.go @@ -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") + } +} diff --git a/backend/migrations/000012_video_support.down.sql b/backend/migrations/000012_video_support.down.sql new file mode 100644 index 0000000..25216a1 --- /dev/null +++ b/backend/migrations/000012_video_support.down.sql @@ -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; diff --git a/backend/migrations/000012_video_support.up.sql b/backend/migrations/000012_video_support.up.sql new file mode 100644 index 0000000..f0f2960 --- /dev/null +++ b/backend/migrations/000012_video_support.up.sql @@ -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')); diff --git a/backend/migrations/sqlite/000004_video_support.down.sql b/backend/migrations/sqlite/000004_video_support.down.sql new file mode 100644 index 0000000..c59110f --- /dev/null +++ b/backend/migrations/sqlite/000004_video_support.down.sql @@ -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; diff --git a/backend/migrations/sqlite/000004_video_support.up.sql b/backend/migrations/sqlite/000004_video_support.up.sql new file mode 100644 index 0000000..4b72cb1 --- /dev/null +++ b/backend/migrations/sqlite/000004_video_support.up.sql @@ -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'; diff --git a/frontend/src/lib/components/PhotoGrid.svelte b/frontend/src/lib/components/PhotoGrid.svelte index f93206d..9b49089 100644 --- a/frontend/src/lib/components/PhotoGrid.svelte +++ b/frontend/src/lib/components/PhotoGrid.svelte @@ -50,6 +50,14 @@ const isSelected = (id: string) => selectedIds.includes(id); 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) => cursorIndex == null ? (i === 0 ? 0 : -1) : isCursor(i) ? 0 : -1; @@ -117,6 +125,21 @@ 360 {/if} + {#if photo.kind === 'video'} + + + + {#if formatDuration(photo.duration_ms)} + + {formatDuration(photo.duration_ms)} + + {/if} + {/if} diff --git a/frontend/src/lib/components/Uploader.svelte b/frontend/src/lib/components/Uploader.svelte index 0de5fce..21b9185 100644 --- a/frontend/src/lib/components/Uploader.svelte +++ b/frontend/src/lib/components/Uploader.svelte @@ -14,7 +14,12 @@ let progress = $state(0); 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() { fileInput?.click(); @@ -23,14 +28,14 @@ export function upload(files: FileList | File[]) { const list = Array.from(files); 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) { - error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Максимум 100 МБ.`; + error = `Файл слишком большой: ${tooBig.map((f) => f.name).join(', ')}. Видео до 2 ГБ, изображения до 100 МБ.`; return; } const total = list.reduce((sum, f) => sum + f.size, 0); - if (total > MAX_SIZE) { - error = 'Суммарный размер файлов превышает 100 МБ. Загрузите меньшими порциями.'; + if (total > VIDEO_MAX) { + error = 'Суммарный размер файлов превышает 2 ГБ. Загрузите меньшими порциями.'; return; } error = ''; @@ -80,7 +85,7 @@ { const id = current?.id; @@ -268,6 +273,10 @@ function onKey(ev: KeyboardEvent) { 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 (!document.fullscreenElement) onClose(); return; @@ -396,6 +405,41 @@ onFallback={() => (panoFailed = true)} /> + {:else if isVideo} +
+ {#if playbackSrc} + + + {:else} +
+ {#if videoPoster} + {current?.original_filename + {/if} + + Обработка видео… + +
+ {/if} +
{:else}
' }, add: { path: '' }, remove: { path: '' }, + play_arrow: { path: '' }, photo_library: { path: '' }, group: { path: '' }, settings: { path: '' }, diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 35d6f29..9c74308 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -51,6 +51,8 @@ export interface Photo { width: number; height: number; mime_type: string; + kind: 'image' | 'video'; + duration_ms?: number; taken_at?: string | null; latitude?: number | null; longitude?: number | null; @@ -67,6 +69,7 @@ export interface Photo { thumb_url?: string; medium_url?: string; preview_url?: string; + playback_url?: string; original_url?: string; download_url?: string; } diff --git a/openspec/changes/add-video-support/.openspec.yaml b/openspec/changes/add-video-support/.openspec.yaml new file mode 100644 index 0000000..a369219 --- /dev/null +++ b/openspec/changes/add-video-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-11 diff --git a/openspec/changes/add-video-support/design.md b/openspec/changes/add-video-support/design.md new file mode 100644 index 0000000..84b1d68 --- /dev/null +++ b/openspec/changes/add-video-support/design.md @@ -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 (источник