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
+80
View File
@@ -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
}