2026-05-29 22:25:59 +03:00
|
|
|
package jobs
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-31 02:14:10 +03:00
|
|
|
"bytes"
|
|
|
|
|
"context"
|
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-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]
|
|
|
|
|
if file.Thumbnail != "" || !needsThumbnail(*file) {
|
|
|
|
|
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-05-29 23:44:05 +03:00
|
|
|
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 == "" {
|
|
|
|
|
result.Failed++
|
|
|
|
|
continue
|
2026-05-29 22:25:59 +03:00
|
|
|
}
|
2026-05-29 23:44:05 +03:00
|
|
|
|
|
|
|
|
file.Thumbnail = thumbnail
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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-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-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
|
|
|
|
|
}
|
|
|
|
|
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 {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return os.ReadFile(targetPath)
|
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
|
|
|
|
|
}
|