Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ export abstract class Constants {
</svg>`;

// 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);
Expand Down
4 changes: 2 additions & 2 deletions app/src/util/image.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
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;
};

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;
Expand Down
2 changes: 2 additions & 0 deletions kernel/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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/jaypipes/ghw v0.21.2
github.com/jinzhu/copier v0.4.0
github.com/json-iterator/go v1.1.12
Expand All @@ -55,6 +56,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.12
Expand Down
4 changes: 4 additions & 0 deletions kernel/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6h
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=
Expand Down Expand Up @@ -365,6 +367,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=
Expand Down
229 changes: 219 additions & 10 deletions kernel/model/assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,12 @@ package model

import (
"bytes"
"crypto/sha1"
"encoding/hex"
"errors"
"fmt"
"image"
"image/jpeg"
"io/fs"
"mime"
"net/http"
Expand All @@ -29,6 +33,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"

"github.com/88250/go-humanize"
Expand All @@ -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"
Expand All @@ -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 {
Expand All @@ -74,7 +84,7 @@ func HandleAssetsRemoveEvent(assetAbsPath string) {
}

removeIndexAssetContent(assetAbsPath)
removeAssetThumbnail(assetAbsPath)
removeAssetCache(assetAbsPath)
}

func HandleAssetsChangeEvent(assetAbsPath string) {
Expand All @@ -87,19 +97,200 @@ 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 {
Expand All @@ -114,6 +305,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 {
Expand Down
1 change: 1 addition & 0 deletions kernel/model/conf.go
Original file line number Diff line number Diff line change
Expand Up @@ -1170,6 +1170,7 @@ func clearWorkspaceTemp() {
os.RemoveAll(filepath.Join(util.TempDir, "blocktree.msgpack")) // v2.7.2 前旧版的块树数据
os.RemoveAll(filepath.Join(util.DataDir, "%")) // v3.0.6 生成的错误历史文件夹
os.RemoveAll(filepath.Join(util.TempDir, "blocktree")) // v3.1.0 前旧版的块树数据
os.RemoveAll(filepath.Join(util.TempDir, "thumbnails")) // 旧版的缩略图目录
Copy link
Copy Markdown
Contributor Author

@TCOTC TCOTC Jan 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

确定要合并了再按前两条的格式补充版本号、解决冲突


logging.LogInfof("cleared workspace temp")
}
Expand Down
Loading