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
+208 -16
View File
@@ -1,10 +1,17 @@
package photos
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"time"
@@ -26,10 +33,49 @@ type Service struct {
albums AlbumAccess
storage storage.Backend
hub *sse.Hub
imageMax int64
videoMax int64
videoEnabled bool
tempDir string
}
func NewService(store Repository, albums AlbumAccess, storage storage.Backend, hub *sse.Hub) *Service {
return &Service{store: store, albums: albums, storage: storage, hub: hub}
return &Service{
store: store,
albums: albums,
storage: storage,
hub: hub,
imageMax: 100 << 20,
videoMax: 2 << 30,
videoEnabled: true,
}
}
// SetUploadLimits overrides the per-kind upload size limits.
func (s *Service) SetUploadLimits(imageMax, videoMax int64) {
if imageMax > 0 {
s.imageMax = imageMax
}
if videoMax > 0 {
s.videoMax = videoMax
}
}
// SetVideoEnabled controls whether video uploads are accepted. It is false when
// the ffmpeg/ffprobe toolchain is unavailable.
func (s *Service) SetVideoEnabled(enabled bool) { s.videoEnabled = enabled }
// SetTempDir sets where upload spool files are written (empty = system temp).
func (s *Service) SetTempDir(dir string) { s.tempDir = dir }
// MaxUploadSize returns the largest per-file limit across media kinds, for the
// request body cap.
func (s *Service) MaxUploadSize() int64 {
if s.videoMax > s.imageMax {
return s.videoMax
}
return s.imageMax
}
type UploadInput struct {
@@ -37,7 +83,26 @@ type UploadInput struct {
UserID string
Filename string
MimeType string
Data []byte
// Reader is the upload stream. When nil, Data is used instead.
Reader io.Reader
// Size is the declared payload size when known (0 when unknown).
Size int64
// Data is an optional in-memory payload for callers that already hold it.
Data []byte
}
func (in UploadInput) reader() io.Reader {
if in.Reader != nil {
return in.Reader
}
return bytes.NewReader(in.Data)
}
func (in UploadInput) declaredSize() int64 {
if in.Size > 0 {
return in.Size
}
return int64(len(in.Data))
}
func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, error) {
@@ -49,19 +114,66 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
return nil, httpx.Forbidden("forbidden", "you do not have permission to upload photos")
}
mime := in.MimeType
if mime == "" {
mime = http.DetectContentType(in.Data)
br := bufio.NewReader(in.reader())
head, _ := br.Peek(512)
kind, mime := resolveMedia(in.MimeType, in.Filename, head)
if kind == "" {
return nil, httpx.BadRequest("invalid_file", "file is not an image or video")
}
if !strings.HasPrefix(mime, "image/") {
return nil, httpx.BadRequest("invalid_file", "file is not an image")
if kind == KindVideo && !s.videoEnabled {
return nil, httpx.BadRequest("video_disabled", "video upload is not available")
}
limit := s.imageMax
if kind == KindVideo {
limit = s.videoMax
}
if sz := in.declaredSize(); sz > limit {
return nil, uploadTooLarge(kind, limit)
}
width, height, ok := inspect(in.Data)
if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image")
// Spool to a temporary file, computing size and checksum without holding the
// whole payload in memory. LimitReader enforces the cap even when the
// declared size is unknown.
tmp, err := os.CreateTemp(s.tempDir, "upload-*")
if err != nil {
return nil, err
}
tmpName := tmp.Name()
defer func() { _ = os.Remove(tmpName) }()
hasher := sha256.New()
written, err := io.Copy(io.MultiWriter(tmp, hasher), io.LimitReader(br, limit+1))
if err != nil {
_ = tmp.Close()
return nil, err
}
if err := tmp.Close(); err != nil {
return nil, err
}
if written > limit {
return nil, uploadTooLarge(kind, limit)
}
var width, height int
var durationMS int64
attrs := map[string]any{}
var meta exifInfo
if kind == KindImage {
data, err := os.ReadFile(tmpName)
if err != nil {
return nil, err
}
w, h, ok := inspect(data)
if !ok {
return nil, httpx.BadRequest("invalid_image", "could not decode image")
}
width, height = w, h
meta = extractEXIF(data)
attrs = meta.Attributes
if attrs == nil {
attrs = map[string]any{}
}
}
meta := extractEXIF(in.Data)
ext := strings.ToLower(filepath.Ext(in.Filename))
if ext == "" {
@@ -78,23 +190,25 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
UploaderID: in.UserID,
StorageKey: key,
OriginalFilename: in.Filename,
Size: int64(len(in.Data)),
Size: written,
Width: width,
Height: height,
Kind: kind,
DurationMS: durationMS,
MimeType: mime,
TakenAt: meta.TakenAt,
Latitude: meta.Latitude,
Longitude: meta.Longitude,
EXIF: meta.Attributes,
EXIF: attrs,
}
obj := &Object{
ID: objectID,
StorageKey: key,
MimeType: mime,
Size: int64(len(in.Data)),
Size: written,
Width: width,
Height: height,
Checksum: checksum(in.Data),
Checksum: hex.EncodeToString(hasher.Sum(nil)),
}
// The metadata layer is the ledger of record: the row is written first,
@@ -102,10 +216,17 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
if err := s.store.CreateWithOriginal(ctx, p, obj); err != nil {
return nil, err
}
if err := s.storage.Put(ctx, key, bytes.NewReader(in.Data), int64(len(in.Data)), mime); err != nil {
file, err := os.Open(tmpName)
if err != nil {
_ = s.store.DeleteUpload(ctx, photoID, objectID)
return nil, err
}
putErr := s.storage.Put(ctx, key, file, written, mime)
file.Close()
if putErr != nil {
_ = s.store.DeleteUpload(ctx, photoID, objectID)
return nil, putErr
}
if err := s.store.ActivateObject(ctx, objectID); err != nil {
return nil, err
}
@@ -119,6 +240,72 @@ func (s *Service) Upload(ctx context.Context, in UploadInput) (*models.Photo, er
return p, nil
}
// resolveMedia determines the media kind and effective MIME type of an upload.
// It prefers the declared MIME type, falls back to sniffing the leading bytes,
// then to the file extension.
func resolveMedia(declared, filename string, head []byte) (kind, outMime string) {
kind = kindFor(declared, filename)
if kind == "" && len(head) > 0 {
detected := http.DetectContentType(head)
kind = kindFor(detected, filename)
if declared == "" {
declared = detected
}
}
if kind == "" {
return "", ""
}
outMime = declared
if !strings.HasPrefix(outMime, kind+"/") {
if byExt := mime.TypeByExtension(filepath.Ext(filename)); byExt != "" {
outMime = byExt
}
}
if outMime == "" || !strings.HasPrefix(outMime, kind+"/") {
if kind == KindVideo {
outMime = "video/mp4"
} else {
outMime = "image/jpeg"
}
}
return kind, outMime
}
// kindFor classifies a MIME type or file extension as image or video.
func kindFor(mimeType, filename string) string {
switch {
case strings.HasPrefix(mimeType, "image/"):
return KindImage
case strings.HasPrefix(mimeType, "video/"):
return KindVideo
}
switch strings.ToLower(filepath.Ext(filename)) {
case ".mp4", ".m4v", ".mov", ".mkv", ".webm", ".avi", ".mpg", ".mpeg", ".3gp":
return KindVideo
case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic", ".heif", ".tiff", ".tif", ".bmp", ".avif":
return KindImage
}
return ""
}
func uploadTooLarge(kind string, limit int64) error {
return httpx.NewError(http.StatusRequestEntityTooLarge, "file_too_large",
fmt.Sprintf("file exceeds the maximum %s upload size (%s)", kind, humanSize(limit)))
}
func humanSize(n int64) string {
switch {
case n >= 1<<30:
return fmt.Sprintf("%d GB", n>>30)
case n >= 1<<20:
return fmt.Sprintf("%d MB", n>>20)
case n >= 1<<10:
return fmt.Sprintf("%d KB", n>>10)
default:
return fmt.Sprintf("%d B", n)
}
}
func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
if p.StorageKey != "" {
url, err := s.storage.URL(ctx, p.StorageKey)
@@ -143,6 +330,11 @@ func (s *Service) attachURLs(ctx context.Context, p *models.Photo) error {
p.PreviewURL = url
}
}
if p.PlaybackKey != "" {
if url, err := s.storage.URL(ctx, p.PlaybackKey); err == nil {
p.PlaybackURL = url
}
}
return nil
}