2026-05-29 22:25:59 +03:00
|
|
|
package jobs
|
|
|
|
|
|
|
|
|
|
import (
|
2026-06-08 03:43:43 +03:00
|
|
|
"archive/zip"
|
2026-05-31 02:14:10 +03:00
|
|
|
"bytes"
|
|
|
|
|
"context"
|
2026-06-08 03:43:43 +03:00
|
|
|
"encoding/json"
|
2026-06-05 10:42:30 +03:00
|
|
|
"fmt"
|
2026-06-03 14:55:19 +03:00
|
|
|
"html"
|
2026-05-29 22:25:59 +03:00
|
|
|
"image"
|
2026-06-03 14:55:19 +03:00
|
|
|
"image/color"
|
|
|
|
|
"image/draw"
|
2026-05-29 22:25:59 +03:00
|
|
|
_ "image/gif"
|
|
|
|
|
"image/jpeg"
|
|
|
|
|
_ "image/png"
|
2026-05-31 02:14:10 +03:00
|
|
|
"io"
|
2026-05-29 22:25:59 +03:00
|
|
|
"log/slog"
|
|
|
|
|
"os"
|
|
|
|
|
"os/exec"
|
2026-06-03 14:55:19 +03:00
|
|
|
"path/filepath"
|
|
|
|
|
"regexp"
|
2026-06-08 03:43:43 +03:00
|
|
|
"sort"
|
2026-06-05 10:42:30 +03:00
|
|
|
"strconv"
|
2026-05-29 22:25:59 +03:00
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
"golang.org/x/image/font"
|
|
|
|
|
"golang.org/x/image/font/basicfont"
|
|
|
|
|
"golang.org/x/image/math/fixed"
|
2026-05-29 22:25:59 +03:00
|
|
|
_ "golang.org/x/image/webp"
|
|
|
|
|
"warpbox.dev/backend/libs/config"
|
|
|
|
|
"warpbox.dev/backend/libs/services"
|
|
|
|
|
)
|
|
|
|
|
|
2026-05-31 19:52:46 +03:00
|
|
|
type ThumbnailJobResult struct {
|
2026-05-29 22:25:59 +03:00
|
|
|
Scanned int
|
|
|
|
|
Generated int
|
|
|
|
|
Failed int
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
func GenerateThumbnailsForBoxAsync(uploadService *services.UploadService, logger *slog.Logger, boxID string) {
|
|
|
|
|
go func() {
|
|
|
|
|
box, err := uploadService.GetBox(boxID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn("thumbnail box lookup failed", "source", "thumbnail", "severity", "warn", "code", 4204, "box_id", boxID, "error", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
result, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn("thumbnail one-shot job failed", "source", "thumbnail", "severity", "warn", "code", 4205, "box_id", boxID, "error", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if result.Generated > 0 || result.Failed > 0 {
|
|
|
|
|
logger.Info("thumbnail one-shot job complete", "source", "thumbnail", "severity", "user_activity", "code", 2205, "box_id", boxID, "generated", result.Generated, "failed", result.Failed)
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:25:59 +03:00
|
|
|
func newThumbnailsJob(cfg config.Config, logger *slog.Logger, uploadService *services.UploadService) job {
|
|
|
|
|
return job{
|
|
|
|
|
name: "thumbnail",
|
|
|
|
|
enabled: cfg.ThumbnailEnabled,
|
|
|
|
|
interval: cfg.ThumbnailEvery,
|
|
|
|
|
run: func() {
|
|
|
|
|
result, err := generateMissingThumbnails(uploadService, logger)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn("thumbnail job failed", "source", "thumbnail", "severity", "warn", "code", 4203, "error", err.Error())
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if result.Generated > 0 || result.Failed > 0 {
|
|
|
|
|
logger.Info("thumbnail job complete", "source", "thumbnail", "severity", "user_activity", "code", 2204, "generated", result.Generated, "failed", result.Failed)
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 19:52:46 +03:00
|
|
|
func RunThumbnailsNow(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
|
|
|
|
return generateMissingThumbnails(uploadService, logger)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func generateMissingThumbnails(uploadService *services.UploadService, logger *slog.Logger) (ThumbnailJobResult, error) {
|
2026-05-29 22:25:59 +03:00
|
|
|
boxes, err := uploadService.ListBoxes(0)
|
|
|
|
|
if err != nil {
|
2026-05-31 19:52:46 +03:00
|
|
|
return ThumbnailJobResult{}, err
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 19:52:46 +03:00
|
|
|
var result ThumbnailJobResult
|
2026-05-29 22:25:59 +03:00
|
|
|
now := time.Now().UTC()
|
|
|
|
|
for _, box := range boxes {
|
|
|
|
|
if !box.ExpiresAt.After(now) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
boxResult, err := generateMissingThumbnailsForBox(uploadService, logger, box)
|
|
|
|
|
result.Scanned += boxResult.Scanned
|
|
|
|
|
result.Generated += boxResult.Generated
|
|
|
|
|
result.Failed += boxResult.Failed
|
|
|
|
|
if err != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 19:52:46 +03:00
|
|
|
func generateMissingThumbnailsForBox(uploadService *services.UploadService, logger *slog.Logger, box services.Box) (ThumbnailJobResult, error) {
|
|
|
|
|
var result ThumbnailJobResult
|
2026-05-29 23:44:05 +03:00
|
|
|
if !box.ExpiresAt.After(time.Now().UTC()) {
|
|
|
|
|
return result, nil
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
changed := false
|
|
|
|
|
for i := range box.Files {
|
|
|
|
|
file := &box.Files[i]
|
2026-06-05 10:42:30 +03:00
|
|
|
needsPrimary := file.Thumbnail == "" && needsThumbnail(*file)
|
|
|
|
|
needsScenes := file.SceneThumbnail == "" && needsVideoScenes(*file)
|
2026-06-08 03:43:43 +03:00
|
|
|
needsArchive := !archiveListingCurrent(*file) && needsArchiveListing(*file)
|
|
|
|
|
if !needsPrimary && !needsScenes && !needsArchive {
|
2026-05-29 23:44:05 +03:00
|
|
|
continue
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
2026-05-29 23:44:05 +03:00
|
|
|
result.Scanned++
|
2026-05-29 22:25:59 +03:00
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
if needsPrimary {
|
|
|
|
|
thumbnail, err := generateThumbnail(uploadService, box, *file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.Warn("thumbnail generation failed", "source", "thumbnail", "severity", "warn", "code", 4101, "file_id", file.ID, "error", err.Error())
|
|
|
|
|
result.Failed++
|
|
|
|
|
} else if thumbnail == "" {
|
|
|
|
|
result.Failed++
|
|
|
|
|
} else {
|
|
|
|
|
file.Thumbnail = thumbnail
|
|
|
|
|
changed = true
|
|
|
|
|
result.Generated++
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
2026-05-29 23:44:05 +03:00
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
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++
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-06-08 03:43:43 +03:00
|
|
|
|
|
|
|
|
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++
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-29 23:44:05 +03:00
|
|
|
if changed {
|
|
|
|
|
if err := uploadService.SaveBox(box); err != nil {
|
|
|
|
|
return result, err
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
return result, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func needsThumbnail(file services.File) bool {
|
2026-06-03 14:55:19 +03:00
|
|
|
return file.PreviewKind == "image" || file.PreviewKind == "video" || isTextThumbnailCandidate(file)
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
func needsVideoScenes(file services.File) bool {
|
|
|
|
|
return file.PreviewKind == "video" || strings.HasPrefix(strings.ToLower(file.ContentType), "video/")
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 15:20:26 +03:00
|
|
|
func NeedsThumbnail(file services.File) bool {
|
|
|
|
|
return needsThumbnail(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
func NeedsVideoScenes(file services.File) bool {
|
|
|
|
|
return needsVideoScenes(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:43:43 +03:00
|
|
|
func NeedsArchiveListing(file services.File) bool {
|
|
|
|
|
return needsArchiveListing(file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 15:20:26 +03:00
|
|
|
func GenerateThumbnailForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
|
|
|
|
return generateThumbnail(uploadService, box, file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
func GenerateVideoScenesForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
|
|
|
|
return generateVideoScenesThumbnail(uploadService, box, file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:43:43 +03:00
|
|
|
func GenerateArchiveListingForFile(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
|
|
|
|
return generateArchiveListing(uploadService, box, file)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:25:59 +03:00
|
|
|
func generateThumbnail(uploadService *services.UploadService, box services.Box, file services.File) (string, error) {
|
|
|
|
|
thumbnailName := "@thumb@" + file.ID + ".jpg"
|
2026-05-31 02:14:10 +03:00
|
|
|
object, err := uploadService.OpenFileObject(context.Background(), box, file)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
defer object.Body.Close()
|
2026-05-29 22:25:59 +03:00
|
|
|
|
|
|
|
|
switch {
|
|
|
|
|
case strings.HasPrefix(file.ContentType, "image/"):
|
2026-05-31 02:14:10 +03:00
|
|
|
data, err := createImageThumbnail(object.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
|
|
|
|
return thumbnailName, err
|
2026-05-29 22:25:59 +03:00
|
|
|
case strings.HasPrefix(file.ContentType, "video/"):
|
2026-05-31 02:14:10 +03:00
|
|
|
data, err := createVideoThumbnail(object.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
|
|
|
|
return thumbnailName, err
|
2026-06-03 14:55:19 +03:00
|
|
|
case isTextThumbnailCandidate(file):
|
|
|
|
|
data, err := createTextThumbnail(file, object.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
_, err = uploadService.PutThumbnailObject(context.Background(), box, thumbnailName, bytes.NewReader(data), int64(len(data)), "image/jpeg")
|
|
|
|
|
return thumbnailName, err
|
2026-05-29 22:25:59 +03:00
|
|
|
default:
|
|
|
|
|
return "", nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-05 10:42:30 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:43:43 +03:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
func isTextThumbnailCandidate(file services.File) bool {
|
|
|
|
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
|
|
|
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
|
|
|
|
contentType = strings.TrimSpace(contentType[:i])
|
|
|
|
|
}
|
|
|
|
|
if strings.HasPrefix(contentType, "text/") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
switch contentType {
|
|
|
|
|
case "application/json", "application/ld+json", "application/xml", "application/javascript", "application/x-javascript", "application/markdown":
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
|
|
|
|
switch ext {
|
|
|
|
|
case "c", "cc", "conf", "cpp", "cs", "css", "csv", "diff", "dockerfile", "go", "h", "hpp", "htm", "html", "ini", "java", "js", "json", "jsx", "kt", "log", "lua", "md", "mdown", "markdown", "php", "pl", "properties", "py", "rb", "rs", "sh", "sql", "swift", "toml", "ts", "tsx", "txt", "xml", "yaml", "yml", "zig":
|
|
|
|
|
return true
|
|
|
|
|
default:
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-08 03:43:43 +03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
func createImageThumbnail(source io.Reader) ([]byte, error) {
|
2026-05-29 22:25:59 +03:00
|
|
|
img, _, err := image.Decode(source)
|
|
|
|
|
if err != nil {
|
2026-05-31 02:14:10 +03:00
|
|
|
return nil, err
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thumb := resizeNearest(img, 360, 240)
|
2026-05-31 02:14:10 +03:00
|
|
|
var target bytes.Buffer
|
|
|
|
|
err = jpeg.Encode(&target, thumb, &jpeg.Options{Quality: 82})
|
2026-05-29 22:25:59 +03:00
|
|
|
if err != nil {
|
2026-05-31 02:14:10 +03:00
|
|
|
return nil, err
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
2026-05-31 02:14:10 +03:00
|
|
|
return target.Bytes(), nil
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 02:14:10 +03:00
|
|
|
func createVideoThumbnail(source io.Reader) ([]byte, error) {
|
|
|
|
|
sourceFile, err := os.CreateTemp("", "warpbox-video-*")
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-06-05 10:42:30 +03:00
|
|
|
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-*")
|
2026-05-31 02:14:10 +03:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-06-05 10:42:30 +03:00
|
|
|
defer os.Remove(sourceFile.Name())
|
|
|
|
|
if _, err := io.Copy(sourceFile, source); err != nil {
|
|
|
|
|
sourceFile.Close()
|
2026-05-31 02:14:10 +03:00
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-06-05 10:42:30 +03:00
|
|
|
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))
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
|
|
|
|
|
2026-06-03 14:55:19 +03:00
|
|
|
func createTextThumbnail(file services.File, source io.Reader) ([]byte, error) {
|
|
|
|
|
data, err := io.ReadAll(io.LimitReader(source, 128*1024))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
sourceText := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
|
|
|
sourceText = strings.ReplaceAll(sourceText, "\r", "\n")
|
|
|
|
|
|
|
|
|
|
mode := textThumbnailMode(file)
|
|
|
|
|
title := strings.ToUpper(mode)
|
|
|
|
|
var lines []string
|
|
|
|
|
if mode == "HTML" {
|
|
|
|
|
lines = renderedHTMLThumbnailLines(sourceText)
|
|
|
|
|
} else if mode == "MARKDOWN" {
|
|
|
|
|
lines = renderedMarkdownThumbnailLines(sourceText)
|
|
|
|
|
} else {
|
|
|
|
|
title = "CODE"
|
|
|
|
|
lines = codeThumbnailLines(sourceText)
|
|
|
|
|
}
|
|
|
|
|
return renderTextThumbnail(file.Name, title, lines), nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func textThumbnailMode(file services.File) string {
|
|
|
|
|
contentType := strings.ToLower(strings.TrimSpace(file.ContentType))
|
|
|
|
|
if i := strings.IndexByte(contentType, ';'); i >= 0 {
|
|
|
|
|
contentType = strings.TrimSpace(contentType[:i])
|
|
|
|
|
}
|
|
|
|
|
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(file.Name)), ".")
|
|
|
|
|
if ext == "html" || ext == "htm" || contentType == "text/html" {
|
|
|
|
|
return "HTML"
|
|
|
|
|
}
|
|
|
|
|
if ext == "md" || ext == "mdown" || ext == "markdown" || contentType == "text/markdown" || contentType == "application/markdown" {
|
|
|
|
|
return "MARKDOWN"
|
|
|
|
|
}
|
|
|
|
|
return "CODE"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderedHTMLThumbnailLines(source string) []string {
|
|
|
|
|
text := regexp.MustCompile(`(?is)<script[^>]*>.*?</script>`).ReplaceAllString(source, " ")
|
|
|
|
|
text = regexp.MustCompile(`(?is)<style[^>]*>.*?</style>`).ReplaceAllString(text, " ")
|
|
|
|
|
text = regexp.MustCompile(`(?i)</?(p|div|section|article|main|header|footer|br|li|ul|ol|h[1-6]|tr|table|blockquote|pre|code)[^>]*>`).ReplaceAllString(text, "\n")
|
|
|
|
|
text = regexp.MustCompile(`(?s)<[^>]+>`).ReplaceAllString(text, " ")
|
|
|
|
|
text = html.UnescapeString(text)
|
|
|
|
|
return documentThumbnailLines(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderedMarkdownThumbnailLines(source string) []string {
|
|
|
|
|
text := regexp.MustCompile("(?s)```.*?```").ReplaceAllStringFunc(source, func(block string) string {
|
|
|
|
|
block = strings.Trim(block, "` \n\t")
|
|
|
|
|
lines := strings.Split(block, "\n")
|
|
|
|
|
if len(lines) > 1 {
|
|
|
|
|
lines = lines[1:]
|
|
|
|
|
}
|
|
|
|
|
return "\n" + strings.Join(lines, "\n") + "\n"
|
|
|
|
|
})
|
|
|
|
|
text = regexp.MustCompile(`(?m)^#{1,6}\s*`).ReplaceAllString(text, "")
|
|
|
|
|
text = regexp.MustCompile(`!\[([^\]]*)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
|
|
|
|
text = regexp.MustCompile(`\[([^\]]+)\]\([^)]+\)`).ReplaceAllString(text, "$1")
|
|
|
|
|
text = regexp.MustCompile("`([^`]+)`").ReplaceAllString(text, "$1")
|
|
|
|
|
text = strings.NewReplacer("**", "", "__", "", "*", "", "_", "", "~~", "").Replace(text)
|
|
|
|
|
return documentThumbnailLines(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func documentThumbnailLines(source string) []string {
|
|
|
|
|
source = regexp.MustCompile(`[ \t]+`).ReplaceAllString(source, " ")
|
|
|
|
|
rawLines := strings.Split(source, "\n")
|
|
|
|
|
lines := make([]string, 0, 9)
|
|
|
|
|
for _, raw := range rawLines {
|
|
|
|
|
raw = strings.TrimSpace(raw)
|
|
|
|
|
if raw == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, line := range wrapTextThumbnailLine(raw, 43) {
|
|
|
|
|
lines = append(lines, line)
|
|
|
|
|
if len(lines) >= 9 {
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(lines) == 0 {
|
|
|
|
|
return []string{"Rendered preview is empty."}
|
|
|
|
|
}
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func codeThumbnailLines(source string) []string {
|
|
|
|
|
rawLines := strings.Split(source, "\n")
|
|
|
|
|
lines := make([]string, 0, 10)
|
|
|
|
|
for _, raw := range rawLines {
|
|
|
|
|
raw = strings.ReplaceAll(raw, "\t", " ")
|
|
|
|
|
raw = strings.TrimRight(raw, " ")
|
|
|
|
|
if strings.TrimSpace(raw) == "" && len(lines) == 0 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(raw) > 48 {
|
|
|
|
|
raw = raw[:45] + "..."
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, raw)
|
|
|
|
|
if len(lines) >= 10 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if len(lines) == 0 {
|
|
|
|
|
return []string{"(empty file)"}
|
|
|
|
|
}
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func renderTextThumbnail(name, mode string, lines []string) []byte {
|
|
|
|
|
canvas := image.NewRGBA(image.Rect(0, 0, 360, 240))
|
|
|
|
|
drawSolid(canvas, canvas.Bounds(), color.RGBA{R: 0x0b, G: 0x0b, B: 0x16, A: 0xff})
|
|
|
|
|
drawSolid(canvas, image.Rect(10, 10, 350, 230), color.RGBA{R: 0x17, G: 0x14, B: 0x2d, A: 0xff})
|
|
|
|
|
drawSolid(canvas, image.Rect(10, 10, 350, 16), color.RGBA{R: 0xa7, G: 0x8b, B: 0xfa, A: 0xff})
|
|
|
|
|
|
|
|
|
|
face := basicfont.Face7x13
|
|
|
|
|
drawThumbText(canvas, face, trimThumbnailText(name, 38), 22, 36, color.RGBA{R: 0xf5, G: 0xf3, B: 0xff, A: 0xff})
|
|
|
|
|
drawThumbText(canvas, face, mode+" PREVIEW", 22, 55, color.RGBA{R: 0x67, G: 0xe8, B: 0xf9, A: 0xff})
|
|
|
|
|
|
|
|
|
|
codePane := image.Rect(22, 72, 338, 210)
|
|
|
|
|
if mode == "CODE" {
|
|
|
|
|
drawSolid(canvas, codePane, color.RGBA{R: 0x0f, G: 0x11, B: 0x1a, A: 0xff})
|
|
|
|
|
} else {
|
|
|
|
|
drawSolid(canvas, codePane, color.RGBA{R: 0x21, G: 0x1b, B: 0x3e, A: 0xff})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
y := 91
|
|
|
|
|
for _, line := range lines {
|
|
|
|
|
drawThumbText(canvas, face, line, 32, y, color.RGBA{R: 0xf8, G: 0xfa, B: 0xfc, A: 0xff})
|
|
|
|
|
y += 14
|
|
|
|
|
if y > 202 {
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var target bytes.Buffer
|
|
|
|
|
_ = jpeg.Encode(&target, canvas, &jpeg.Options{Quality: 84})
|
|
|
|
|
return target.Bytes()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func drawSolid(dst *image.RGBA, rect image.Rectangle, c color.Color) {
|
|
|
|
|
draw.Draw(dst, rect, &image.Uniform{c}, image.Point{}, draw.Src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func drawThumbText(dst *image.RGBA, face font.Face, text string, x, y int, c color.Color) {
|
|
|
|
|
d := font.Drawer{
|
|
|
|
|
Dst: dst,
|
|
|
|
|
Src: image.NewUniform(c),
|
|
|
|
|
Face: face,
|
|
|
|
|
Dot: fixed.P(x, y),
|
|
|
|
|
}
|
|
|
|
|
d.DrawString(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func wrapTextThumbnailLine(text string, maxChars int) []string {
|
|
|
|
|
if len(text) <= maxChars {
|
|
|
|
|
return []string{text}
|
|
|
|
|
}
|
|
|
|
|
words := strings.Fields(text)
|
|
|
|
|
if len(words) == 0 {
|
|
|
|
|
return []string{text[:maxChars-3] + "..."}
|
|
|
|
|
}
|
|
|
|
|
lines := []string{}
|
|
|
|
|
current := ""
|
|
|
|
|
for _, word := range words {
|
|
|
|
|
if current == "" {
|
|
|
|
|
current = word
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
if len(current)+1+len(word) <= maxChars {
|
|
|
|
|
current += " " + word
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
|
|
|
|
current = word
|
|
|
|
|
}
|
|
|
|
|
if current != "" {
|
|
|
|
|
lines = append(lines, trimThumbnailText(current, maxChars))
|
|
|
|
|
}
|
|
|
|
|
return lines
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func trimThumbnailText(text string, maxChars int) string {
|
|
|
|
|
if len(text) <= maxChars {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
if maxChars <= 3 {
|
|
|
|
|
return text[:maxChars]
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSpace(text[:maxChars-3]) + "..."
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-29 22:25:59 +03:00
|
|
|
func resizeNearest(src image.Image, maxWidth, maxHeight int) *image.RGBA {
|
|
|
|
|
bounds := src.Bounds()
|
|
|
|
|
width := bounds.Dx()
|
|
|
|
|
height := bounds.Dy()
|
|
|
|
|
if width <= 0 || height <= 0 {
|
|
|
|
|
return image.NewRGBA(image.Rect(0, 0, 1, 1))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
scale := min(float64(maxWidth)/float64(width), float64(maxHeight)/float64(height))
|
|
|
|
|
if scale > 1 {
|
|
|
|
|
scale = 1
|
|
|
|
|
}
|
|
|
|
|
targetWidth := max(1, int(float64(width)*scale))
|
|
|
|
|
targetHeight := max(1, int(float64(height)*scale))
|
|
|
|
|
dst := image.NewRGBA(image.Rect(0, 0, targetWidth, targetHeight))
|
|
|
|
|
|
|
|
|
|
for y := 0; y < targetHeight; y++ {
|
|
|
|
|
for x := 0; x < targetWidth; x++ {
|
|
|
|
|
srcX := bounds.Min.X + int(float64(x)/scale)
|
|
|
|
|
srcY := bounds.Min.Y + int(float64(y)/scale)
|
|
|
|
|
dst.Set(x, y, src.At(srcX, srcY))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return dst
|
|
|
|
|
}
|