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.
147 lines
5.1 KiB
Go
147 lines
5.1 KiB
Go
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")
|
|
}
|
|
}
|