diff --git a/backend/libs/handlers/app.go b/backend/libs/handlers/app.go index de3b6ae..598cad0 100644 --- a/backend/libs/handlers/app.go +++ b/backend/libs/handlers/app.go @@ -134,6 +134,7 @@ func (a *App) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /d/{boxID}/f/{fileID}/download", a.DownloadFileContent) mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage) mux.HandleFunc("GET /d/{boxID}/thumb/{fileID}", a.Thumbnail) + mux.HandleFunc("GET /d/{boxID}/scene/{fileID}", a.VideoScenesPreview) mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage) mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /sitemap.xml", a.SitemapXML) diff --git a/backend/libs/handlers/download.go b/backend/libs/handlers/download.go index aaa57f9..8d5db7f 100644 --- a/backend/libs/handlers/download.go +++ b/backend/libs/handlers/download.go @@ -47,7 +47,9 @@ type fileView struct { URL string DownloadURL string ThumbnailURL string + SceneURL string HasThumbnail bool + HasScene bool IconURL string IconRetroURL string ReactURL string @@ -345,6 +347,43 @@ func (a *App) Thumbnail(w http.ResponseWriter, r *http.Request) { http.ServeContent(w, r, file.ID+"-thumbnail.jpg", object.ModTime, readSeekCloser(object.Body)) } +func (a *App) VideoScenesPreview(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Robots-Tag", "noindex, nofollow, noarchive") + box, file, ok := a.loadFileForRequest(w, r) + if !ok { + return + } + if !jobs.NeedsVideoScenes(file) { + http.NotFound(w, r) + return + } + if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) { + a.servePlaceholderThumbnail(w, r) + return + } + + object, err := a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file) + if err != nil { + if scene := a.generateMissingVideoScenesForRequest(r, box, file); scene != "" { + file.SceneThumbnail = scene + object, err = a.uploadService.OpenSceneThumbnailObject(r.Context(), box, file) + if err == nil { + defer object.Body.Close() + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body)) + return + } + } + a.servePlaceholderThumbnail(w, r) + return + } + defer object.Body.Close() + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Cache-Control", "public, max-age=604800, immutable") + http.ServeContent(w, r, file.ID+"-scenes.jpg", object.ModTime, readSeekCloser(object.Body)) +} + func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string { if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { return "" @@ -369,6 +408,30 @@ func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.B return thumbnail } +func (a *App) generateMissingVideoScenesForRequest(r *http.Request, box services.Box, file services.File) string { + if file.SceneThumbnail != "" || !jobs.NeedsVideoScenes(file) { + return "" + } + scene, err := jobs.GenerateVideoScenesForFile(a.uploadService, box, file) + if err != nil || scene == "" { + if err != nil { + a.logger.Warn("on-demand video scenes preview generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4105, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...) + } + return "" + } + for i := range box.Files { + if box.Files[i].ID == file.ID { + box.Files[i].SceneThumbnail = scene + break + } + } + if err := a.uploadService.SaveBox(box); err != nil { + a.logger.Warn("on-demand video scenes preview metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4106, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...) + return "" + } + return scene +} + // servePlaceholderThumbnail serves the fallback image with no-store so the // browser re-requests on the next load and picks up the real thumbnail as soon // as it has been generated. @@ -513,7 +576,9 @@ func (a *App) fileViewWithReactions(box services.Box, file services.File, reacti URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID), DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", box.ID, file.ID), ThumbnailURL: fmt.Sprintf("/d/%s/thumb/%s", box.ID, file.ID), + SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID), HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file), + HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file), IconURL: fileIconURL("standard", icon.Standard), IconRetroURL: fileIconURL("retro", icon.Retro), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), diff --git a/backend/libs/handlers/meta.go b/backend/libs/handlers/meta.go index d31e82e..f78139c 100644 --- a/backend/libs/handlers/meta.go +++ b/backend/libs/handlers/meta.go @@ -23,6 +23,7 @@ Disallow: /account/ Disallow: /d/*/f/*/download Disallow: /d/*/zip Disallow: /d/*/thumb/ +Disallow: /d/*/scene/ Disallow: /d/*/og-image.jpg Disallow: /d/*/unlock Disallow: /d/*/manage/ diff --git a/backend/libs/jobs/thumbnails.go b/backend/libs/jobs/thumbnails.go index 0da9021..a875947 100644 --- a/backend/libs/jobs/thumbnails.go +++ b/backend/libs/jobs/thumbnails.go @@ -3,6 +3,7 @@ package jobs import ( "bytes" "context" + "fmt" "html" "image" "image/color" @@ -16,6 +17,7 @@ import ( "os/exec" "path/filepath" "regexp" + "strconv" "strings" "time" @@ -108,25 +110,40 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg changed := false for i := range box.Files { file := &box.Files[i] - if file.Thumbnail != "" || !needsThumbnail(*file) { + needsPrimary := file.Thumbnail == "" && needsThumbnail(*file) + needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file) + if !needsPrimary && !needsScenes { continue } result.Scanned++ - thumbnail, err := generateThumbnail(uploadService, box, *file) - if err != nil { - logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) - result.Failed++ - continue - } - if thumbnail == "" { - result.Failed++ - continue + if needsPrimary { + thumbnail, err := generateThumbnail(uploadService, box, *file) + if err != nil { + logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) + result.Failed++ + } else if thumbnail == "" { + result.Failed++ + } else { + file.Thumbnail = thumbnail + changed = true + result.Generated++ + } } - file.Thumbnail = thumbnail - changed = true - result.Generated++ + if needsScenes { + sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file) + if err != nil { + logger.Warn("video scenes preview generation failed", "source", "thumbnail", "severity", "warn", "code", 4104, "file_id", file.ID, "error", err.Error()) + result.Failed++ + } else if sceneThumbnail == "" { + result.Failed++ + } else { + file.SceneThumbnail = sceneThumbnail + changed = true + result.Generated++ + } + } } if changed { @@ -141,14 +158,26 @@ func needsThumbnail(file services.File) bool { return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file) } +func needsVideoScenes(file services.File) bool { + return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/") +} + func NeedsThumbnail(file services.File) bool { return needsThumbnail(file) } +func NeedsVideoScenes(file services.File) bool { + return needsVideoScenes(file) +} + func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { return generateThumbnail(uploadService, box, file) } +func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { + return generateVideoScenesThumbnail(uploadService, box, file) +} + func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { thumbnailName := "@thumb@" + file.ID + ".jpg" object, err := uploadService.OpenFileObject(context.Background(), box, file) @@ -184,6 +213,25 @@ func generateThumbnail(uploadService *services.UploadService, box services.Box, } } +func generateVideoScenesThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) { + if !needsVideoScenes(file) { + return "", nil + } + sceneName := "@scene@" + file.ID + ".jpg" + object, err := uploadService.OpenFileObject(context.Background(), box, file) + if err != nil { + return "", err + } + defer object.Body.Close() + + data, err := createVideoScenesThumbnail(file, object.Body) + if err != nil { + return "", err + } + _, err = uploadService.PutThumbnailObject(context.Background(), box, sceneName, bytes.NewReader(data), int64(len(data)), "image/jpeg") + return sceneName, err +} + func isTextThumbnailCandidate(file services.File) bool { contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) if i := strings.IndexByte(contentType, ';'); i >= 0 { @@ -233,17 +281,320 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) { if err := sourceFile.Close(); err != nil { return nil, err } - targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg") + sourcePath := sourceFile.Name() + candidates := []string{"00:00:01", "00:00:03", "00:00:06"} + var fallback []byte + for _, timestamp := range candidates { + targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg") + if err != nil { + return nil, err + } + targetPath := targetFile.Name() + targetFile.Close() + if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil { + os.Remove(targetPath) + continue + } + data, err := os.ReadFile(targetPath) + os.Remove(targetPath) + if err != nil { + continue + } + if len(fallback) == 0 { + fallback = data + } + if usableVideoFrame(data) { + return data, nil + } + } + + scenes, err := createVideoScenesThumbnailFromPath(services.File{Name: "video", ContentType: "video"}, sourcePath) + if err == nil { + img, err := jpeg.Decode(bytes.NewReader(scenes)) + if err == nil { + thumb := resizeNearest(img, 360, 240) + var target bytes.Buffer + if err := jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82}); err == nil { + return target.Bytes(), nil + } + } + } + if len(fallback) > 0 { + return fallback, nil + } + return nil, fmt.Errorf("could not extract a usable video thumbnail") +} + +func createVideoScenesThumbnail(file services.File, source io.Reader) ([]byte, error) { + sourceFile, err := os.CreateTemp("", "warpbox-video-*") if err != nil { return nil, err } - targetPath := targetFile.Name() - targetFile.Close() - defer os.Remove(targetPath) - if err := exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", "00:00:01", "-i", sourceFile.Name(), "-frames:v", "1", "-vf", "scale=360:-1", targetPath).Run(); err != nil { + defer os.Remove(sourceFile.Name()) + if _, err := io.Copy(sourceFile, source); err != nil { + sourceFile.Close() return nil, err } - return os.ReadFile(targetPath) + if err := sourceFile.Close(); err != nil { + return nil, err + } + return createVideoScenesThumbnailFromPath(file, sourceFile.Name()) +} + +func createVideoScenesThumbnailFromPath(file services.File, sourcePath string) ([]byte, error) { + info := probeVideoInfo(sourcePath, file) + timestamps := videoSceneTimestamps(info.Duration) + frames := make([]videoSceneFrame, 0, len(timestamps)) + + for _, timestamp := range timestamps { + targetFile, err := os.CreateTemp("", "warpbox-scene-*.jpg") + if err != nil { + continue + } + targetPath := targetFile.Name() + targetFile.Close() + if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=640:-1"); err != nil { + os.Remove(targetPath) + continue + } + data, err := os.ReadFile(targetPath) + os.Remove(targetPath) + if err != nil { + continue + } + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + continue + } + frames = append(frames, videoSceneFrame{Timestamp: timestamp, Image: img}) + } + + return renderVideoScenesThumbnail(file, info, frames), nil +} + +func extractVideoFrame(sourcePath, timestamp, targetPath, scaleFilter string) error { + return exec.Command("ffmpeg", "-y", "-loglevel", "error", "-ss", timestamp, "-i", sourcePath, "-frames:v", "1", "-vf", scaleFilter, targetPath).Run() +} + +type videoSceneFrame struct { + Timestamp string + Image image.Image +} + +type videoInfo struct { + Codec string + Width int + Height int + Duration float64 + FrameRate string +} + +func probeVideoInfo(sourcePath string, file services.File) videoInfo { + info := videoInfo{Codec: "unknown", FrameRate: "unknown"} + output, err := exec.Command("ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name,width,height,duration,avg_frame_rate", "-of", "default=noprint_wrappers=1", sourcePath).Output() + if err != nil { + if file.ContentType != "" { + info.Codec = file.ContentType + } + return info + } + for _, line := range strings.Split(string(output), "\n") { + key, value, ok := strings.Cut(strings.TrimSpace(line), "=") + if !ok || value == "" || value == "N/A" { + continue + } + switch key { + case "codec_name": + info.Codec = value + case "width": + info.Width, _ = strconv.Atoi(value) + case "height": + info.Height, _ = strconv.Atoi(value) + case "duration": + info.Duration, _ = strconv.ParseFloat(value, 64) + case "avg_frame_rate": + info.FrameRate = simplifyFrameRate(value) + } + } + return info +} + +func simplifyFrameRate(value string) string { + if value == "0/0" || value == "" { + return "unknown" + } + parts := strings.Split(value, "/") + if len(parts) != 2 { + return value + } + n, errN := strconv.ParseFloat(parts[0], 64) + d, errD := strconv.ParseFloat(parts[1], 64) + if errN != nil || errD != nil || d == 0 { + return value + } + return fmt.Sprintf("%.2f fps", n/d) +} + +func videoSceneTimestamps(duration float64) []string { + if duration > 4 { + points := []float64{0.12, 0.33, 0.58, 0.82} + timestamps := make([]string, 0, len(points)) + for _, point := range points { + seconds := duration * point + if seconds < 1 { + seconds = 1 + } + timestamps = append(timestamps, secondsToTimestamp(seconds)) + } + return timestamps + } + return []string{"00:00:01", "00:00:03", "00:00:06", "00:00:10"} +} + +func secondsToTimestamp(seconds float64) string { + total := int(seconds + 0.5) + hours := total / 3600 + minutes := total % 3600 / 60 + secs := total % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, secs) +} + +func usableVideoFrame(data []byte) bool { + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + return false + } + return averageLuma(img) >= 18 +} + +func averageLuma(img image.Image) float64 { + bounds := img.Bounds() + width := bounds.Dx() + height := bounds.Dy() + if width <= 0 || height <= 0 { + return 0 + } + stepX := max(1, width/80) + stepY := max(1, height/80) + var total float64 + var samples int + for y := bounds.Min.Y; y < bounds.Max.Y; y += stepY { + for x := bounds.Min.X; x < bounds.Max.X; x += stepX { + r, g, b, _ := img.At(x, y).RGBA() + total += 0.2126*float64(r>>8) + 0.7152*float64(g>>8) + 0.0722*float64(b>>8) + samples++ + } + } + if samples == 0 { + return 0 + } + return total / float64(samples) +} + +func renderVideoScenesThumbnail(file services.File, info videoInfo, frames []videoSceneFrame) []byte { + canvas := image.NewRGBA(image.Rect(0, 0, 1200, 630)) + drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x12, A: 0xff}) + drawSolid(canvas, image.Rect(0, 0, 1200, 630), color.RGBA{R: 0x10, G: 0x13, B: 0x1f, A: 0xff}) + drawSolid(canvas, image.Rect(36, 36, 1164, 594), color.RGBA{R: 0x17, G: 0x17, B: 0x22, A: 0xff}) + drawSolid(canvas, image.Rect(36, 36, 1164, 96), color.RGBA{R: 0x20, G: 0x1b, B: 0x34, A: 0xff}) + drawSolid(canvas, image.Rect(36, 96, 1164, 100), color.RGBA{R: 0x7c, G: 0x3a, B: 0xed, A: 0xff}) + + face := basicfont.Face7x13 + drawThumbText(canvas, face, "VIDEO SCENES PREVIEW", 62, 63, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}) + drawThumbText(canvas, face, trimThumbnailText(file.Name, 72), 62, 84, color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}) + + meta := videoMetaLines(file, info) + y := 122 + for _, line := range meta { + drawThumbText(canvas, face, line, 62, y, color.RGBA{R: 0xcb, G: 0xd5, B: 0xe1, A: 0xff}) + y += 20 + } + + cells := []image.Rectangle{ + image.Rect(62, 212, 586, 388), + image.Rect(614, 212, 1138, 388), + image.Rect(62, 414, 586, 566), + image.Rect(614, 414, 1138, 566), + } + for i, rect := range cells { + drawSolid(canvas, rect, color.RGBA{R: 0x0f, G: 0x17, B: 0x22, A: 0xff}) + if i < len(frames) { + drawImageCover(canvas, rect, frames[i].Image) + drawSolid(canvas, image.Rect(rect.Min.X, rect.Min.Y, rect.Min.X+88, rect.Min.Y+24), color.RGBA{R: 0x00, G: 0x00, B: 0x00, A: 0xcc}) + drawThumbText(canvas, face, frames[i].Timestamp, rect.Min.X+10, rect.Min.Y+17, color.RGBA{R: 0xff, G: 0xff, B: 0xff, A: 0xff}) + } else { + drawThumbText(canvas, face, "No frame available", rect.Min.X+18, rect.Min.Y+34, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff}) + } + } + + var target bytes.Buffer + _ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 86}) + return target.Bytes() +} + +func videoMetaLines(file services.File, info videoInfo) []string { + resolution := "unknown resolution" + if info.Width > 0 && info.Height > 0 { + resolution = fmt.Sprintf("%dx%d", info.Width, info.Height) + } + duration := "unknown duration" + if info.Duration > 0 { + duration = secondsToHumanDuration(info.Duration) + } + contentType := file.ContentType + if contentType == "" { + contentType = "video" + } + return []string{ + "Duration: " + duration + " Codec: " + info.Codec, + "Resolution: " + resolution + " Frame rate: " + info.FrameRate, + "Type: " + contentType + " Generated by Warpbox", + } +} + +func secondsToHumanDuration(seconds float64) string { + total := int(seconds + 0.5) + hours := total / 3600 + minutes := total % 3600 / 60 + secs := total % 60 + if hours > 0 { + return fmt.Sprintf("%d:%02d:%02d", hours, minutes, secs) + } + return fmt.Sprintf("%d:%02d", minutes, secs) +} + +func drawImageCover(dst *image.RGBA, rect image.Rectangle, src image.Image) { + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + dstW := rect.Dx() + dstH := rect.Dy() + if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 { + return + } + + srcRatio := float64(srcW) / float64(srcH) + dstRatio := float64(dstW) / float64(dstH) + crop := bounds + if srcRatio > dstRatio { + newW := int(float64(srcH) * dstRatio) + x0 := bounds.Min.X + (srcW-newW)/2 + crop = image.Rect(x0, bounds.Min.Y, x0+newW, bounds.Max.Y) + } else if srcRatio < dstRatio { + newH := int(float64(srcW) / dstRatio) + y0 := bounds.Min.Y + (srcH-newH)/2 + crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+newH) + } + + for y := rect.Min.Y; y < rect.Max.Y; y++ { + for x := rect.Min.X; x < rect.Max.X; x++ { + u := float64(x-rect.Min.X) / float64(dstW) + v := float64(y-rect.Min.Y) / float64(dstH) + srcX := crop.Min.X + min(crop.Dx()-1, int(u*float64(crop.Dx()))) + srcY := crop.Min.Y + min(crop.Dy()-1, int(v*float64(crop.Dy()))) + dst.Set(x, y, src.At(srcX, srcY)) + } + } } func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) { diff --git a/backend/libs/jobs/thumbnails_test.go b/backend/libs/jobs/thumbnails_test.go index 3186e45..7aaac46 100644 --- a/backend/libs/jobs/thumbnails_test.go +++ b/backend/libs/jobs/thumbnails_test.go @@ -72,6 +72,52 @@ func TestNeedsThumbnailIncludesCodeTextFiles(t *testing.T) { } } +func TestUsableVideoFrameRejectsBlackFrame(t *testing.T) { + var dark bytes.Buffer + if err := jpeg.Encode(&dark, solidTestImage(color.RGBA{A: 255}), nil); err != nil { + t.Fatalf("jpeg.Encode dark returned error: %v", err) + } + if usableVideoFrame(dark.Bytes()) { + t.Fatalf("black video frame should not be usable") + } + + var bright bytes.Buffer + if err := jpeg.Encode(&bright, solidTestImage(color.RGBA{R: 180, G: 80, B: 40, A: 255}), nil); err != nil { + t.Fatalf("jpeg.Encode bright returned error: %v", err) + } + if !usableVideoFrame(bright.Bytes()) { + t.Fatalf("bright video frame should be usable") + } +} + +func TestRenderVideoScenesThumbnailReturnsLargeJPEG(t *testing.T) { + data := renderVideoScenesThumbnail( + services.File{Name: "clip.mp4", ContentType: "video/mp4"}, + videoInfo{Codec: "h264", Width: 1920, Height: 1080, Duration: 125, FrameRate: "24.00 fps"}, + []videoSceneFrame{ + {Timestamp: "00:00:10", Image: solidTestImage(color.RGBA{R: 140, G: 40, B: 80, A: 255})}, + {Timestamp: "00:00:35", Image: solidTestImage(color.RGBA{R: 40, G: 120, B: 150, A: 255})}, + }, + ) + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("jpeg.Decode returned error: %v", err) + } + if img.Bounds().Dx() != 1200 || img.Bounds().Dy() != 630 { + t.Fatalf("scene preview size = %dx%d, want 1200x630", img.Bounds().Dx(), img.Bounds().Dy()) + } +} + +func solidTestImage(c color.Color) image.Image { + img := image.NewRGBA(image.Rect(0, 0, 32, 24)) + for y := 0; y < img.Bounds().Dy(); y++ { + for x := 0; x < img.Bounds().Dx(); x++ { + img.Set(x, y, c) + } + } + return img +} + func newThumbnailTestUploadService(t *testing.T) *services.UploadService { t.Helper() service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) diff --git a/backend/libs/services/upload.go b/backend/libs/services/upload.go index e7bce12..2f15a1a 100644 --- a/backend/libs/services/upload.go +++ b/backend/libs/services/upload.go @@ -121,18 +121,20 @@ type Box struct { } type File struct { - ID string `json:"id"` - Name string `json:"name"` - StoredName string `json:"storedName"` - Size int64 `json:"size"` - ContentType string `json:"contentType"` - PreviewKind string `json:"previewKind"` - Thumbnail string `json:"thumbnail,omitempty"` - ObjectKey string `json:"objectKey,omitempty"` - ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` - Processing bool `json:"processing,omitempty"` - ProcessingError string `json:"processingError,omitempty"` - UploadedAt time.Time `json:"uploadedAt"` + ID string `json:"id"` + Name string `json:"name"` + StoredName string `json:"storedName"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + PreviewKind string `json:"previewKind"` + Thumbnail string `json:"thumbnail,omitempty"` + SceneThumbnail string `json:"sceneThumbnail,omitempty"` + ObjectKey string `json:"objectKey,omitempty"` + ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` + SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"` + Processing bool `json:"processing,omitempty"` + ProcessingError string `json:"processingError,omitempty"` + UploadedAt time.Time `json:"uploadedAt"` } type UploadResult struct { @@ -731,6 +733,9 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) { if key := s.ThumbnailObjectKey(box, file); key != "" { _ = backend.Delete(context.Background(), key) } + if key := s.SceneThumbnailObjectKey(box, file); key != "" { + _ = backend.Delete(context.Background(), key) + } } box.Files = append(box.Files[:index], box.Files[index+1:]...) @@ -818,6 +823,16 @@ func (s *UploadService) ThumbnailObjectKey(box Box, file File) string { return boxObjectKey(box.ID, file.Thumbnail) } +func (s *UploadService) SceneThumbnailObjectKey(box Box, file File) string { + if file.SceneThumbnailObjectKey != "" { + return file.SceneThumbnailObjectKey + } + if file.SceneThumbnail == "" { + return "" + } + return boxObjectKey(box.ID, file.SceneThumbnail) +} + func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) { if file.Processing { return StorageObject{}, fmt.Errorf("file is still processing") @@ -841,6 +856,18 @@ func (s *UploadService) OpenThumbnailObject(ctx context.Context, box Box, file F return backend.Get(ctx, key) } +func (s *UploadService) OpenSceneThumbnailObject(ctx context.Context, box Box, file File) (StorageObject, error) { + key := s.SceneThumbnailObjectKey(box, file) + if key == "" { + return StorageObject{}, os.ErrNotExist + } + backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) + if err != nil { + return StorageObject{}, err + } + return backend.Get(ctx, key) +} + func (s *UploadService) PutThumbnailObject(ctx context.Context, box Box, name string, body io.Reader, size int64, contentType string) (string, error) { backend, err := s.storage.Backend(s.BoxStorageBackendID(box)) if err != nil { diff --git a/backend/static/css/30-download.css b/backend/static/css/30-download.css index fc2db40..ffd91d1 100644 --- a/backend/static/css/30-download.css +++ b/backend/static/css/30-download.css @@ -260,6 +260,11 @@ height: clamp(18rem, 64vh, 38rem); } +.video-scenes-preview { + object-fit: contain; + background: color-mix(in srgb, var(--background) 88%, black 12%); +} + .native-audio-preview { align-self: center; width: min(42rem, calc(100% - 2rem)); diff --git a/backend/static/js/45-preview.js b/backend/static/js/45-preview.js index 154d812..7938a16 100644 --- a/backend/static/js/45-preview.js +++ b/backend/static/js/45-preview.js @@ -16,6 +16,7 @@ sourceURL: preview.dataset.sourceUrl || "", downloadURL: preview.dataset.downloadUrl || "", iconURL: preview.dataset.iconUrl || "", + sceneURL: preview.dataset.sceneUrl || "", activeMode: "", defaultMode: "default", pendingMode: "", @@ -24,6 +25,7 @@ rawLoaded: false, prismLoaded: false, renderLoaded: false, + sceneLoaded: false, renderFullscreenFallback: false, confirmedLargeModes: {}, tabs: [] @@ -35,6 +37,7 @@ defaultPane: preview.querySelector("[data-default-preview]"), imagePane: preview.querySelector("[data-image-preview]"), videoPane: preview.querySelector("[data-video-preview]"), + videoScenesPane: preview.querySelector("[data-video-scenes-preview]"), browserAudioPane: preview.querySelector("[data-browser-audio-preview]"), rawPane: preview.querySelector("[data-raw-preview]"), rawOutput: preview.querySelector("[data-raw-output]"), @@ -90,6 +93,9 @@ if (type.isVideo) { tabs.push({ mode: "video", label: "Video Preview" }); + if (state.sceneURL && els.videoScenesPane) { + tabs.push({ mode: "scenes", label: "Scenes Preview" }); + } return tabs; } @@ -181,6 +187,9 @@ show(els.imagePane); } else if (mode === "video") { show(els.videoPane); + } else if (mode === "scenes") { + show(els.videoScenesPane); + ensureScenesPreview(); } else if (mode === "browser-audio") { show(els.browserAudioPane); } else if (mode === "raw") { @@ -403,6 +412,7 @@ hide(els.defaultPane); hide(els.imagePane); hide(els.videoPane); + hide(els.videoScenesPane); hide(els.browserAudioPane); hide(els.rawPane); hide(els.codePane); @@ -498,6 +508,7 @@ "default": "Default", "image": "Image preview", "video": "Video preview", + "scenes": "Scenes preview", "browser-audio": "Browser preview", "raw": "Raw preview", "code": "Code preview", @@ -506,6 +517,18 @@ return labels[mode] || "Preview"; } + function ensureScenesPreview() { + if (state.sceneLoaded || !els.videoScenesPane) { + return; + } + var src = els.videoScenesPane.dataset.sceneSrc || state.sceneURL; + if (!src) { + return; + } + els.videoScenesPane.src = src; + state.sceneLoaded = true; + } + function loadPrism() { if (window.Prism) { return Promise.resolve(); diff --git a/backend/templates/pages/preview.html b/backend/templates/pages/preview.html index 087e03e..d94cdd1 100644 --- a/backend/templates/pages/preview.html +++ b/backend/templates/pages/preview.html @@ -23,7 +23,7 @@ -