Files
ds-galo/backend/internal/photos/video_worker_test.go
iu d38ede0201 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.
2026-10-11 20:37:53 +03:00

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")
}
}