From 32380aae73b1fe77bfa610cc03c7db754c59bc4b Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:30:38 +0800 Subject: [PATCH 1/3] =?UTF-8?q?:sparkles:=20=E6=94=AF=E6=8C=81=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20HEIF/HEIC=20=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/constants.ts | 2 +- app/src/util/image.ts | 4 ++-- kernel/go.mod | 2 ++ kernel/go.sum | 4 ++++ kernel/model/conf.go | 1 + kernel/util/path.go | 2 +- 6 files changed, 11 insertions(+), 4 deletions(-) diff --git a/app/src/constants.ts b/app/src/constants.ts index a5154561b60..83b0c4c98d6 100644 --- a/app/src/constants.ts +++ b/app/src/constants.ts @@ -767,7 +767,7 @@ export abstract class Constants { `; // assets - public static readonly SIYUAN_ASSETS_IMAGE: string[] = [".apng", ".ico", ".cur", ".jpg", ".jpe", ".jpeg", ".jfif", ".pjp", ".pjpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif", ".tiff", ".tif"]; + public static readonly SIYUAN_ASSETS_IMAGE: string[] = [".apng", ".ico", ".cur", ".jpg", ".jpe", ".jpeg", ".jfif", ".pjp", ".pjpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif", ".tiff", ".tif", ".heic", ".heif"]; public static readonly SIYUAN_ASSETS_AUDIO: string[] = [".mp3", ".wav", ".ogg", ".m4a", ".aac", ".flac"]; public static readonly SIYUAN_ASSETS_VIDEO: string[] = [".mov", ".weba", ".mkv", ".mp4", ".webm"]; public static readonly SIYUAN_ASSETS_EXTS: string[] = [".pdf"].concat(Constants.SIYUAN_ASSETS_IMAGE, Constants.SIYUAN_ASSETS_AUDIO, Constants.SIYUAN_ASSETS_VIDEO); diff --git a/app/src/util/image.ts b/app/src/util/image.ts index 9c9c83ea910..781a067ab1c 100644 --- a/app/src/util/image.ts +++ b/app/src/util/image.ts @@ -1,6 +1,6 @@ export const getCompressURL = (url: string) => { if (url.startsWith("assets/") && - (url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg"))) { + (url.endsWith(".png") || url.endsWith(".jpg") || url.endsWith(".jpeg") || url.endsWith(".heic") || url.endsWith(".heif"))) { return url + "?style=thumb"; } return url; @@ -8,7 +8,7 @@ export const getCompressURL = (url: string) => { export const removeCompressURL = (url: string) => { if (url.startsWith("assets/") && - (url.endsWith(".png?style=thumb") || url.endsWith(".jpg?style=thumb") || url.endsWith(".jpeg?style=thumb"))) { + (url.endsWith(".png?style=thumb") || url.endsWith(".jpg?style=thumb") || url.endsWith(".jpeg?style=thumb") || url.endsWith(".heic?style=thumb") || url.endsWith(".heif?style=thumb"))) { return url.replace("?style=thumb", ""); } return url; diff --git a/kernel/go.mod b/kernel/go.mod index 75084f988a5..6dc3e2d0324 100644 --- a/kernel/go.mod +++ b/kernel/go.mod @@ -41,6 +41,7 @@ require ( github.com/gorilla/css v1.0.1 github.com/gorilla/websocket v1.5.3 github.com/imroc/req/v3 v3.57.0 + github.com/jdeng/goheif v0.0.0-20251001174315-babb64285736 github.com/jinzhu/copier v0.4.0 github.com/json-iterator/go v1.1.12 github.com/klippa-app/go-pdfium v1.17.2 @@ -54,6 +55,7 @@ require ( github.com/pdfcpu/pdfcpu v0.11.0 github.com/radovskyb/watcher v1.0.7 github.com/rqlite/sql v0.0.0-20251204023435-65660522892e + github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 github.com/sashabaranov/go-openai v1.41.2 github.com/shirou/gopsutil/v4 v4.25.11 diff --git a/kernel/go.sum b/kernel/go.sum index a504ebc0cd4..82a9e8ecd1f 100644 --- a/kernel/go.sum +++ b/kernel/go.sum @@ -247,6 +247,8 @@ github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg github.com/jaytaylor/html2text v0.0.0-20180606194806-57d518f124b0/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056 h1:iCHtR9CQyktQ5+f3dMVZfwD2KWJUgm7M0gdL9NGr8KA= github.com/jaytaylor/html2text v0.0.0-20230321000545-74c2419ad056/go.mod h1:CVKlgaMiht+LXvHG173ujK6JUhZXKb2u/BQtjPDIvyk= +github.com/jdeng/goheif v0.0.0-20251001174315-babb64285736 h1:8p2uq8IfUtGXUYvV9EFpP5FQKgcXVcGoGjT/P8N4KoA= +github.com/jdeng/goheif v0.0.0-20251001174315-babb64285736/go.mod h1:whEdtAJfm8ia675sbmIATUVAT/P9gnb7zHpR3hzqst0= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= @@ -362,6 +364,8 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rqlite/sql v0.0.0-20251204023435-65660522892e h1:ccOm5zC6YqJtBrMmtiNcLPjFyWzB+TDY+fDIlQNsIFw= github.com/rqlite/sql v0.0.0-20251204023435-65660522892e/go.mod h1:ib9zVtNgRKiGuoMyUqqL5aNpk+r+++YlyiVIkclVqPg= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK3dcGsnCnO41eRBOnY12zwkn5qVwgc= +github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI= github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs= github.com/sashabaranov/go-openai v1.41.2 h1:vfPRBZNMpnqu8ELsclWcAvF19lDNgh1t6TVfFFOPiSM= diff --git a/kernel/model/conf.go b/kernel/model/conf.go index fd3b3219619..333822e4afa 100644 --- a/kernel/model/conf.go +++ b/kernel/model/conf.go @@ -1087,6 +1087,7 @@ func clearWorkspaceTemp() { os.RemoveAll(filepath.Join(util.TempDir, "base64")) os.RemoveAll(filepath.Join(util.TempDir, "blocktree.msgpack")) // v2.7.2 前旧版的块树数据 os.RemoveAll(filepath.Join(util.TempDir, "blocktree")) // v3.1.0 前旧版的块树数据 + os.RemoveAll(filepath.Join(util.TempDir, "thumbnails")) // 旧版的缩略图目录 // 退出时自动删除超过 7 天的安装包 https://github.com/siyuan-note/siyuan/issues/6128 install := filepath.Join(util.TempDir, "install") diff --git a/kernel/util/path.go b/kernel/util/path.go index e2f39a6ef7d..2b694fda715 100644 --- a/kernel/util/path.go +++ b/kernel/util/path.go @@ -321,7 +321,7 @@ func IsAssetLinkDest(dest []byte) bool { } var ( - SiYuanAssetsImage = []string{".apng", ".ico", ".cur", ".jpg", ".jpe", ".jpeg", ".jfif", ".pjp", ".pjpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif"} + SiYuanAssetsImage = []string{".apng", ".ico", ".cur", ".jpg", ".jpe", ".jpeg", ".jfif", ".pjp", ".pjpeg", ".png", ".gif", ".webp", ".bmp", ".svg", ".avif", ".heic", ".heif"} SiYuanAssetsAudio = []string{".mp3", ".wav", ".ogg", ".m4a", ".flac"} SiYuanAssetsVideo = []string{".mov", ".weba", ".mkv", ".mp4", ".webm"} ) From e7691de6a5cd5067c9455571b355b5984443cf54 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Wed, 31 Dec 2025 23:42:16 +0800 Subject: [PATCH 2/3] =?UTF-8?q?:sparkles:=20=E6=94=AF=E6=8C=81=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20HEIF/HEIC=20=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kernel/server/serve.go | 112 +++++++++++++++++++++++++++++++++++------ kernel/util/file.go | 12 +++-- 2 files changed, 106 insertions(+), 18 deletions(-) diff --git a/kernel/server/serve.go b/kernel/server/serve.go index e55ee92d2c9..be14af70e6a 100644 --- a/kernel/server/serve.go +++ b/kernel/server/serve.go @@ -508,33 +508,117 @@ func serveAssets(ginServer *gin.Engine) { } } - if serveThumbnail(context, p, requestPath) { + if serveThumbnail(context, p) { // 如果请求缩略图服务成功则返回 return } + if serveHeifConversion(context, p) { + // 如果 HEIF 转换服务成功则返回 + return + } + // 返回原始文件 http.ServeFile(context.Writer, context.Request, p) - return }) ginServer.GET("/history/*path", model.CheckAuth, model.CheckAdminRole, func(context *gin.Context) { p := filepath.Join(util.HistoryDir, context.Param("path")) http.ServeFile(context.Writer, context.Request, p) - return }) } -func serveThumbnail(context *gin.Context, assetAbsPath, requestPath string) bool { - if style := context.Query("style"); style == "thumb" && model.NeedGenerateAssetsThumbnail(assetAbsPath) { // 请求缩略图 - thumbnailPath := filepath.Join(util.TempDir, "thumbnails", "assets", requestPath) - if !gulu.File.IsExist(thumbnailPath) { - // 如果缩略图不存在,则生成缩略图 - err := model.GenerateAssetsThumbnail(assetAbsPath, thumbnailPath) - if err != nil { - logging.LogErrorf("generate thumbnail failed: %s", err) - return false - } +// isHeifSupported 检测客户端是否支持 HEIF 格式 +func isHeifSupported(c *gin.Context) bool { + userAgentStr := c.GetHeader("User-Agent") + if userAgentStr == "" { + return false + } + + // iOS 应用的 WKWebView 支持 HEIF + if strings.Contains(userAgentStr, "SiYuan/") { + if strings.Contains(userAgentStr, "iOS") { + return true + } + // 其他移动端不支持 HEIF + return false + } + + // 排除 Electron + if strings.Contains(userAgentStr, "Electron") { + return false + } + + // Safari 浏览器支持 HEIF + ua := useragent.New(userAgentStr) + name, _ := ua.Browser() + return name == "Safari" +} + +// serveHeifConversion 处理 HEIF 转换请求 +func serveHeifConversion(context *gin.Context, assetAbsPath string) bool { + if !util.IsHeifImage(assetAbsPath) { + return false + } + + if isHeifSupported(context) { + // 客户端原生支持 HEIF,直接返回原始文件 + return false + } + + hash := model.GetFilenameHash(assetAbsPath) + cachePath := filepath.Join(util.TempDir, "assets-cache", "heif", hash+".jpg") + + // 转换 HEIF 为 JPEG,保持原始尺寸 + err := model.ConvertHeifToJpeg(assetAbsPath, cachePath, hash, 85) + if err != nil { + logging.LogErrorf("convert HEIF to JPEG failed [%s]: %s", assetAbsPath, err) + // 转换失败时返回原始文件 + return false + } + + // 返回转换后的文件 + http.ServeFile(context.Writer, context.Request, cachePath) + return true +} + +// serveHeifThumbnail 处理 HEIF 缩略图请求 +func serveHeifThumbnail(context *gin.Context, assetAbsPath string) bool { + hash := model.GetFilenameHash(assetAbsPath) + thumbnailPath := filepath.Join(util.TempDir, "assets-cache", "thumb", hash+".jpg") + + // 生成 HEIF 缩略图 + err := model.GenerateHeifThumbnail(assetAbsPath, thumbnailPath, hash) + if err != nil { + logging.LogErrorf("generate HEIF thumbnail failed: %s", err) + return false + } + + http.ServeFile(context.Writer, context.Request, thumbnailPath) + return true +} + +func serveThumbnail(context *gin.Context, assetAbsPath string) bool { + style := context.Query("style") + if style != "thumb" { + return false + } + + // HEIF 图片缩略图 + if util.IsHeifImage(assetAbsPath) && serveHeifThumbnail(context, assetAbsPath) { + return true + } + + // 普通图片缩略图 + if model.NeedGenerateAssetsThumbnail(assetAbsPath) { + hash := model.GetFilenameHash(assetAbsPath) + thumbnailPath := filepath.Join(util.TempDir, "assets-cache", "thumb", hash+filepath.Ext(assetAbsPath)) + + // 如果缩略图不存在,则生成缩略图 + err := model.GenerateAssetsThumbnail(assetAbsPath, thumbnailPath) + if err != nil { + logging.LogErrorf("generate thumbnail failed [%s]: %s", assetAbsPath, err) + return false } http.ServeFile(context.Writer, context.Request, thumbnailPath) @@ -548,7 +632,6 @@ func serveRepoDiff(ginServer *gin.Engine) { requestPath := context.Param("path") p := filepath.Join(util.TempDir, "repo", "diff", requestPath) http.ServeFile(context.Writer, context.Request, p) - return }) } @@ -894,7 +977,6 @@ func jwtMiddleware(c *gin.Context) { } c.Set(model.RoleContextKey, model.RoleVisitor) c.Next() - return } func serveFixedStaticFiles(ginServer *gin.Engine) { diff --git a/kernel/util/file.go b/kernel/util/file.go index aebeab4f94e..be2357ef6d7 100644 --- a/kernel/util/file.go +++ b/kernel/util/file.go @@ -322,10 +322,16 @@ func IsSubPath(absPath, toCheckPath string) bool { return false } -func IsCompressibleAssetImage(p string) bool { +// IsDirectThumbnailableImage 检查文件是否为具有直接生成缩略图能力的图片格式(PNG、JPG、JPEG) +func IsDirectThumbnailableImage(p string) bool { lowerName := strings.ToLower(p) - return strings.HasPrefix(lowerName, "assets/") && - (strings.HasSuffix(lowerName, ".png") || strings.HasSuffix(lowerName, ".jpg") || strings.HasSuffix(lowerName, ".jpeg")) + return strings.HasSuffix(lowerName, ".png") || strings.HasSuffix(lowerName, ".jpg") || strings.HasSuffix(lowerName, ".jpeg") +} + +// IsHeifImage 检查文件是否为 HEIF 图像格式 +func IsHeifImage(p string) bool { + lowerName := strings.ToLower(p) + return strings.HasSuffix(lowerName, ".heic") || strings.HasSuffix(lowerName, ".heif") } func SizeOfDirectory(path string) (size int64, err error) { From 38c9bad97cc954f990be896b8e7c41266aef3129 Mon Sep 17 00:00:00 2001 From: Jeffrey Chen <78434827+TCOTC@users.noreply.github.com> Date: Thu, 1 Jan 2026 04:12:32 +0800 Subject: [PATCH 3/3] =?UTF-8?q?:sparkles:=20=E6=94=AF=E6=8C=81=E9=A2=84?= =?UTF-8?q?=E8=A7=88=20HEIF/HEIC=20=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- kernel/model/assets.go | 229 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 219 insertions(+), 10 deletions(-) diff --git a/kernel/model/assets.go b/kernel/model/assets.go index 601d48b131e..0f0de5caabc 100644 --- a/kernel/model/assets.go +++ b/kernel/model/assets.go @@ -18,8 +18,12 @@ package model import ( "bytes" + "crypto/sha1" + "encoding/hex" "errors" "fmt" + "image" + "image/jpeg" "io/fs" "mime" "net/http" @@ -29,6 +33,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" "github.com/88250/go-humanize" @@ -39,6 +44,8 @@ import ( "github.com/88250/lute/parse" "github.com/disintegration/imaging" "github.com/gabriel-vasile/mimetype" + "github.com/jdeng/goheif" + "github.com/rwcarlsen/goexif/exif" "github.com/siyuan-note/filelock" "github.com/siyuan-note/httpclient" "github.com/siyuan-note/logging" @@ -51,6 +58,9 @@ import ( "github.com/siyuan-note/siyuan/kernel/util" ) +// convertingTasks 用于并发控制,避免同一文件重复转换 +var convertingTasks sync.Map // map[string]*sync.Once + func GetAssetPathByHash(hash string) string { assetHash := cache.GetAssetHash(hash) if nil == assetHash { @@ -66,24 +76,205 @@ func GetAssetPathByHash(hash string) string { func HandleAssetsRemoveEvent(assetAbsPath string) { removeIndexAssetContent(assetAbsPath) - removeAssetThumbnail(assetAbsPath) + removeAssetCache(assetAbsPath) } func HandleAssetsChangeEvent(assetAbsPath string) { indexAssetContent(assetAbsPath) - removeAssetThumbnail(assetAbsPath) + removeAssetCache(assetAbsPath) } -func removeAssetThumbnail(assetAbsPath string) { - if util.IsCompressibleAssetImage(assetAbsPath) { - p := filepath.ToSlash(assetAbsPath) - idx := strings.Index(p, "assets/") - if -1 == idx { - return - } - thumbnailPath := filepath.Join(util.TempDir, "thumbnails", "assets", p[idx+7:]) +// removeAssetCache 删除资源文件缓存 +func removeAssetCache(assetAbsPath string) { + if util.IsDirectThumbnailableImage(assetAbsPath) { + hash := GetFilenameHash(assetAbsPath) + thumbnailPath := filepath.Join(util.TempDir, "assets-cache", "thumb", hash+filepath.Ext(assetAbsPath)) os.RemoveAll(thumbnailPath) + convertingTasks.Delete(hash + "_thumb") // 清理 sync.Map,允许重新转换 + } else if util.IsHeifImage(assetAbsPath) { + hash := GetFilenameHash(assetAbsPath) + + thumbnailPath := filepath.Join(util.TempDir, "assets-cache", "thumb", hash+".jpg") + os.RemoveAll(thumbnailPath) + convertingTasks.Delete(hash + "_thumb") + + heifCachePath := filepath.Join(util.TempDir, "assets-cache", "heif", hash+".jpg") + os.RemoveAll(heifCachePath) + convertingTasks.Delete(hash + "_heif") + } +} + +// GetFilenameHash 计算文件名的 SHA1 hash(包含扩展名) +func GetFilenameHash(assetAbsPath string) string { + filename := filepath.Base(assetAbsPath) + hash := sha1.Sum([]byte(filename)) + return hex.EncodeToString(hash[:]) +} + +// ConvertHeifToJpeg 将 HEIF 文件转换为 JPEG(保持原始尺寸) +func ConvertHeifToJpeg(sourceImgPath, jpegPath, hash string, quality int) error { + if gulu.File.IsExist(jpegPath) { + return nil + } + + once, _ := convertingTasks.LoadOrStore(hash+"_heif", &sync.Once{}) + + var convertErr error + once.(*sync.Once).Do(func() { + convertErr = convertHeifToJpeg(sourceImgPath, jpegPath, quality) + }) + + return convertErr +} + +func convertHeifToJpeg(sourceImgPath, jpegPath string, quality int) (err error) { + start := time.Now() + img, err := decodeHeifImage(sourceImgPath) + if err != nil { + return + } + + if err = os.MkdirAll(filepath.Dir(jpegPath), 0755); err != nil { + return + } + + fo, err := os.Create(jpegPath) + if err != nil { + return + } + defer fo.Close() + + err = jpeg.Encode(fo, img, &jpeg.Options{Quality: quality}) + if err != nil { + return + } + logging.LogDebugf("converted HEIF image [%s] to [%s], quality [%d], cost [%d]ms", sourceImgPath, jpegPath, quality, time.Since(start).Milliseconds()) + return +} + +// decodeHeifImage 解码 HEIF 图像 +func decodeHeifImage(sourceImgPath string) (image.Image, error) { + fi, err := os.Open(sourceImgPath) + if err != nil { + return nil, err + } + defer fi.Close() + + exifData, _ := goheif.ExtractExif(fi) + if _, err = fi.Seek(0, 0); err != nil { + return nil, err + } + + img, err := goheif.Decode(fi) + if err != nil { + return nil, err + } + + if exifData != nil { + img = fixImageOrientation(img, exifData) } + + return img, nil +} + +// fixImageOrientation 根据 EXIF 方向信息修正图像方向 +func fixImageOrientation(img image.Image, exifData []byte) image.Image { + if len(exifData) == 0 { + return img + } + + x, err := exif.Decode(bytes.NewReader(exifData)) + if err != nil { + return img + } + + orientationTag, err := x.Get(exif.Orientation) + if err != nil { + return img + } + + orientation, err := orientationTag.Int(0) + if err != nil { + return img + } + + // 从正确方向恢复原图方向的操作(Mac预览中的描述) -> 从原图方向调整到正确方向的操作 = 等效操作 + switch orientation { + case 1: + // 正常方向,返回原图 + return img + case 2: + // 水平翻转 -> 水平翻转 + return imaging.FlipH(img) + case 3: + // 旋转 180 度 -> 旋转 180 度 + return imaging.Rotate180(img) + case 4: + // 垂直翻转 -> 垂直翻转 + return imaging.FlipV(img) + case 5: + // 逆时针旋转 90 度 + 垂直翻转 -> 顺时针旋转 90 度 + 垂直翻转 = 逆时针旋转 270 度 + 垂直翻转 + return imaging.FlipH(imaging.Rotate270(img)) + case 6: + // 逆时针旋转 90 度 -> 顺时针旋转 90 度 = 逆时针旋转 270 度 + return imaging.Rotate270(img) + case 7: + // 顺时针旋转 90 度 + 垂直翻转 -> 逆时针旋转 90 度 + 垂直翻转 + return imaging.FlipH(imaging.Rotate90(img)) + case 8: + // 顺时针旋转 90 度 -> 逆时针旋转 90 度 + return imaging.Rotate90(img) + default: + // 无 orientation 或未知值,返回原图 + return img + } +} + +// GenerateHeifThumbnail 生成 HEIF 文件的缩略图 +func GenerateHeifThumbnail(sourceImgPath, thumbnailPath, hash string) error { + if gulu.File.IsExist(thumbnailPath) { + return nil + } + + once, _ := convertingTasks.LoadOrStore(hash+"_thumb", &sync.Once{}) + + var convertErr error + once.(*sync.Once).Do(func() { + convertErr = generateHeifThumbnail(sourceImgPath, thumbnailPath) + }) + + return convertErr +} + +func generateHeifThumbnail(sourceImgPath, thumbnailPath string) (err error) { + start := time.Now() + img, err := decodeHeifImage(sourceImgPath) + if err != nil { + return + } + + // 固定最大宽度为 520,计算缩放比例 + bounds := img.Bounds() + maxWidth := 520 + scale := float64(maxWidth) / float64(bounds.Dx()) + resizedImg := imaging.Resize(img, maxWidth, int(float64(bounds.Dy())*scale), imaging.Lanczos) + + if err = os.MkdirAll(filepath.Dir(thumbnailPath), 0755); err != nil { + return + } + + fo, err := os.Create(thumbnailPath) + if err != nil { + return + } + defer fo.Close() + + err = jpeg.Encode(fo, resizedImg, &jpeg.Options{Quality: 85}) + if err != nil { + return + } + logging.LogDebugf("generated HEIF thumbnail [%s] to [%s], cost [%d]ms", sourceImgPath, thumbnailPath, time.Since(start).Milliseconds()) + return } func NeedGenerateAssetsThumbnail(sourceImgPath string) bool { @@ -98,6 +289,24 @@ func NeedGenerateAssetsThumbnail(sourceImgPath string) bool { } func GenerateAssetsThumbnail(sourceImgPath, resizedImgPath string) (err error) { + if gulu.File.IsExist(resizedImgPath) { + return nil + } + + // 获取或创建 sync.Once + key := GetFilenameHash(sourceImgPath) + "_thumb" + once, _ := convertingTasks.LoadOrStore(key, &sync.Once{}) + + var convertErr error + once.(*sync.Once).Do(func() { + convertErr = generateAssetsThumbnail(sourceImgPath, resizedImgPath) + }) + + return convertErr +} + +// generateAssetsThumbnail 执行实际的缩略图生成 +func generateAssetsThumbnail(sourceImgPath, resizedImgPath string) (err error) { start := time.Now() img, err := imaging.Open(sourceImgPath) if err != nil {