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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user