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:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
+57 -7
View File
@@ -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" {