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:
@@ -122,6 +122,22 @@ func (f *FS) Get(_ context.Context, key string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Open streams a blob from disk for processing without buffering it fully.
|
||||
func (f *FS) Open(_ context.Context, key string) (io.ReadCloser, error) {
|
||||
p, err := f.objectPath(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Open(p)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func (f *FS) Delete(_ context.Context, keys ...string) error {
|
||||
var errs []error
|
||||
for _, key := range keys {
|
||||
|
||||
@@ -51,6 +51,29 @@ func TestFSRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSOpen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newTestFS(t)
|
||||
key := "objects/abc/original.mp4"
|
||||
data := []byte("streamed video bytes")
|
||||
|
||||
if err := fs.Put(ctx, key, bytes.NewReader(data), int64(len(data)), "video/mp4"); err != nil {
|
||||
t.Fatalf("put: %v", err)
|
||||
}
|
||||
rc, err := fs.Open(ctx, key)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
got, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil || !bytes.Equal(got, data) {
|
||||
t.Fatalf("open read = %q err=%v, want %q", got, err, data)
|
||||
}
|
||||
if _, err := fs.Open(ctx, "objects/missing/original.mp4"); !errors.Is(err, ErrObjectNotFound) {
|
||||
t.Fatalf("open missing err = %v, want ErrObjectNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFSPathTraversalRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fs := newTestFS(t)
|
||||
|
||||
@@ -44,6 +44,9 @@ type Backend interface {
|
||||
Put(ctx context.Context, key string, body io.Reader, size int64, contentType string) error
|
||||
Delete(ctx context.Context, keys ...string) error
|
||||
Get(ctx context.Context, key string) ([]byte, error)
|
||||
// Open returns a streaming reader over a blob, for processing objects too
|
||||
// large to buffer in memory (for example video). The caller closes it.
|
||||
Open(ctx context.Context, key string) (io.ReadCloser, error)
|
||||
// URL returns a time-limited signed app URL the client can use to fetch the blob.
|
||||
URL(ctx context.Context, key string) (string, error)
|
||||
// ServeObject validates the signed URL and writes the blob, including
|
||||
@@ -191,6 +194,21 @@ func (c *S3) Get(ctx context.Context, key string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// Open streams a blob from S3 for processing without buffering it fully.
|
||||
func (c *S3) Open(ctx context.Context, key string) (io.ReadCloser, error) {
|
||||
out, err := c.s3.GetObject(ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(c.bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if isNoSuchKey(err) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("open object %s: %w", key, err)
|
||||
}
|
||||
return out.Body, nil
|
||||
}
|
||||
|
||||
// URL returns a signed app URL. The blob itself is streamed by ServeObject, so
|
||||
// the S3 endpoint is never exposed to the client.
|
||||
func (c *S3) URL(_ context.Context, key string) (string, error) {
|
||||
|
||||
Reference in New Issue
Block a user