Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f9755fa98f |
@@ -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)
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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,25 +110,40 @@ 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++
|
||||||
|
|
||||||
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
if needsPrimary {
|
||||||
if err != nil {
|
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
||||||
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
if err != nil {
|
||||||
result.Failed++
|
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
||||||
continue
|
result.Failed++
|
||||||
}
|
} else if thumbnail == "" {
|
||||||
if thumbnail == "" {
|
result.Failed++
|
||||||
result.Failed++
|
} else {
|
||||||
continue
|
file.Thumbnail = thumbnail
|
||||||
|
changed = true
|
||||||
|
result.Generated++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
file.Thumbnail = thumbnail
|
if needsScenes {
|
||||||
changed = true
|
sceneThumbnail, err := generateVideoScenesThumbnail(uploadService, box, *file)
|
||||||
result.Generated++
|
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 {
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
targetPath := targetFile.Name()
|
defer os.Remove(sourceFile.Name())
|
||||||
targetFile.Close()
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
||||||
defer os.Remove(targetPath)
|
sourceFile.Close()
|
||||||
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 {
|
|
||||||
return nil, err
|
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) {
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -121,18 +121,20 @@ type Box struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
StoredName string `json:"storedName"`
|
StoredName string `json:"storedName"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
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"`
|
||||||
ObjectKey string `json:"objectKey,omitempty"`
|
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
|
||||||
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
ObjectKey string `json:"objectKey,omitempty"`
|
||||||
Processing bool `json:"processing,omitempty"`
|
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
|
||||||
ProcessingError string `json:"processingError,omitempty"`
|
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
|
||||||
UploadedAt time.Time `json:"uploadedAt"`
|
Processing bool `json:"processing,omitempty"`
|
||||||
|
ProcessingError string `json:"processingError,omitempty"`
|
||||||
|
UploadedAt time.Time `json:"uploadedAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UploadResult struct {
|
type UploadResult struct {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user