6 Commits

Author SHA1 Message Date
45507cdcae feat(ogimage): render custom OG images for archive files
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m50s
Add support for generating and rendering rich Open Graph (OG) image cards for archive files. When an archive file is shared, the handler now fetches or generates its listing metadata and renders a custom card displaying file/folder counts, uncompressed size, and a visual representation of the archive's contents.
2026-06-08 03:56:42 +03:00
a454e4239f feat(archive): add retro theme support to archive browser
Implement retro-themed styling and classic pixelated icons for the
archive browser when the "retro" theme is active.

Changes include:
- Adding CSS overrides for `[data-theme="retro"]` to style the archive
  browser container, tree nodes, and hover states.
- Updating the JS preview script to dynamically append retro image
  icons (e.g., classic shell32/zipfldr icons) alongside SVGs.
- Toggling visibility between SVG and retro image icons using CSS
  based on the active theme.
2026-06-08 03:50:14 +03:00
cba416b238 feat(preview): add archive listing and browser support
Introduces the ability to browse and preview the contents of archive files directly within the web interface.

Changes include:
- Added a new API endpoint `GET /d/{boxID}/archive/{fileID}` to fetch archive listings.
- Implemented on-demand archive listing generation in the backend.
- Updated the frontend preview component to support rendering and navigating archive contents.
2026-06-08 03:43:43 +03:00
f9755fa98f 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.
2026-06-05 10:42:30 +03:00
2eba04b9da fix(upload): sniff content type for application/octet-stream
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m51s
When an incoming file has an empty content type or is marked as
"application/octet-stream", attempt to detect the actual MIME type
by reading the first 512 bytes of the file. This improves content
type accuracy for generic binary uploads.
2026-06-03 15:31:18 +03:00
81f4ce5e36 fix(handlers): support thumbnail rendering for files needing thumbnails
Some checks failed
Build and Publish Docker Image / deploy (push) Failing after 45s
Update `HasThumbnail` in `fileViewWithReactions` to evaluate to true if the file already has a thumbnail or if it is a file type that requires one (`jobs.NeedsThumbnail`). This ensures the download page renders the thumbnail element for files that are pending thumbnail generation or support dynamic thumbnails.

Additionally, update tests in `upload_stage3_test.go` to verify the thumbnail image is rendered and relax the OG image URL matching.
2026-06-03 15:22:58 +03:00
12 changed files with 1773 additions and 36 deletions

View File

@@ -134,6 +134,8 @@ 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}/archive/{fileID}", a.ArchiveListing)
mux.HandleFunc("GET /d/{boxID}/og-image.jpg", a.BoxOGImage)
mux.HandleFunc("GET /robots.txt", a.RobotsTxt)
mux.HandleFunc("GET /sitemap.xml", a.SitemapXML)

View File

@@ -47,7 +47,11 @@ type fileView struct {
URL string
DownloadURL string
ThumbnailURL string
SceneURL string
ArchiveURL string
HasThumbnail bool
HasScene bool
HasArchive bool
IconURL string
IconRetroURL string
ReactURL string
@@ -345,6 +349,88 @@ 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) ArchiveListing(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.NeedsArchiveListing(file) {
http.NotFound(w, r)
return
}
if a.uploadService.IsProtected(box) && box.Obfuscate && !a.isBoxUnlocked(r, box) {
http.Error(w, "password required", http.StatusUnauthorized)
return
}
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err == nil {
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", object.ModTime, readSeekCloser(object.Body))
return
}
}
http.Error(w, "archive preview unavailable", http.StatusInternalServerError)
return
}
defer object.Body.Close()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=604800, immutable")
http.ServeContent(w, r, file.ID+"-archive.json", 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 +455,55 @@ 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
}
func (a *App) generateMissingArchiveListingForRequest(r *http.Request, box services.Box, file services.File) string {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json" || !jobs.NeedsArchiveListing(file) {
return ""
}
listing, err := jobs.GenerateArchiveListingForFile(a.uploadService, box, file)
if err != nil || listing == "" {
if err != nil {
a.logger.Warn("on-demand archive listing generation failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4108, "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].ArchiveListing = listing
box.Files[i].ArchiveListingObjectKey = ""
break
}
}
if err := a.uploadService.SaveBox(box); err != nil {
a.logger.Warn("on-demand archive listing metadata save failed", withRequestLogAttrs(r, "source", "thumbnail", "severity", "warn", "code", 4109, "box_id", box.ID, "file_id", file.ID, "error", err.Error())...)
return ""
}
return listing
}
// 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 +648,11 @@ 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),
HasThumbnail: file.Thumbnail != "",
SceneURL: fmt.Sprintf("/d/%s/scene/%s", box.ID, file.ID),
ArchiveURL: fmt.Sprintf("/d/%s/archive/%s", box.ID, file.ID),
HasThumbnail: file.Thumbnail != "" || jobs.NeedsThumbnail(file),
HasScene: file.SceneThumbnail != "" || jobs.NeedsVideoScenes(file),
HasArchive: file.ArchiveListing != "" || jobs.NeedsArchiveListing(file),
IconURL: fileIconURL("standard", icon.Standard),
IconRetroURL: fileIconURL("retro", icon.Retro),
ReactURL: fmt.Sprintf("/d/%s/f/%s/react", box.ID, file.ID),

View File

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

View File

@@ -2,6 +2,7 @@ package handlers
import (
"bytes"
"encoding/json"
"fmt"
"image"
"image/color"
@@ -23,6 +24,7 @@ import (
_ "golang.org/x/image/webp"
"warpbox.dev/backend/libs/helpers"
"warpbox.dev/backend/libs/jobs"
"warpbox.dev/backend/libs/services"
)
@@ -95,10 +97,65 @@ func (a *App) FileOGImage(w http.ResponseWriter, r *http.Request) {
return
}
if jobs.NeedsArchiveListing(file) {
if listing, ok := a.archiveListingForOG(r, box, file); ok {
a.serveOGImage(w, r, a.renderArchiveCard(file, listing))
return
}
}
icon := a.ogFileIcon(file)
a.serveOGImage(w, r, a.renderFileCard(file, icon))
}
type ogArchiveListing struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *ogArchiveNode `json:"root"`
}
type ogArchiveNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Items []*ogArchiveNode `json:"items,omitempty"`
}
func (a *App) archiveListingForOG(r *http.Request, box services.Box, file services.File) (ogArchiveListing, bool) {
if strings.ToLower(filepath.Ext(file.ArchiveListing)) != ".json" {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
}
}
object, err := a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
if err != nil {
if listing := a.generateMissingArchiveListingForRequest(r, box, file); listing != "" {
file.ArchiveListing = listing
file.ArchiveListingObjectKey = ""
object, err = a.uploadService.OpenArchiveListingObject(r.Context(), box, file)
}
}
if err != nil {
return ogArchiveListing{}, false
}
defer object.Body.Close()
var listing ogArchiveListing
if err := json.NewDecoder(object.Body).Decode(&listing); err != nil {
return ogArchiveListing{}, false
}
if listing.Root == nil {
return ogArchiveListing{}, false
}
return listing, true
}
func (a *App) serveOGImage(w http.ResponseWriter, r *http.Request, img image.Image) {
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 85}); err != nil {
@@ -213,6 +270,174 @@ func (a *App) renderFileCard(file services.File, icon image.Image) image.Image {
return canvas
}
func (a *App) renderArchiveCard(file services.File, listing ogArchiveListing) image.Image {
canvas := image.NewRGBA(image.Rect(0, 0, ogImageWidth, ogImageHeight))
draw.Draw(canvas, canvas.Bounds(), &image.Uniform{ogBackground}, image.Point{}, draw.Src)
panel := image.Rect(70, 54, ogImageWidth-70, ogImageHeight-54)
draw.Draw(canvas, panel, &image.Uniform{color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(panel.Min.X, panel.Min.Y, panel.Max.X, panel.Min.Y+6), &image.Uniform{color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff}}, image.Point{}, draw.Src)
titleFace := a.ogFont(36, true)
bodyFace := a.ogFont(22, false)
treeFace := a.ogFont(19, false)
labelFace := a.ogFont(17, true)
icon := a.ogFileIcon(file)
if icon != nil {
xdraw.NearestNeighbor.Scale(canvas, image.Rect(104, 92, 182, 170), icon, icon.Bounds(), xdraw.Over, nil)
} else {
draw.Draw(canvas, image.Rect(104, 92, 182, 170), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
}
title := listing.Name
if strings.TrimSpace(title) == "" {
title = file.Name
}
titleLines := wrapOGText(title, titleFace, 820)
if len(titleLines) > 2 {
titleLines = titleLines[:2]
titleLines[1] = trimOGText(titleLines[1], titleFace, 820)
}
y := 106
for _, line := range titleLines {
drawOGText(canvas, titleFace, line, 204, y, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
y += 42
}
meta := fmt.Sprintf("%s · %d files · %d folders · %s unpacked", archiveTypeLabel(listing, file), listing.FileCount, listing.FolderCount, formatOGArchiveBytes(listing.UncompressedSize))
drawOGText(canvas, bodyFace, trimOGText(meta, bodyFace, 840), 204, y+14, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
treePanel := image.Rect(104, 214, 1096, 548)
draw.Draw(canvas, treePanel, &image.Uniform{color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(canvas, image.Rect(treePanel.Min.X, treePanel.Min.Y, treePanel.Max.X, treePanel.Min.Y+38), &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
drawOGText(canvas, labelFace, "Archive Preview", treePanel.Min.X+18, treePanel.Min.Y+25, color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff})
rows := archiveOGRows(listing.Root, 13)
rowY := treePanel.Min.Y + 64
for _, row := range rows {
if row.Ellipsis {
drawOGText(canvas, treeFace, "... more files inside", treePanel.Min.X+24, rowY, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
break
}
x := treePanel.Min.X + 20 + row.Depth*28
drawArchiveOGIcon(canvas, row.Icon, x, rowY-17)
name := row.Name
if row.Dir {
name += "/"
}
maxNameWidth := treePanel.Max.X - x - 170
drawOGText(canvas, treeFace, trimOGText(name, treeFace, maxNameWidth), x+32, rowY, archiveOGTextColor(row))
if !row.Dir {
size := formatOGArchiveBytes(row.Size)
drawOGText(canvas, treeFace, size, treePanel.Max.X-142, rowY, color.RGBA{R: 0x94, G: 0xa3, B: 0xb8, A: 0xff})
}
rowY += 23
}
drawOGText(canvas, bodyFace, "warpbox.dev", 920, 592, color.RGBA{R: 0xaa, G: 0xa4, B: 0xd6, A: 0xff})
return canvas
}
type archiveOGRow struct {
Name string
Icon string
Size uint64
Dir bool
Depth int
Ellipsis bool
}
func archiveOGRows(root *ogArchiveNode, limit int) []archiveOGRow {
rows := make([]archiveOGRow, 0, limit+1)
truncated := false
var walk func(items []*ogArchiveNode, depth int)
walk = func(items []*ogArchiveNode, depth int) {
for _, item := range items {
if len(rows) >= limit {
truncated = true
return
}
icon := item.Icon
if item.Dir {
icon = "folder"
}
rows = append(rows, archiveOGRow{Name: item.Name, Icon: icon, Size: item.Size, Dir: item.Dir, Depth: depth})
if item.Dir {
walk(item.Items, depth+1)
}
}
}
if root != nil {
walk(root.Items, 0)
}
if truncated {
rows = append(rows, archiveOGRow{Ellipsis: true})
}
return rows
}
func drawArchiveOGIcon(dst *image.RGBA, icon string, x, y int) {
c := archiveOGIconColor(icon)
rect := image.Rect(x, y, x+20, y+20)
draw.Draw(dst, rect, &image.Uniform{color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff}}, image.Point{}, draw.Src)
draw.Draw(dst, image.Rect(x+3, y+4, x+17, y+17), &image.Uniform{c}, image.Point{}, draw.Src)
if icon == "folder" {
draw.Draw(dst, image.Rect(x+3, y+2, x+11, y+6), &image.Uniform{c}, image.Point{}, draw.Src)
}
}
func archiveOGIconColor(icon string) color.RGBA {
switch icon {
case "folder":
return color.RGBA{R: 0xf6, G: 0xc1, B: 0x77, A: 0xff}
case "img":
return color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff}
case "vid":
return color.RGBA{R: 0xf9, G: 0xa8, B: 0xd4, A: 0xff}
case "aud":
return color.RGBA{R: 0x86, G: 0xef, B: 0xac, A: 0xff}
case "code":
return color.RGBA{R: 0xc4, G: 0xb5, B: 0xfd, A: 0xff}
case "arc":
return color.RGBA{R: 0xfc, G: 0xd3, B: 0x4d, A: 0xff}
default:
return color.RGBA{R: 0xe2, G: 0xe8, B: 0xf0, A: 0xff}
}
}
func archiveOGTextColor(row archiveOGRow) color.RGBA {
if row.Dir {
return color.RGBA{R: 0xff, G: 0xfb, B: 0xeb, A: 0xff}
}
return color.RGBA{R: 0xd8, G: 0xd2, B: 0xff, A: 0xff}
}
func archiveTypeLabel(listing ogArchiveListing, file services.File) string {
if strings.TrimSpace(listing.Type) != "" {
return listing.Type
}
if strings.TrimSpace(file.ContentType) != "" {
return file.ContentType
}
return "Archive"
}
func formatOGArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
value := float64(size) / unit
for _, suffix := range []string{"KiB", "MiB", "GiB", "TiB"} {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= unit
}
return fmt.Sprintf("%.1f PiB", value)
}
func fileCardInfo(file services.File) string {
switch {
case strings.HasPrefix(file.ContentType, "audio/"):

View File

@@ -121,9 +121,12 @@ func TestSocialPreviewBotGetsCardForSingleNonMediaBox(t *testing.T) {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
}
if !strings.Contains(body, `class="file-thumb" src="/d/`+payload.BoxID+`/thumb/`+payload.Files[0].ID+`"`) {
t.Fatalf("download page did not render text thumbnail image: %s", body)
}
if !strings.Contains(body, "Click to preview or download") && !strings.Contains(body, "click to preview or download") {
t.Fatalf("social preview body missing preview/download description: %s", body)
}
@@ -145,7 +148,7 @@ func TestSocialPreviewBotGetsCardForNonMediaFilePreview(t *testing.T) {
t.Fatalf("status = %d, body = %s", response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `property="og:image" content="http://example.test/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
if !strings.Contains(body, `/d/`+payload.BoxID+`/f/`+payload.Files[0].ID+`/og-image.jpg"`) {
t.Fatalf("social preview bot did not receive file card metadata: %s", body)
}
if !strings.Contains(body, `name="twitter:card" content="summary_large_image"`) {

View File

@@ -1,8 +1,11 @@
package jobs
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"html"
"image"
"image/color"
@@ -16,6 +19,8 @@ import (
"os/exec"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -108,26 +113,57 @@ 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)
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
if !needsPrimary && !needsScenes && !needsArchive {
continue
}
result.Scanned++
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++
continue
}
if thumbnail == "" {
} else if thumbnail == "" {
result.Failed++
continue
}
} else {
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 needsArchive {
archiveListing, err := generateArchiveListing(uploadService, box, *file)
if err != nil {
logger.Warn("archive listing generation failed", "source", "thumbnail", "severity", "warn", "code", 4107, "file_id", file.ID, "error", err.Error())
result.Failed++
} else if archiveListing == "" {
result.Failed++
} else {
file.ArchiveListing = archiveListing
file.ArchiveListingObjectKey = ""
changed = true
result.Generated++
}
}
}
if changed {
if err := uploadService.SaveBox(box); err != nil {
@@ -141,14 +177,34 @@ 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 NeedsArchiveListing(file services.File) bool {
return needsArchiveListing(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 GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
return generateArchiveListing(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 +240,44 @@ 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 generateArchiveListing(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
if !needsArchiveListing(file) {
return "", nil
}
listingName := "@archive@" + file.ID + ".json"
object, err := uploadService.OpenFileObject(context.Background(), box, file)
if err != nil {
return "", err
}
defer object.Body.Close()
data, err := createArchiveListing(file, object.Body)
if err != nil {
return "", err
}
_, err = uploadService.PutThumbnailObject(context.Background(), box, listingName, bytes.NewReader(data), int64(len(data)), "application/json")
return listingName, err
}
func isTextThumbnailCandidate(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
@@ -205,6 +299,219 @@ func isTextThumbnailCandidate(file services.File) bool {
}
}
func needsArchiveListing(file services.File) bool {
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
if i := strings.IndexByte(contentType, ';'); i >= 0 {
contentType = strings.TrimSpace(contentType[:i])
}
switch contentType {
case "application/zip", "application/x-zip-compressed", "application/java-archive", "application/vnd.android.package-archive", "application/epub+zip":
return true
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
switch ext {
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return true
default:
return false
}
}
func archiveListingCurrent(file services.File) bool {
return strings.ToLower(filepath.Ext(file.ArchiveListing)) == ".json"
}
type archiveTreeNode struct {
Name string `json:"name"`
Size uint64 `json:"size,omitempty"`
Dir bool `json:"dir"`
Icon string `json:"icon,omitempty"`
Children map[string]*archiveTreeNode `json:"-"`
Items []*archiveTreeNode `json:"items,omitempty"`
}
type archiveListingData struct {
Name string `json:"name"`
Type string `json:"type"`
FileCount int `json:"fileCount"`
FolderCount int `json:"folderCount"`
UncompressedSize uint64 `json:"uncompressedSize"`
Root *archiveTreeNode `json:"root"`
}
func createArchiveListing(file services.File, source io.Reader) ([]byte, error) {
sourceFile, err := os.CreateTemp("", "warpbox-archive-*")
if err != nil {
return nil, err
}
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
}
archive, err := zip.OpenReader(sourceFile.Name())
if err != nil {
return nil, err
}
defer archive.Close()
root := &archiveTreeNode{Name: ".", Dir: true, Children: map[string]*archiveTreeNode{}}
var totalSize uint64
var fileCount int
var dirCount int
for _, entry := range archive.File {
name := strings.Trim(entry.Name, "/")
if name == "" || strings.HasPrefix(name, "__MACOSX/") {
continue
}
parts := strings.Split(name, "/")
node := root
for i, part := range parts {
if part == "" {
continue
}
if node.Children == nil {
node.Children = map[string]*archiveTreeNode{}
}
child, ok := node.Children[part]
if !ok {
child = &archiveTreeNode{Name: part, Dir: i < len(parts)-1 || entry.FileInfo().IsDir(), Children: map[string]*archiveTreeNode{}}
node.Children[part] = child
if child.Dir {
dirCount++
}
}
node = child
}
if !entry.FileInfo().IsDir() {
node.Dir = false
node.Size = entry.UncompressedSize64
totalSize += entry.UncompressedSize64
fileCount++
}
}
finalizeArchiveTree(root)
data := archiveListingData{
Name: file.Name,
Type: archiveLabel(file),
FileCount: fileCount,
FolderCount: dirCount,
UncompressedSize: totalSize,
Root: root,
}
return json.MarshalIndent(data, "", " ")
}
func finalizeArchiveTree(node *archiveTreeNode) {
node.Items = sortedArchiveChildren(node)
for _, child := range node.Items {
if child.Dir {
child.Icon = "folder"
finalizeArchiveTree(child)
} else {
child.Icon = archiveFileIconName(child.Name)
}
}
}
func writeArchiveTree(out *strings.Builder, node *archiveTreeNode, prefix string) {
children := sortedArchiveChildren(node)
for i, child := range children {
last := i == len(children)-1
branch := "|-- "
nextPrefix := prefix + "| "
if last {
branch = "`-- "
nextPrefix = prefix + " "
}
out.WriteString(prefix)
out.WriteString(branch)
out.WriteString(archiveNodeLabel(child))
out.WriteString("\n")
if child.Dir {
writeArchiveTree(out, child, nextPrefix)
}
}
}
func sortedArchiveChildren(node *archiveTreeNode) []*archiveTreeNode {
children := make([]*archiveTreeNode, 0, len(node.Children))
for _, child := range node.Children {
children = append(children, child)
}
sort.Slice(children, func(i, j int) bool {
if children[i].Dir != children[j].Dir {
return children[i].Dir
}
return strings.ToLower(children[i].Name) < strings.ToLower(children[j].Name)
})
return children
}
func archiveNodeLabel(node *archiveTreeNode) string {
if node.Dir {
return "[DIR] " + node.Name + "/"
}
return archiveFileIcon(node.Name) + " " + node.Name + " (" + formatArchiveBytes(node.Size) + ")"
}
func archiveFileIcon(name string) string {
return "[" + strings.ToUpper(archiveFileIconName(name)) + "]"
}
func archiveFileIconName(name string) string {
switch strings.TrimPrefix(strings.ToLower(filepath.Ext(name)), ".") {
case "jpg", "jpeg", "png", "gif", "webp", "avif", "svg":
return "img"
case "mp4", "mov", "webm", "mkv", "avi":
return "vid"
case "mp3", "wav", "flac", "ogg", "m4a":
return "aud"
case "md", "txt", "log", "csv":
return "txt"
case "html", "css", "js", "ts", "go", "rs", "py", "json", "xml", "yaml", "yml":
return "code"
case "zip", "jar", "war", "ear", "apk", "epub", "docx", "xlsx", "pptx":
return "arc"
default:
return "file"
}
}
func archiveLabel(file services.File) string {
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
if ext != "" {
return strings.ToUpper(ext) + " archive"
}
if file.ContentType != "" {
return file.ContentType
}
return "ZIP-compatible archive"
}
func formatArchiveBytes(size uint64) string {
const unit = 1024
if size < unit {
return fmt.Sprintf("%d B", size)
}
div := float64(unit)
value := float64(size) / div
units := []string{"KiB", "MiB", "GiB", "TiB"}
for _, suffix := range units {
if value < unit {
return fmt.Sprintf("%.1f %s", value, suffix)
}
value /= div
}
return fmt.Sprintf("%.1f PiB", value)
}
func createImageThumbnail(source io.Reader) ([]byte, error) {
img, _, err := image.Decode(source)
if err != nil {
@@ -233,17 +540,320 @@ func createVideoThumbnail(source io.Reader) ([]byte, error) {
if err := sourceFile.Close(); err != nil {
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")
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 {
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
}
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) {

View File

@@ -1,7 +1,9 @@
package jobs
import (
"archive/zip"
"bytes"
"encoding/json"
"image"
"image/color"
"image/jpeg"
@@ -72,6 +74,97 @@ 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 TestCreateArchiveListingRendersZipTree(t *testing.T) {
var archive bytes.Buffer
writer := zip.NewWriter(&archive)
addZipTestFile(t, writer, "docs/readme.md", "hello")
addZipTestFile(t, writer, "src/main.go", "package main\n")
if err := writer.Close(); err != nil {
t.Fatalf("zip.Close returned error: %v", err)
}
data, err := createArchiveListing(services.File{Name: "bundle.zip", ContentType: "application/zip"}, bytes.NewReader(archive.Bytes()))
if err != nil {
t.Fatalf("createArchiveListing returned error: %v", err)
}
var listing archiveListingData
if err := json.Unmarshal(data, &listing); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\n%s", err, string(data))
}
if listing.Name != "bundle.zip" || listing.FileCount != 2 || listing.FolderCount != 2 {
t.Fatalf("archive listing metadata = %+v", listing)
}
if listing.Root == nil || len(listing.Root.Items) != 2 {
t.Fatalf("archive listing root = %+v", listing.Root)
}
if listing.Root.Items[0].Name != "docs" || listing.Root.Items[0].Icon != "folder" {
t.Fatalf("first archive folder = %+v", listing.Root.Items[0])
}
if listing.Root.Items[0].Items[0].Name != "readme.md" || listing.Root.Items[0].Items[0].Icon != "txt" {
t.Fatalf("markdown archive file = %+v", listing.Root.Items[0].Items[0])
}
if listing.Root.Items[1].Items[0].Name != "main.go" || listing.Root.Items[1].Items[0].Icon != "code" {
t.Fatalf("go archive file = %+v", listing.Root.Items[1].Items[0])
}
}
func addZipTestFile(t *testing.T, writer *zip.Writer, name, body string) {
t.Helper()
file, err := writer.Create(name)
if err != nil {
t.Fatalf("zip.Create returned error: %v", err)
}
if _, err := file.Write([]byte(body)); err != nil {
t.Fatalf("zip file write returned error: %v", err)
}
}
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)))

View File

@@ -128,8 +128,12 @@ type File struct {
ContentType string `json:"contentType"`
PreviewKind string `json:"previewKind"`
Thumbnail string `json:"thumbnail,omitempty"`
SceneThumbnail string `json:"sceneThumbnail,omitempty"`
ArchiveListing string `json:"archiveListing,omitempty"`
ObjectKey string `json:"objectKey,omitempty"`
ThumbnailObjectKey string `json:"thumbnailObjectKey,omitempty"`
SceneThumbnailObjectKey string `json:"sceneThumbnailObjectKey,omitempty"`
ArchiveListingObjectKey string `json:"archiveListingObjectKey,omitempty"`
Processing bool `json:"processing,omitempty"`
ProcessingError string `json:"processingError,omitempty"`
UploadedAt time.Time `json:"uploadedAt"`
@@ -397,7 +401,7 @@ func (s *UploadService) writeIncomingFilesToBox(ctx context.Context, box *Box, f
storedName := "@each@" + fileID + strings.ToLower(filepath.Ext(incoming.Name()))
objectKey := boxObjectKey(box.ID, storedName)
contentType := incoming.ContentType()
if contentType == "" {
if contentType == "" || contentType == "application/octet-stream" {
buffer := make([]byte, 512)
n, _ := file.Read(buffer)
contentType = http.DetectContentType(buffer[:n])
@@ -731,6 +735,12 @@ 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)
}
if key := s.ArchiveListingObjectKey(box, file); key != "" {
_ = backend.Delete(context.Background(), key)
}
}
box.Files = append(box.Files[:index], box.Files[index+1:]...)
@@ -818,6 +828,26 @@ 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) ArchiveListingObjectKey(box Box, file File) string {
if file.ArchiveListingObjectKey != "" {
return file.ArchiveListingObjectKey
}
if file.ArchiveListing == "" {
return ""
}
return boxObjectKey(box.ID, file.ArchiveListing)
}
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 +871,30 @@ 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) OpenArchiveListingObject(ctx context.Context, box Box, file File) (StorageObject, error) {
key := s.ArchiveListingObjectKey(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 {

View File

@@ -54,6 +54,20 @@
margin: 0.45rem 0 0;
}
.preview-header > .button {
flex: 0 0 auto;
padding-inline: 1rem;
overflow: visible;
}
.preview-header > .button svg {
flex: 0 0 auto;
}
[data-theme="retro"] .preview-header > .button-primary:active {
padding-right: calc(1rem - 1px);
}
.preview-window {
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--border) 78%, var(--primary));
@@ -260,12 +274,281 @@
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));
height: auto;
}
.archive-browser-preview {
width: 100%;
height: clamp(18rem, 64vh, 38rem);
overflow: auto;
background: color-mix(in srgb, var(--card) 86%, black 14%);
color: var(--foreground);
}
.archive-browser-header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.9rem 1rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--card) 92%, black 8%);
}
.archive-browser-header strong {
min-width: 0;
overflow-wrap: anywhere;
font-size: 0.98rem;
}
.archive-browser-header span {
color: var(--muted-foreground);
font-size: 0.82rem;
}
.archive-tree {
padding: 0.6rem 0.8rem 1rem;
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.45;
}
.archive-node {
min-width: max-content;
}
.archive-node-row {
display: grid;
grid-template-columns: 1.25rem 1.45rem minmax(12rem, 1fr) auto;
align-items: center;
gap: 0.45rem;
min-height: 2.1rem;
padding: 0.18rem 0.45rem;
border-radius: 6px;
color: var(--foreground);
}
.archive-node-row:hover {
background: color-mix(in srgb, var(--accent) 12%, transparent);
}
.archive-folder > summary {
cursor: pointer;
list-style: none;
}
.archive-folder > summary::-webkit-details-marker {
display: none;
}
.archive-chevron,
.archive-chevron-spacer,
.archive-file-icon {
display: inline-grid;
place-items: center;
width: 1.25rem;
height: 1.25rem;
}
.archive-chevron {
color: var(--muted-foreground);
transition: transform 140ms ease, color 140ms ease;
}
.archive-folder[open] > summary .archive-chevron {
transform: rotate(90deg);
color: var(--accent);
}
.archive-chevron svg {
width: 1.18rem;
height: 1.18rem;
}
.archive-file-icon {
color: var(--muted-foreground);
}
.archive-svg-icon,
.archive-retro-icon {
display: inline-grid;
place-items: center;
width: 100%;
height: 100%;
}
.archive-retro-icon {
display: none;
object-fit: contain;
image-rendering: pixelated;
}
.archive-file-icon svg,
.archive-chevron svg {
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.archive-file-icon-folder {
color: var(--accent);
}
.archive-file-icon-folder svg {
fill: color-mix(in srgb, var(--accent) 18%, transparent);
}
.archive-file-icon-img {
color: #67e8f9;
}
.archive-file-icon-vid {
color: #f9a8d4;
}
.archive-file-icon-aud {
color: #86efac;
}
.archive-file-icon-code {
color: #c4b5fd;
}
.archive-file-icon-arc {
color: #fcd34d;
}
.archive-file-icon-txt {
color: #f8fafc;
}
[data-theme="retro"] .archive-browser-preview {
border: 1px solid #000000;
background: #ffffff;
color: #000000;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #ffffff;
}
[data-theme="retro"] .archive-browser-header {
border-bottom: 1px solid #808080;
background: #c0c0c0;
color: #000000;
box-shadow:
inset -1px -1px 0 #808080,
inset 1px 1px 0 #ffffff;
}
[data-theme="retro"] .archive-browser-header span {
color: #404040;
}
[data-theme="retro"] .archive-tree {
background: #ffffff;
font-family: "PixelOperatorMono", "Courier New", monospace;
}
[data-theme="retro"] .archive-node-row {
min-height: 1.8rem;
border-radius: 0;
color: #000000;
}
[data-theme="retro"] .archive-node-row:hover {
background: #000078;
color: #ffffff;
}
[data-theme="retro"] .archive-node-size {
color: #404040;
}
[data-theme="retro"] .archive-node-row:hover .archive-node-size {
color: #ffffff;
}
[data-theme="retro"] .archive-chevron {
color: #000000;
}
[data-theme="retro"] .archive-folder[open] > summary .archive-chevron {
color: #000078;
}
[data-theme="retro"] .archive-node-row:hover .archive-chevron,
[data-theme="retro"] .archive-node-row:hover .archive-file-icon {
color: #ffffff;
}
[data-theme="retro"] .archive-chevron svg {
width: 1.35rem;
height: 1.35rem;
stroke-width: 2.4;
}
[data-theme="retro"] .archive-file-icon {
width: 1.45rem;
height: 1.45rem;
color: #000000;
}
[data-theme="retro"] .archive-svg-icon {
display: none;
}
[data-theme="retro"] .archive-retro-icon {
display: block;
}
[data-theme="retro"] .archive-browser-empty,
[data-theme="retro"] .archive-browser-legacy {
color: #000000;
background: #ffffff;
font-family: "PixelOperatorMono", "Courier New", monospace;
}
.archive-node-name {
min-width: 0;
overflow-wrap: anywhere;
}
.archive-node-size {
color: var(--muted-foreground);
font-size: 0.78rem;
}
.archive-browser-empty {
margin: 0;
padding: 1rem;
color: var(--muted-foreground);
}
.archive-browser-legacy {
min-width: max-content;
margin: 0;
padding: 1rem;
color: var(--foreground);
font-family: Consolas, Monaco, "Andale Mono", "Ubuntu Mono", monospace;
font-size: 0.88rem;
line-height: 1.55;
white-space: pre;
}
.preview-placeholder {
display: grid;
place-items: center;
@@ -278,6 +561,7 @@
.preview-placeholder[hidden],
.default-preview[hidden],
.native-preview[hidden],
.archive-browser-preview[hidden],
.large-preview-gate[hidden],
.code-preview[hidden],
.render-preview[hidden] {

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

View File

@@ -16,6 +16,8 @@
sourceURL: preview.dataset.sourceUrl || "",
downloadURL: preview.dataset.downloadUrl || "",
iconURL: preview.dataset.iconUrl || "",
sceneURL: preview.dataset.sceneUrl || "",
archiveURL: preview.dataset.archiveUrl || "",
activeMode: "",
defaultMode: "default",
pendingMode: "",
@@ -24,6 +26,11 @@
rawLoaded: false,
prismLoaded: false,
renderLoaded: false,
sceneLoaded: false,
archiveLoaded: false,
archiveUIRendered: false,
archiveData: null,
archiveText: "",
renderFullscreenFallback: false,
confirmedLargeModes: {},
tabs: []
@@ -35,11 +42,15 @@
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]"),
codePane: preview.querySelector("[data-code-preview]"),
codeOutput: preview.querySelector("[data-code-output]"),
archiveBrowserPane: preview.querySelector("[data-archive-browser-preview]"),
archivePane: preview.querySelector("[data-archive-preview]"),
archiveOutput: preview.querySelector("[data-archive-output]"),
renderPane: preview.querySelector("[data-render-preview]"),
fullscreenButton: preview.querySelector("[data-render-fullscreen]"),
gatePane: preview.querySelector("[data-large-preview-gate]"),
@@ -65,6 +76,7 @@
var isImage = state.previewKind === "image" || baseType.indexOf("image/") === 0 && baseType !== "image/svg+xml";
var isVideo = state.previewKind === "video" || baseType.indexOf("video/") === 0;
var isAudio = state.previewKind === "audio" || baseType.indexOf("audio/") === 0;
var isArchive = Boolean(state.archiveURL) && isArchiveFile(extension, baseType);
return {
extension: extension,
@@ -76,6 +88,7 @@
isImage: isImage,
isVideo: isVideo,
isAudio: isAudio,
isArchive: isArchive,
isMobile: window.matchMedia && window.matchMedia("(max-width: 720px), (pointer: coarse)").matches
};
}
@@ -90,6 +103,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;
}
@@ -98,6 +114,12 @@
return tabs;
}
if (type.isArchive) {
tabs.push({ mode: "archive-ui", label: "Archive Preview" });
tabs.push({ mode: "archive", label: "Text Tree" });
return tabs;
}
if (type.isTextLike) {
if (type.isHTML || type.isMarkdown) {
tabs.push({ mode: "render", label: "Render Preview" });
@@ -116,6 +138,9 @@
if (type.isVideo) {
return "video";
}
if (type.isArchive) {
return "archive-ui";
}
if (state.sizeBytes > LARGE_PREVIEW_BYTES) {
if (type.isAudio && hasMode(tabs, "browser-audio")) {
return "browser-audio";
@@ -181,6 +206,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") {
@@ -189,6 +217,12 @@
} else if (mode === "code") {
show(els.codePane);
ensurePrismPreview();
} else if (mode === "archive-ui") {
show(els.archiveBrowserPane);
ensureArchiveBrowserPreview();
} else if (mode === "archive") {
show(els.archivePane);
ensureArchivePreview();
} else if (mode === "render") {
show(els.renderPane);
if (fileType.isMarkdown) {
@@ -403,9 +437,12 @@
hide(els.defaultPane);
hide(els.imagePane);
hide(els.videoPane);
hide(els.videoScenesPane);
hide(els.browserAudioPane);
hide(els.rawPane);
hide(els.codePane);
hide(els.archiveBrowserPane);
hide(els.archivePane);
hide(els.renderPane);
hide(els.gatePane);
hide(els.placeholder);
@@ -498,14 +535,275 @@
"default": "Default",
"image": "Image preview",
"video": "Video preview",
"scenes": "Scenes preview",
"browser-audio": "Browser preview",
"raw": "Raw preview",
"code": "Code preview",
"archive-ui": "Archive preview",
"archive": "Archive preview",
"render": "Render 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 ensureArchivePreview() {
if (state.archiveLoaded || !els.archiveOutput || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
var text = state.archiveText || archiveDataToText(state.archiveData);
els.archiveOutput.textContent = text;
state.archiveLoaded = true;
hide(els.placeholder);
show(els.archivePane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveBrowserPreview() {
if (state.archiveUIRendered || !els.archiveBrowserPane || !state.archiveURL) {
return;
}
ensureArchiveData()
.then(function () {
renderArchiveBrowser(state.archiveData);
state.archiveUIRendered = true;
hide(els.placeholder);
show(els.archiveBrowserPane);
})
.catch(function () {
showError("Archive preview could not be loaded.");
});
}
function ensureArchiveData() {
if (state.archiveData || state.archiveText) {
return Promise.resolve();
}
showLoading("Loading archive contents...");
return fetch(state.archiveURL, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("Archive preview could not be loaded.");
}
return response.text();
})
.then(function (text) {
try {
state.archiveData = JSON.parse(text);
} catch (error) {
state.archiveText = text;
}
});
}
function renderArchiveBrowser(data) {
if (!els.archiveBrowserPane) {
return;
}
els.archiveBrowserPane.innerHTML = "";
if (!data || !data.root) {
var fallback = document.createElement("pre");
fallback.className = "archive-browser-legacy";
fallback.textContent = state.archiveText || "Archive preview is unavailable.";
els.archiveBrowserPane.appendChild(fallback);
return;
}
var header = document.createElement("div");
header.className = "archive-browser-header";
header.innerHTML = "<strong></strong><span></span>";
header.querySelector("strong").textContent = data.name || state.fileName || "Archive";
header.querySelector("span").textContent = [
data.type || "Archive",
formatArchiveCount(data.fileCount, "file"),
formatArchiveCount(data.folderCount, "folder"),
formatBytes(data.uncompressedSize || 0)
].filter(Boolean).join(" · ");
els.archiveBrowserPane.appendChild(header);
var tree = document.createElement("div");
tree.className = "archive-tree";
var items = data.root.items || [];
if (items.length === 0) {
var emptyTree = document.createElement("p");
emptyTree.className = "archive-browser-empty";
emptyTree.textContent = "This archive is empty.";
tree.appendChild(emptyTree);
} else {
items.forEach(function (item) {
tree.appendChild(renderArchiveNode(item, 0));
});
}
els.archiveBrowserPane.appendChild(tree);
}
function renderArchiveNode(node, depth) {
var row = document.createElement(node.dir ? "details" : "div");
row.className = node.dir ? "archive-node archive-folder" : "archive-node archive-file";
if (node.dir && depth < 1) {
row.open = true;
}
var summary = document.createElement(node.dir ? "summary" : "div");
summary.className = "archive-node-row";
summary.style.paddingLeft = (0.45 + depth * 1.15).toFixed(2) + "rem";
if (node.dir) {
summary.appendChild(createArchiveChevron());
} else {
var spacer = document.createElement("span");
spacer.className = "archive-chevron-spacer";
summary.appendChild(spacer);
}
summary.appendChild(createArchiveIcon(node.icon || (node.dir ? "folder" : "file")));
var name = document.createElement("span");
name.className = "archive-node-name";
name.textContent = node.name + (node.dir ? "/" : "");
summary.appendChild(name);
if (!node.dir) {
var size = document.createElement("span");
size.className = "archive-node-size";
size.textContent = formatBytes(node.size || 0);
summary.appendChild(size);
}
row.appendChild(summary);
if (node.dir) {
(node.items || []).forEach(function (child) {
row.appendChild(renderArchiveNode(child, depth + 1));
});
}
return row;
}
function createArchiveChevron() {
var chevron = document.createElement("span");
chevron.className = "archive-chevron";
chevron.setAttribute("aria-hidden", "true");
chevron.innerHTML = '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 6 6 6-6 6"/></svg>';
return chevron;
}
function createArchiveIcon(icon) {
var element = document.createElement("span");
element.className = "archive-file-icon archive-file-icon-" + icon;
element.setAttribute("aria-hidden", "true");
element.innerHTML = '<span class="archive-svg-icon">' + archiveIconSVG(icon) + '</span>';
var retroURL = archiveRetroIconURL(icon);
if (retroURL) {
var retro = document.createElement("img");
retro.className = "archive-retro-icon";
retro.src = retroURL;
retro.alt = "";
retro.decoding = "async";
retro.loading = "lazy";
element.appendChild(retro);
}
return element;
}
function archiveDataToText(data) {
if (!data || !data.root) {
return state.archiveText || "";
}
var lines = [
"Archive preview",
"Name: " + (data.name || state.fileName || "Archive"),
"Type: " + (data.type || "Archive"),
"Entries: " + (data.fileCount || 0) + " files, " + (data.folderCount || 0) + " folders",
"Uncompressed size: " + formatBytes(data.uncompressedSize || 0),
"",
"."
];
appendArchiveTextLines(lines, data.root.items || [], "");
if (!(data.root.items || []).length) {
lines.push("(empty archive)");
}
return lines.join("\n");
}
function appendArchiveTextLines(lines, items, prefix) {
items.forEach(function (item, index) {
var last = index === items.length - 1;
var branch = last ? "`-- " : "|-- ";
var nextPrefix = prefix + (last ? " " : "| ");
var label = item.dir ? "[DIR] " + item.name + "/" : "[" + (item.icon || "file").toUpperCase() + "] " + item.name + " (" + formatBytes(item.size || 0) + ")";
lines.push(prefix + branch + label);
if (item.dir) {
appendArchiveTextLines(lines, item.items || [], nextPrefix);
}
});
}
function archiveIconSVG(icon) {
var icons = {
folder: '<svg viewBox="0 0 24 24" focusable="false"><path d="M3 6.75A2.75 2.75 0 0 1 5.75 4h4.1l2 2.2h6.4A2.75 2.75 0 0 1 21 8.95v8.3A2.75 2.75 0 0 1 18.25 20H5.75A2.75 2.75 0 0 1 3 17.25Z"/></svg>',
img: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="5" width="16" height="14" rx="2"/><path d="m7 16 3.2-3.2 2.6 2.6 2.2-2.2L19 17"/><circle cx="9" cy="9" r="1.4"/></svg>',
vid: '<svg viewBox="0 0 24 24" focusable="false"><rect x="4" y="6" width="12" height="12" rx="2"/><path d="m16 10 4-2.5v9L16 14"/></svg>',
aud: '<svg viewBox="0 0 24 24" focusable="false"><path d="M9 18V6l10-2v12"/><circle cx="7" cy="18" r="3"/><circle cx="17" cy="16" r="3"/></svg>',
txt: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M9 12h6M9 15h6M9 18h4"/></svg>',
code: '<svg viewBox="0 0 24 24" focusable="false"><path d="m9 8-4 4 4 4M15 8l4 4-4 4M13 5l-2 14"/></svg>',
arc: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5M10 6h2M10 9h2M10 12h2M10 15h2M10 18h2"/></svg>',
file: '<svg viewBox="0 0 24 24" focusable="false"><path d="M7 3h7l4 4v14H7Z"/><path d="M14 3v5h5"/></svg>'
};
return icons[icon] || icons.file;
}
function archiveRetroIconURL(icon) {
var base = "/static/file-icons/retro/";
var icons = {
folder: "directory_open_file_mydocs-4.png",
img: "shimgvw.dll_14_1-2.png",
vid: "wmploc.dll_14_504-2.png",
aud: "wmploc.dll_14_610-2.png",
txt: "shell32.dll_14_151-2.png",
code: "mshtml.dll_14_2660-2.png",
arc: "zipfldr.dll_14_101-2.png",
file: "shell32.dll_14_152-2.png"
};
return base + (icons[icon] || icons.file);
}
function formatArchiveCount(value, label) {
value = Number(value || 0);
return value + " " + label + (value === 1 ? "" : "s");
}
function formatBytes(value) {
value = Number(value || 0);
if (value < 1024) {
return value + " B";
}
var units = ["KiB", "MiB", "GiB", "TiB"];
var size = value / 1024;
for (var i = 0; i < units.length; i++) {
if (size < 1024 || i === units.length - 1) {
return size.toFixed(1) + " " + units[i];
}
size /= 1024;
}
return value + " B";
}
function loadPrism() {
if (window.Prism) {
return Promise.resolve();
@@ -632,6 +930,28 @@
return parts.length > 1 ? parts.pop() : "";
}
function isArchiveFile(extension, baseType) {
var archiveExtensions = {
"apk": true,
"docx": true,
"ear": true,
"epub": true,
"jar": true,
"pptx": true,
"war": true,
"xlsx": true,
"zip": true
};
var archiveTypes = {
"application/epub+zip": true,
"application/java-archive": true,
"application/vnd.android.package-archive": true,
"application/x-zip-compressed": true,
"application/zip": true
};
return Boolean(archiveExtensions[extension] || archiveTypes[baseType]);
}
function languageFor(extension, baseType) {
var extensionMap = {
"c": "c",

View File

@@ -23,7 +23,7 @@
</a>
</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}}" data-archive-url="{{.Data.File.ArchiveURL}}">
<div class="preview-window-titlebar">
<div>
<strong data-preview-mode-label>Preview</strong>
@@ -49,6 +49,7 @@
</div>
<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>
{{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>
<div class="code-preview raw-code-preview" data-raw-preview hidden>
<pre><code data-raw-output></code></pre>
@@ -56,6 +57,10 @@
<div class="code-preview prism-code-preview" data-code-preview hidden>
<pre class="line-numbers"><code data-code-output></code></pre>
</div>
{{if .Data.File.HasArchive}}<div class="archive-browser-preview" data-archive-browser-preview hidden></div>
<div class="archive-preview code-preview" data-archive-preview hidden>
<pre><code data-archive-output></code></pre>
</div>{{end}}
<iframe class="render-preview" data-render-preview title="Rendered preview of {{.Data.File.Name}}" sandbox hidden></iframe>
<div class="large-preview-gate" data-large-preview-gate hidden>
<strong>Large preview</strong>