Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 GetAssetImgSize(assetPath string) (width, height int) {
absPath, err := GetAssetAbsPath(assetPath)
if err != nil {
Expand Down Expand Up @@ -92,7 +102,7 @@ func HandleAssetsRemoveEvent(assetAbsPath string) {
}

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

func HandleAssetsChangeEvent(assetAbsPath string) {
Expand All @@ -105,19 +115,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 @@ -132,6 +323,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 @@ -1179,6 +1179,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")) // 旧版的缩略图目录

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