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"
2026-06-10 18:19:45 +03:00
"sync"
2026-05-29 22:25:59 +03:00
"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-06-10 18:19:45 +03:00
var thumbnailJobs sync . WaitGroup
2026-05-29 23:44:05 +03:00
func GenerateThumbnailsForBoxAsync ( uploadService * services . UploadService , logger * slog . Logger , boxID string ) {
2026-06-10 18:19:45 +03:00
thumbnailJobs . Add ( 1 )
2026-05-29 23:44:05 +03:00
go func ( ) {
2026-06-10 18:19:45 +03:00
defer thumbnailJobs . Done ( )
2026-05-29 23:44:05 +03:00
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
}
2026-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
2026-06-16 01:34:13 +03:00
logger . Warn ( "thumbnail one shot skipped trouble box" , "source" , "thumbnail" , "severity" , "warn" , "code" , 4206 , "box_id" , boxID , "error" , services . BoxTroubleReason ( box ) )
2026-06-08 11:53:37 +03:00
return
}
2026-05-29 23:44:05 +03:00
result , err := generateMissingThumbnailsForBox ( uploadService , logger , box )
if err != nil {
2026-06-16 01:34:13 +03:00
logger . Warn ( "thumbnail one shot job failed" , "source" , "thumbnail" , "severity" , "warn" , "code" , 4205 , "box_id" , boxID , "error" , err . Error ( ) )
2026-05-29 23:44:05 +03:00
return
}
if result . Generated > 0 || result . Failed > 0 {
2026-06-16 01:34:13 +03:00
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 23:44:05 +03:00
}
} ( )
}
2026-06-10 18:19:45 +03:00
func WaitForThumbnailJobs ( ) {
thumbnailJobs . Wait ( )
}
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-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
continue
}
2026-05-29 22:25:59 +03:00
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-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
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-08 11:53:37 +03:00
if file . Processing || services . FileHasTrouble ( * file ) {
continue
}
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 ) {
2026-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
return "" , fmt . Errorf ( "box is marked as trouble: %s" , services . BoxTroubleReason ( box ) )
}
if file . Processing {
return "" , fmt . Errorf ( "file is still processing" )
}
if services . FileHasTrouble ( file ) {
return "" , fmt . Errorf ( "file processing failed: %s" , file . ProcessingError )
}
2026-05-29 22:25:59 +03:00
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
}
2026-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
return "" , fmt . Errorf ( "box is marked as trouble: %s" , services . BoxTroubleReason ( box ) )
}
if file . Processing {
return "" , fmt . Errorf ( "file is still processing" )
}
if services . FileHasTrouble ( file ) {
return "" , fmt . Errorf ( "file processing failed: %s" , file . ProcessingError )
}
2026-06-05 10:42:30 +03:00
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
}
2026-06-08 11:53:37 +03:00
if services . BoxHasTrouble ( box ) {
return "" , fmt . Errorf ( "box is marked as trouble: %s" , services . BoxTroubleReason ( box ) )
}
if file . Processing {
return "" , fmt . Errorf ( "file is still processing" )
}
if services . FileHasTrouble ( file ) {
return "" , fmt . Errorf ( "file processing failed: %s" , file . ProcessingError )
}
2026-06-08 03:43:43 +03:00
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
}