feat(backend): add video scene preview generation and endpoint
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m52s

- Register a new route `GET /d/{boxID}/scene/{fileID}` to serve video scene previews.
- Implement the `VideoScenesPreview` handler to serve existing previews or generate them on-demand.
- Add helper functions to analyze video frames (e.g., luma calculation to filter out dark frames) and render the final scene thumbnail.
- Update the `fileView` struct to include scene URL and status fields.
This commit is contained in:
2026-06-05 10:42:30 +03:00
parent 2eba04b9da
commit f9755fa98f
9 changed files with 552 additions and 32 deletions

View File

@@ -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}/download", a.DownloadFileContent)
mux.HandleFunc("GET /d/{boxID}/f/{fileID}/og-image.jpg", a.FileOGImage) 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}/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 /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /robots.txt", a.RobotsTxt) mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML) mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)

View File

@@ -47,7 +47,9 @@ type fileView struct {
URL string URL string
DownloadURL string DownloadURL string
ThumbnailURL string ThumbnailURL string
SceneURL string
HasThumbnail bool HasThumbnail bool
HasScene bool
IconURL string IconURL string
IconRetroURL string IconRetroURL string
ReactURL 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)) 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 { func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.Box, file services.File) string {
if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) { if file.Thumbnail != "" || !jobs.NeedsThumbnail(file) {
return "" return ""
@@ -369,6 +408,30 @@ func (a *App) generateMissingThumbnailForRequest(r *http.Request, box services.B
return thumbnail 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 // 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 // browser re-requests on the next load and picks up the real thumbnail as soon
// as it has been generated. // 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), URL: fmt.Sprintf("/d/%s/f/%s", box.ID, file.ID),
DownloadURL: fmt.Sprintf("/d/%s/f/%s/download", 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), 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), HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
IconURL: fileIconURL("standard", icon.Standard), IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro), IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID), ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),

View File

@@ -23,6 +23,7 @@ Disallow: /account/
Disallow: /d/*/f/*/download Disallow: /d/*/f/*/download
Disallow: /d/*/zip Disallow: /d/*/zip
Disallow: /d/*/thumb/ Disallow: /d/*/thumb/
Disallow: /d/*/scene/
Disallow: /d/*/og-image.jpg Disallow: /d/*/og-image.jpg
Disallow: /d/*/unlock Disallow: /d/*/unlock
Disallow: /d/*/manage/ Disallow: /d/*/manage/

View File

@@ -3,6 +3,7 @@ package jobs
import ( import (
"bytes" "bytes"
"context" "context"
"fmt"
"html" "html"
"image" "image"
"image/color" "image/color"
@@ -16,6 +17,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv"
"strings" "strings"
"time" "time"
@@ -108,26 +110,41 @@ func generateMissingThumbnailsForBox(uploadService *services.UploadService, logg
changed := false changed := false
for i := range box.Files { for i := range box.Files {
file := &box.Files[i] file := &box.Files[i]
if file.Thumbnail != "" || !needsThumbnail(*file) { needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
if !needsPrimary && !needsScenes {
continue continue
} }
result.Scanned++ result.Scanned++
if needsPrimary {
thumbnail, err := generateThumbnail(uploadService, box, *file) thumbnail, err := generateThumbnail(uploadService, box, *file)
if err != nil { if err != nil {
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error()) logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
result.Failed++ result.Failed++
continue } else if thumbnail == "" {
}
if thumbnail == "" {
result.Failed++ result.Failed++
continue } else {
}
file.Thumbnail = thumbnail file.Thumbnail = thumbnail
changed = true changed = true
result.Generated++ 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 { if changed {
if err := uploadService.SaveBox(box); err != nil { if err := uploadService.SaveBox(box); err != nil {
@@ -141,14 +158,26 @@ func needsThumbnail(file services.File) bool {
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file) 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 { func NeedsThumbnail(file services.File) bool {
return needsThumbnail(file) 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) { func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateThumbnail(uploadService, box, file) 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) { func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
thumbnailName := "@thumb@" + file.ID + ".jpg" thumbnailName := "@thumb@" + file.ID + ".jpg"
object, err := uploadService.OpenFileObject(context.Background(), box, file) 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 { func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType)) contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 { if i := strings.IndexByte(contentType, ';'); i >= 0 {
@@ -233,17 +281,320 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
if err := sourceFile.Close(); err != nil { if err := sourceFile.Close(); err != nil {
return nil, err return nil, err
} }
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") targetFile, err := os.CreateTemp("", "warpbox-thumb-*.jpg")
if err != nil { if err != nil {
return nil, err return nil, err
} }
targetPath := targetFile.Name() targetPath := targetFile.Name()
targetFile.Close() targetFile.Close()
defer os.Remove(targetPath) if err := extractVideoFrame(sourcePath, timestamp, targetPath, "scale=360:-1"); err != nil {
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 { 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 return nil, err
} }
return os.ReadFile(targetPath) defer os.Remove(sourceFile.Name())
if _, err := io.Copy(sourceFile, source); err != nil {
sourceFile.Close()
return nil, err
}
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) { func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {

View File

@@ -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 { func newThumbnailTestUploadService(t *testing.T) *services.UploadService {
t.Helper() t.Helper()
service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil))) service, err := services.NewUploadService(1024*1024, t.TempDir(), "http://example.test", slog.New(slog.NewTextHandler(io.Discard, nil)))

View File

@@ -128,8 +128,10 @@ type File struct {
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"` PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"` Thumbnail string `json:"thumbnail,omitempty"`
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
ObjectKey string `json:"objectKey,omitempty"` ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"` ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
Processing bool `json:"processing,omitempty"` Processing bool `json:"processing,omitempty"`
ProcessingError string `json:"processingError,omitempty"` ProcessingError string `json:"processingError,omitempty"`
UploadedAt time.Time `json:"uploadedAt"` UploadedAt time.Time `json:"uploadedAt"`
@@ -731,6 +733,9 @@ func (s *UploadService) RemoveFileFromBox(boxID, fileID string) (bool, error) {
if key := s.ThumbnailObjectKey(box, file); key != "" { if key := s.ThumbnailObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), 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:]...) 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) 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) { func (s *UploadService) OpenFileObject(ctx context.Context, box Box, file File) (StorageObject, error) {
if file.Processing { if file.Processing {
return StorageObject{}, fmt.Errorf("file is still 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) 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) { 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)) backend, err := s.storage.Backend(s.BoxStorageBackendID(box))
if err != nil { if err != nil {

View File

@@ -260,6 +260,11 @@
height: clamp(18rem, 64vh, 38rem); height: clamp(18rem, 64vh, 38rem);
} }
.video-scenes-preview {
object-fit: contain;
background: color-mix(in srgb, var(--background) 88%, black 12%);
}
.native-audio-preview { .native-audio-preview {
align-self: center; align-self: center;
width: min(42rem, calc(100% - 2rem)); width: min(42rem, calc(100% - 2rem));

View File

@@ -16,6 +16,7 @@
sourceURL: preview.dataset.sourceUrl || "", sourceURL: preview.dataset.sourceUrl || "",
downloadURL: preview.dataset.downloadUrl || "", downloadURL: preview.dataset.downloadUrl || "",
iconURL: preview.dataset.iconUrl || "", iconURL: preview.dataset.iconUrl || "",
sceneURL: preview.dataset.sceneUrl || "",
activeMode: "", activeMode: "",
defaultMode: "default", defaultMode: "default",
pendingMode: "", pendingMode: "",
@@ -24,6 +25,7 @@
rawLoaded: false, rawLoaded: false,
prismLoaded: false, prismLoaded: false,
renderLoaded: false, renderLoaded: false,
sceneLoaded: false,
renderFullscreenFallback: false, renderFullscreenFallback: false,
confirmedLargeModes: {}, confirmedLargeModes: {},
tabs: [] tabs: []
@@ -35,6 +37,7 @@
defaultPane: preview.querySelector("[data-default-preview]"), defaultPane: preview.querySelector("[data-default-preview]"),
imagePane: preview.querySelector("[data-image-preview]"), imagePane: preview.querySelector("[data-image-preview]"),
videoPane: preview.querySelector("[data-video-preview]"), videoPane: preview.querySelector("[data-video-preview]"),
videoScenesPane: preview.querySelector("[data-video-scenes-preview]"),
browserAudioPane: preview.querySelector("[data-browser-audio-preview]"), browserAudioPane: preview.querySelector("[data-browser-audio-preview]"),
rawPane: preview.querySelector("[data-raw-preview]"), rawPane: preview.querySelector("[data-raw-preview]"),
rawOutput: preview.querySelector("[data-raw-output]"), rawOutput: preview.querySelector("[data-raw-output]"),
@@ -90,6 +93,9 @@
if (type.isVideo) { if (type.isVideo) {
tabs.push({ mode: "video", label: "Video Preview" }); tabs.push({ mode: "video", label: "Video Preview" });
if (state.sceneURL && els.videoScenesPane) {
tabs.push({ mode: "scenes", label: "Scenes Preview" });
}
return tabs; return tabs;
} }
@@ -181,6 +187,9 @@
show(els.imagePane); show(els.imagePane);
} else if (mode === "video") { } else if (mode === "video") {
show(els.videoPane); show(els.videoPane);
} else if (mode === "scenes") {
show(els.videoScenesPane);
ensureScenesPreview();
} else if (mode === "browser-audio") { } else if (mode === "browser-audio") {
show(els.browserAudioPane); show(els.browserAudioPane);
} else if (mode === "raw") { } else if (mode === "raw") {
@@ -403,6 +412,7 @@
hide(els.defaultPane); hide(els.defaultPane);
hide(els.imagePane); hide(els.imagePane);
hide(els.videoPane); hide(els.videoPane);
hide(els.videoScenesPane);
hide(els.browserAudioPane); hide(els.browserAudioPane);
hide(els.rawPane); hide(els.rawPane);
hide(els.codePane); hide(els.codePane);
@@ -498,6 +508,7 @@
"default": "Default", "default": "Default",
"image": "Image preview", "image": "Image preview",
"video": "Video preview", "video": "Video preview",
"scenes": "Scenes preview",
"browser-audio": "Browser preview", "browser-audio": "Browser preview",
"raw": "Raw preview", "raw": "Raw preview",
"code": "Code preview", "code": "Code preview",
@@ -506,6 +517,18 @@
return labels[mode] || "Preview"; 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() { function loadPrism() {
if (window.Prism) { if (window.Prism) {
return Promise.resolve(); return Promise.resolve();

View File

@@ -23,7 +23,7 @@
</a> </a>
</header> </header>
<div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}"> <div class="preview-window" data-preview-kind="{{.Data.File.PreviewKind}}" data-file-name="{{.Data.File.Name}}" data-content-type="{{.Data.File.ContentType}}" data-size-bytes="{{.Data.File.SizeBytes}}" data-source-url="{{.Data.DownloadURL}}?inline=1" data-download-url="{{.Data.DownloadURL}}" data-icon-url="{{.Data.File.IconURL}}" data-file-size="{{.Data.File.Size}}" data-scene-url="{{.Data.File.SceneURL}}">
<div class="preview-window-titlebar"> <div class="preview-window-titlebar">
<div> <div>
<strong data-preview-mode-label>Preview</strong> <strong data-preview-mode-label>Preview</strong>
@@ -49,6 +49,7 @@
</div> </div>
<img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden> <img class="native-preview native-image-preview" data-image-preview src="{{.Data.DownloadURL}}?inline=1" alt="{{.Data.File.Name}}" hidden>
<video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video> <video class="native-preview native-video-preview" data-video-preview src="{{.Data.DownloadURL}}?inline=1" poster="{{.Data.File.ThumbnailURL}}" controls preload="metadata" hidden></video>
{{if .Data.File.HasScene}}<img class="native-preview video-scenes-preview" data-video-scenes-preview data-scene-src="{{.Data.File.SceneURL}}" alt="Scenes preview for {{.Data.File.Name}}" hidden>{{end}}
<audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio> <audio class="native-preview native-audio-preview" data-browser-audio-preview src="{{.Data.DownloadURL}}?inline=1" controls preload="metadata" hidden></audio>
<div class="code-preview raw-code-preview" data-raw-preview hidden> <div class="code-preview raw-code-preview" data-raw-preview hidden>
<pre><code data-raw-output></code></pre> <pre><code data-raw-output></code></pre>