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:
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"galo/internal/logging"
|
||||
@@ -54,6 +55,23 @@ type Worker struct {
|
||||
Concurrency int
|
||||
}
|
||||
|
||||
// Upload holds the per-kind upload size limits. Images and video are bounded
|
||||
// separately because video files are an order of magnitude larger.
|
||||
type Upload struct {
|
||||
ImageMaxBytes int64
|
||||
VideoMaxBytes int64
|
||||
}
|
||||
|
||||
// Video holds the external tooling used to prepare uploaded video for playback.
|
||||
// Availability is detected at startup; an absent toolchain disables video
|
||||
// ingestion without failing the server.
|
||||
type Video struct {
|
||||
FFmpegPath string
|
||||
FFprobePath string
|
||||
TranscodeConcurrency int
|
||||
TempDir string
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
Backend string
|
||||
FSRoot string
|
||||
@@ -82,6 +100,8 @@ type Config struct {
|
||||
Server Server
|
||||
SSE SSE
|
||||
Worker Worker
|
||||
Upload Upload
|
||||
Video Video
|
||||
Log Log
|
||||
}
|
||||
|
||||
@@ -151,6 +171,19 @@ func Load() (*Config, error) {
|
||||
return nil, fmt.Errorf("invalid WORKER_CONCURRENCY: %w", err)
|
||||
}
|
||||
|
||||
imageMaxUpload, err := getSizeEnv("IMAGE_MAX_UPLOAD_SIZE", 100<<20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
videoMaxUpload, err := getSizeEnv("VIDEO_MAX_UPLOAD_SIZE", 2<<30)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transcodeConcurrency, err := strconv.Atoi(getEnv("VIDEO_TRANSCODE_CONCURRENCY", "1"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid VIDEO_TRANSCODE_CONCURRENCY: %w", err)
|
||||
}
|
||||
|
||||
logLevel := getEnv("LOG_LEVEL", "info")
|
||||
if _, err := logging.ParseLevel(logLevel); err != nil {
|
||||
return nil, err
|
||||
@@ -207,6 +240,16 @@ func Load() (*Config, error) {
|
||||
PendingTTL: workerPendingTTL,
|
||||
Concurrency: workerConcurrency,
|
||||
},
|
||||
Upload: Upload{
|
||||
ImageMaxBytes: imageMaxUpload,
|
||||
VideoMaxBytes: videoMaxUpload,
|
||||
},
|
||||
Video: Video{
|
||||
FFmpegPath: getEnv("FFMPEG_PATH", "ffmpeg"),
|
||||
FFprobePath: getEnv("FFPROBE_PATH", "ffprobe"),
|
||||
TranscodeConcurrency: transcodeConcurrency,
|
||||
TempDir: getEnv("VIDEO_TEMP_DIR", ""),
|
||||
},
|
||||
Log: Log{
|
||||
Level: logLevel,
|
||||
Format: logFormat,
|
||||
@@ -245,3 +288,40 @@ func getEnv(key, fallback string) string {
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// getSizeEnv reads a byte-size environment variable, accepting a plain integer
|
||||
// or a human suffix (KB/MB/GB, binary). An empty value yields the fallback.
|
||||
func getSizeEnv(key string, fallback int64) (int64, error) {
|
||||
raw := getEnv(key, "")
|
||||
if raw == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
n, err := parseSize(raw)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid %s: %w", key, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func parseSize(s string) (int64, error) {
|
||||
s = strings.TrimSpace(strings.ToUpper(s))
|
||||
mult := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(s, "GB"):
|
||||
mult, s = 1<<30, strings.TrimSuffix(s, "GB")
|
||||
case strings.HasSuffix(s, "MB"):
|
||||
mult, s = 1<<20, strings.TrimSuffix(s, "MB")
|
||||
case strings.HasSuffix(s, "KB"):
|
||||
mult, s = 1<<10, strings.TrimSuffix(s, "KB")
|
||||
case strings.HasSuffix(s, "B"):
|
||||
mult, s = 1, strings.TrimSuffix(s, "B")
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if n < 0 {
|
||||
return 0, fmt.Errorf("must be non-negative")
|
||||
}
|
||||
return n * mult, nil
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user