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