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:
@@ -49,6 +49,7 @@ func Run(t *testing.T, r Repos) {
|
||||
{"Social", testSocial},
|
||||
{"Photos", testPhotos},
|
||||
{"Objects", testObjects},
|
||||
{"VariantJobKinds", testVariantJobKinds},
|
||||
{"Settings", testSettings},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
@@ -94,6 +95,19 @@ func seedPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID s
|
||||
return p
|
||||
}
|
||||
|
||||
func seedVideoPhoto(t *testing.T, ctx context.Context, r Repos, uploaderID, albumID string) *models.Photo {
|
||||
t.Helper()
|
||||
obj := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), State: photos.ObjectPending}
|
||||
p := &models.Photo{ID: newID(), AlbumID: albumID, UploaderID: uploaderID, OriginalFilename: "v.mp4", Kind: photos.KindVideo}
|
||||
if err := r.Photos.CreateWithOriginal(ctx, p, obj); err != nil {
|
||||
t.Fatalf("create video photo: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, obj.ID); err != nil {
|
||||
t.Fatalf("activate object: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func testAuth(t *testing.T, ctx context.Context, r Repos) {
|
||||
u := seedUser(t, ctx, r, "Tester")
|
||||
|
||||
@@ -498,6 +512,19 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("bind medium: %v", err)
|
||||
}
|
||||
|
||||
// The playback role is a first-class binding introduced for video, so it
|
||||
// must be accepted by the role constraint on every backend.
|
||||
playback := &photos.Object{ID: newID(), StorageKey: "objects/" + newID(), MimeType: "video/mp4", State: photos.ObjectPending}
|
||||
if err := r.Photos.CreateObject(ctx, playback); err != nil {
|
||||
t.Fatalf("create playback object: %v", err)
|
||||
}
|
||||
if err := r.Photos.ActivateObject(ctx, playback.ID); err != nil {
|
||||
t.Fatalf("activate playback: %v", err)
|
||||
}
|
||||
if err := r.Photos.BindObject(ctx, p.ID, playback.ID, photos.RolePlayback); err != nil {
|
||||
t.Fatalf("bind playback: %v", err)
|
||||
}
|
||||
|
||||
// After the photo is gone, its objects have no bindings and are collectible.
|
||||
if err := r.Photos.HardDelete(ctx, p.ID); err != nil {
|
||||
t.Fatalf("hard delete: %v", err)
|
||||
@@ -510,8 +537,8 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
for _, o := range claimed {
|
||||
got[o.ID] = true
|
||||
}
|
||||
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] {
|
||||
t.Fatalf("claimed = %+v, want original, thumb and medium", got)
|
||||
if !got[orig.ID] || !got[thumb.ID] || !got[medium.ID] || !got[playback.ID] {
|
||||
t.Fatalf("claimed = %+v, want original, thumb, medium and playback", got)
|
||||
}
|
||||
for _, o := range claimed {
|
||||
if err := r.Photos.DeleteObjectRow(ctx, o.ID); err != nil {
|
||||
@@ -536,7 +563,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
|
||||
// Variant queue: a fresh photo has one job.
|
||||
p2 := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
jobs, err := r.Photos.ClaimVariantJobs(ctx, 10, time.Minute)
|
||||
jobs, err := r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("claim jobs: %v", err)
|
||||
}
|
||||
@@ -546,7 +573,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
if err := r.Photos.CompleteVariantJob(ctx, p2.ID); err != nil {
|
||||
t.Fatalf("complete job: %v", err)
|
||||
}
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p2.ID) {
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p2.ID) {
|
||||
t.Fatalf("completed job still claimed: %+v", jobs)
|
||||
}
|
||||
|
||||
@@ -555,7 +582,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
if err := r.Photos.FailVariantJob(ctx, p3.ID, "boom", 3); err != nil {
|
||||
t.Fatalf("fail job: %v", err)
|
||||
}
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) {
|
||||
t.Fatalf("failed job retried immediately: %+v", jobs)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
@@ -563,7 +590,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
t.Fatalf("fail job again: %v", err)
|
||||
}
|
||||
}
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, 10, time.Minute); contains(jobs, p3.ID) {
|
||||
if jobs, _ = r.Photos.ClaimVariantJobs(ctx, "", 10, time.Minute); contains(jobs, p3.ID) {
|
||||
t.Fatalf("abandoned job still present: %+v", jobs)
|
||||
}
|
||||
|
||||
@@ -572,7 +599,7 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
pOlder := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
pNewer := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
order, err := r.Photos.ClaimVariantJobs(ctx, 2, time.Minute)
|
||||
order, err := r.Photos.ClaimVariantJobs(ctx, "", 2, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("claim ordered jobs: %v", err)
|
||||
}
|
||||
@@ -581,6 +608,29 @@ func testObjects(t *testing.T, ctx context.Context, r Repos) {
|
||||
}
|
||||
}
|
||||
|
||||
func testVariantJobKinds(t *testing.T, ctx context.Context, r Repos) {
|
||||
owner := seedUser(t, ctx, r, "Owner")
|
||||
a := seedAlbum(t, ctx, r, owner.ID)
|
||||
img := seedPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
vid := seedVideoPhoto(t, ctx, r, owner.ID, a.ID)
|
||||
|
||||
imageJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindImage, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("claim image jobs: %v", err)
|
||||
}
|
||||
if !contains(imageJobs, img.ID) || contains(imageJobs, vid.ID) {
|
||||
t.Fatalf("image claim = %+v, want only %s", imageJobs, img.ID)
|
||||
}
|
||||
|
||||
videoJobs, err := r.Photos.ClaimVariantJobs(ctx, photos.KindVideo, 10, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("claim video jobs: %v", err)
|
||||
}
|
||||
if !contains(videoJobs, vid.ID) || contains(videoJobs, img.ID) {
|
||||
t.Fatalf("video claim = %+v, want only %s", videoJobs, vid.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func testSettings(t *testing.T, ctx context.Context, r Repos) {
|
||||
v, err := r.Settings.Ensure(ctx, settings.JWTSecretKey, func() (string, error) { return "generated-1", nil })
|
||||
if err != nil || v != "generated-1" {
|
||||
|
||||
Reference in New Issue
Block a user