feat(video): add video upload with remux/transcode playback

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