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
+5 -12
View File
@@ -2,7 +2,6 @@ package photos
import (
"errors"
"io"
"net/http"
"strconv"
"time"
@@ -13,8 +12,6 @@ import (
"galo/internal/httpx"
)
const maxUploadSize = 100 << 20
type Handler struct {
svc *Service
}
@@ -41,12 +38,12 @@ func (h *Handler) List(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()
if err != nil {
var maxErr *http.MaxBytesError
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
}
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)
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{
AlbumID: c.Param("id"),
UserID: auth.UserID(c),
Filename: fh.Filename,
MimeType: fh.Header.Get("Content-Type"),
Data: data,
Reader: f,
Size: fh.Size,
})
f.Close()
if err != nil {
httpx.RespondError(c, err)
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"
RoleMedium = "medium"
RolePreview = "preview"
RolePlayback = "playback"
)
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
// lease pushes next_attempt_at forward so the same job is not picked up twice
// while it is being processed.
func (s *Store) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
// while it is being processed. A non-empty kind restricts claims to that media
// 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 {
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
JOIN photos p ON p.id = vj.photo_id
WHERE vj.next_attempt_at <= now()
AND ($2 = '' OR vj.kind = $2)
ORDER BY (vj.attempts > 0) ASC,
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
vj.next_attempt_at ASC,
vj.photo_id DESC
LIMIT $1
FOR UPDATE OF vj SKIP LOCKED`, limit)
FOR UPDATE OF vj SKIP LOCKED`, limit, kind)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -10,9 +10,9 @@ import (
"strings"
"time"
"github.com/disintegration/imaging"
exif "github.com/dsoprea/go-exif/v3"
exifcommon "github.com/dsoprea/go-exif/v3/common"
"github.com/disintegration/imaging"
"github.com/gen2brain/webp"
)
+5 -1
View File
@@ -44,9 +44,13 @@ type Repository interface {
PhotosByOriginalObject(ctx context.Context, originalObjectID string) ([]string, error)
ClaimGarbage(ctx context.Context, 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
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(ctx context.Context) ([]string, error)
+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
}
+5 -3
View File
@@ -226,8 +226,9 @@ func (s *SQLiteStore) ClaimStalePending(ctx context.Context, olderThan time.Dura
return objs, nil
}
// ClaimVariantJobs leases due variant jobs and returns their photo ids.
func (s *SQLiteStore) ClaimVariantJobs(ctx context.Context, limit int, lease time.Duration) ([]string, error) {
// ClaimVariantJobs leases due variant jobs and returns their photo ids. A
// 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 {
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
JOIN photos p ON p.id = vj.photo_id
WHERE vj.next_attempt_at <= $1
AND ($3 = '' OR vj.kind = $3)
ORDER BY (vj.attempts > 0) ASC,
CASE WHEN vj.attempts = 0 THEN p.created_at END DESC NULLS LAST,
vj.next_attempt_at ASC,
vj.photo_id DESC
LIMIT $2`, now, limit)
LIMIT $2`, now, limit, kind)
if err != nil {
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_m.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.kind, COALESCE(p.duration_ms, 0),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(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 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 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) {
var p models.Photo
@@ -53,8 +57,9 @@ func scanSQLitePhoto(scan func(...any) error) (*models.Photo, error) {
var lat, long sql.NullFloat64
var liked int
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.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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
if err := tx.QueryRowContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
`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, $10, $11)
RETURNING created_at`,
p.ID, nullIfEmpty(p.AlbumID), p.UploaderID, p.OriginalFilename,
nullTime(p.TakenAt), nullFloat(p.Latitude), nullFloat(p.Longitude), p.Caption, string(exifJSON),
mediaKind(p.Kind), p.DurationMS,
).Scan(&createdAt); err != nil {
return err
}
@@ -109,7 +115,8 @@ func (s *SQLiteStore) CreateWithOriginal(ctx context.Context, p *models.Photo, o
return err
}
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 tx.Commit()
@@ -221,10 +228,11 @@ func (s *SQLiteStore) Clone(ctx context.Context, src *models.Photo, albumID, upl
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
`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, $10, $11)`,
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
}
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 liked int
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.Kind, &p.DurationMS,
&takenAt, &lat, &long, &p.Caption, &createdAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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
}
// 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
// of every photo with empty exif.
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_m.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.kind, COALESCE(p.duration_ms, 0),
p.taken_at, p.latitude, p.longitude, COALESCE(p.caption, ''), p.created_at,
u.id, u.name, COALESCE(u.avatar_url, ''),
(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 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 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) {
var p models.Photo
var exifText, tagsText string
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.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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
}
if err := tx.QueryRow(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`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, $10, $11)
RETURNING created_at`,
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 {
return err
}
@@ -127,7 +133,8 @@ func (s *Store) CreateWithOriginal(ctx context.Context, p *models.Photo, obj *Ob
return err
}
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 tx.Commit(ctx)
@@ -189,8 +196,9 @@ func (s *Store) List(ctx context.Context, in ListParams) ([]models.Photo, error)
var p models.Photo
var exifText, tagsText string
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.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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) }()
if _, err := tx.Exec(ctx,
`INSERT INTO photos (id, album_id, uploader_id, original_filename, taken_at, latitude, longitude, caption, exif)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)`,
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON)); err != nil {
`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, $10, $11)`,
id, albumID, uploaderID, src.OriginalFilename, src.TakenAt, src.Latitude, src.Longitude, src.Caption, string(exifJSON),
mediaKind(src.Kind), src.DurationMS); err != nil {
return err
}
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 exifText, tagsText string
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.Kind, &p.DurationMS,
&p.TakenAt, &p.Latitude, &p.Longitude, &p.Caption, &p.CreatedAt,
&p.Uploader.ID, &p.Uploader.Name, &p.Uploader.AvatarURL,
&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
}
// 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 {
ID 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 (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log/slog"
"os"
"sync"
"time"
"galo/internal/auth"
"galo/internal/video"
)
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
// and the object garbage collector entirely from the state recorded in the
// metadata layer, so it resumes correctly after a restart.
@@ -23,6 +36,11 @@ type Worker struct {
pendingTTL time.Duration
lease time.Duration
concurrency int
tools *video.Toolchain
videoConc int
videoLease time.Duration
tempDir string
}
// 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,
lease: interval * 4,
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.
func (w *Worker) Run(ctx context.Context) {
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) {
w.processVariantJobs(ctx)
w.processVideoJobs(ctx)
w.collectGarbage(ctx)
w.reapStalePending(ctx)
}
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 {
slog.Error("claim variant jobs", "error", err)
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
// flight. Generation is CPU-bound (decode + resize + encode), so parallelism
// 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
// the original, so copies reuse the same physical variant.
// createVariant stores one derived image object and binds it to every photo
// sharing the original, so copies reuse the same physical variant.
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()
key := objectKey(objID, role, ".webp")
key := objectKey(objID, role, ext)
obj := &Object{
ID: objID,
StorageKey: key,
MimeType: "image/webp",
Size: int64(len(data)),
Checksum: checksum(data),
MimeType: mimeType,
Size: size,
Checksum: sum,
}
if err := w.svc.store.CreateObject(ctx, obj); err != nil {
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)
return err
}
@@ -191,6 +256,193 @@ func (w *Worker) createVariant(ctx context.Context, photoIDs []string, role stri
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) {
p, err := w.svc.store.GetByID(ctx, photoID, "")
if err != nil {
+24
View File
@@ -7,7 +7,9 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"galo/internal/config"
"galo/internal/storage"
"galo/internal/video"
)
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 {
t.Helper()
var n int