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}
+

+ {/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 (источник