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,142 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galo/internal/albums"
|
||||
"galo/internal/config"
|
||||
"galo/internal/photos"
|
||||
"galo/internal/video"
|
||||
)
|
||||
|
||||
// makeMP4 generates a tiny h264/yuv420p clip so the web-compatible remux path
|
||||
// can be exercised. It skips when ffmpeg is unavailable.
|
||||
func makeMP4(t *testing.T, ffmpeg string) []byte {
|
||||
t.Helper()
|
||||
out := filepath.Join(t.TempDir(), "clip.mp4")
|
||||
cmd := exec.Command(ffmpeg,
|
||||
"-v", "error", "-y",
|
||||
"-f", "lavfi", "-i", "testsrc=size=160x120:rate=10",
|
||||
"-t", "1",
|
||||
"-pix_fmt", "yuv420p", "-c:v", "libx264", "-preset", "ultrafast",
|
||||
out)
|
||||
if b, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Skipf("ffmpeg fixture failed: %v: %s", err, b)
|
||||
}
|
||||
data, err := os.ReadFile(out)
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture: %v", err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestVideoUploadPreparedForPlayback(t *testing.T) {
|
||||
ffmpeg, err := exec.LookPath("ffmpeg")
|
||||
if err != nil {
|
||||
t.Skip("ffmpeg not available")
|
||||
}
|
||||
ffprobe, err := exec.LookPath("ffprobe")
|
||||
if err != nil {
|
||||
t.Skip("ffprobe not available")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "galo.db")
|
||||
st, closeDB := openStack(t, path, t.TempDir())
|
||||
defer closeDB()
|
||||
|
||||
user, _, err := st.auth.Register(ctx, "video@test.local", "password123", "V")
|
||||
if err != nil {
|
||||
t.Fatalf("register: %v", err)
|
||||
}
|
||||
album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Video"})
|
||||
if err != nil {
|
||||
t.Fatalf("create album: %v", err)
|
||||
}
|
||||
|
||||
p, err := st.photos.Upload(ctx, photos.UploadInput{
|
||||
AlbumID: album.ID,
|
||||
UserID: user.ID,
|
||||
Filename: "clip.mp4",
|
||||
MimeType: "video/mp4",
|
||||
Data: makeMP4(t, ffmpeg),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("upload video: %v", err)
|
||||
}
|
||||
if p.Kind != photos.KindVideo {
|
||||
t.Fatalf("kind = %q, want video", p.Kind)
|
||||
}
|
||||
if p.PlaybackURL != "" {
|
||||
t.Fatalf("playback url should be empty before processing")
|
||||
}
|
||||
|
||||
tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe})
|
||||
w := photos.NewWorker(st.photos, time.Second, 10, time.Minute, 1)
|
||||
w.SetVideo(tools, 1)
|
||||
w.ProcessOnce(ctx)
|
||||
|
||||
got, err := st.photos.Get(ctx, p.ID, user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if got.PlaybackURL == "" {
|
||||
t.Fatalf("playback url not set after processing")
|
||||
}
|
||||
if got.ThumbURL == "" {
|
||||
t.Fatalf("poster variants not generated")
|
||||
}
|
||||
if got.DurationMS <= 0 {
|
||||
t.Fatalf("duration not probed: %d", got.DurationMS)
|
||||
}
|
||||
if got.Width != 160 || got.Height != 120 {
|
||||
t.Fatalf("dimensions = %dx%d, want 160x120", got.Width, got.Height)
|
||||
}
|
||||
|
||||
// Public access (share-link path) exposes the same signed playback URL.
|
||||
pub, err := st.photos.GetPublic(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get public: %v", err)
|
||||
}
|
||||
if pub.PlaybackURL == "" {
|
||||
t.Fatalf("public playback url not set")
|
||||
}
|
||||
|
||||
// Permanently deleting the video collects every object, including playback.
|
||||
rows, err := st.db.QueryContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = ?`, p.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("object ids: %v", err)
|
||||
}
|
||||
var objectIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
t.Fatalf("scan object id: %v", err)
|
||||
}
|
||||
objectIDs = append(objectIDs, id)
|
||||
}
|
||||
rows.Close()
|
||||
if len(objectIDs) < 5 {
|
||||
t.Fatalf("bound objects = %d, want original+thumb+medium+preview+playback", len(objectIDs))
|
||||
}
|
||||
|
||||
if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil {
|
||||
t.Fatalf("permanent delete: %v", err)
|
||||
}
|
||||
w.ProcessOnce(ctx)
|
||||
|
||||
for _, id := range objectIDs {
|
||||
var n int
|
||||
if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM objects WHERE id = ?`, id).Scan(&n); err != nil {
|
||||
t.Fatalf("count object: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("object %s not collected after delete", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user