A:更新工具类
This commit is contained in:
1 parent
39823db70a
commit
bae2721935
14 files changed
+598
-26
No files matched your search
@@ -7,6 +7,7 @@ require (
|
|||||||
github.com/gin-contrib/pprof v1.5.3
|
github.com/gin-contrib/pprof v1.5.3
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/h2non/filetype v1.1.3
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
|||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||||
|
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
|||||||
@@ -1,10 +1,39 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"rear/internal/consts"
|
||||||
"rear/internal/utils"
|
"rear/internal/utils"
|
||||||
|
"rear/pkg/logger"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ImageCompressionOptions 压缩图像配置
|
||||||
|
type ImageCompressionOptions struct {
|
||||||
|
// 缩略图格式
|
||||||
|
ThumbnailFormat consts.ImageFormat
|
||||||
|
// 缩略图大小
|
||||||
|
ThumbnailSize []int
|
||||||
|
// 缩略图质量
|
||||||
|
ThumbnailQuality int
|
||||||
|
}
|
||||||
|
|
||||||
|
// PathConfig 路径相关配置
|
||||||
|
type PathConfig struct {
|
||||||
|
// 缓存内容存放
|
||||||
|
CachePath string
|
||||||
|
// 缩略图存放路径
|
||||||
|
ThumbnailPath string
|
||||||
|
// 日志路径
|
||||||
|
LogPath string
|
||||||
|
// 临时文件路径
|
||||||
|
TempPath string
|
||||||
|
// png 临时文件
|
||||||
|
PngTempPath string
|
||||||
|
}
|
||||||
|
|
||||||
// Config 配置结构
|
// Config 配置结构
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
@@ -12,16 +41,45 @@ type Config struct {
|
|||||||
ReadTimeout time.Duration
|
ReadTimeout time.Duration
|
||||||
WriteTimeout time.Duration
|
WriteTimeout time.Duration
|
||||||
IdleTimeout time.Duration
|
IdleTimeout time.Duration
|
||||||
// 基础支持的格式
|
// 基础支持的格式【ImageMagick, libvips】
|
||||||
BaseSupportedFileTypes []string
|
BaseSupportedFileTypes []string
|
||||||
// 特殊支持的格式
|
// 特殊支持的格式
|
||||||
SpecialSupportedFileTypes []string
|
SpecialSupportedFileTypes []string
|
||||||
|
// 支持的缩略图格式
|
||||||
|
SupportedThumbnailFormat []string
|
||||||
|
ImageCompressionOption ImageCompressionOptions
|
||||||
|
// Raw 单独支持【rawtherapee-cli, dcraw】
|
||||||
|
// heic 单独支持【heif-convert, magick】
|
||||||
|
|
||||||
|
PathConfig PathConfig
|
||||||
|
|
||||||
|
// 软件运行目录
|
||||||
|
AppPath string
|
||||||
|
AppDir string
|
||||||
}
|
}
|
||||||
|
|
||||||
var CONFIG Config
|
var CONFIG Config
|
||||||
|
|
||||||
// InitConfig 初始化配置
|
// InitConfig 初始化配置
|
||||||
func InitConfig() *Config {
|
func InitConfig() *Config {
|
||||||
|
i := ImageCompressionOptions{
|
||||||
|
ThumbnailFormat: consts.FormatJPG,
|
||||||
|
ThumbnailSize: []int{256, 512, 720},
|
||||||
|
ThumbnailQuality: 80,
|
||||||
|
}
|
||||||
|
pathConfig := PathConfig{
|
||||||
|
CachePath: "cache",
|
||||||
|
ThumbnailPath: "thumbnail",
|
||||||
|
LogPath: "app-logs",
|
||||||
|
TempPath: "app-tmp",
|
||||||
|
PngTempPath: "png-tmp",
|
||||||
|
}
|
||||||
|
|
||||||
|
execPath, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("无法获取程序路径: %v", zap.Error(err))
|
||||||
|
}
|
||||||
|
|
||||||
CONFIG = Config{
|
CONFIG = Config{
|
||||||
Port: utils.GetEnv("PORT", "8080"),
|
Port: utils.GetEnv("PORT", "8080"),
|
||||||
Mode: utils.GetEnv("GIN_MODE", "debug"),
|
Mode: utils.GetEnv("GIN_MODE", "debug"),
|
||||||
@@ -30,6 +88,11 @@ func InitConfig() *Config {
|
|||||||
IdleTimeout: 60 * time.Second,
|
IdleTimeout: 60 * time.Second,
|
||||||
BaseSupportedFileTypes: []string{".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"},
|
BaseSupportedFileTypes: []string{".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp"},
|
||||||
SpecialSupportedFileTypes: []string{".gif", ".heic", ".heif", ".webp", ".avif", ".jxl"},
|
SpecialSupportedFileTypes: []string{".gif", ".heic", ".heif", ".webp", ".avif", ".jxl"},
|
||||||
|
SupportedThumbnailFormat: []string{".jpg", ".webp"},
|
||||||
|
ImageCompressionOption: i,
|
||||||
|
PathConfig: pathConfig,
|
||||||
|
AppPath: execPath,
|
||||||
|
AppDir: filepath.Dir(execPath),
|
||||||
}
|
}
|
||||||
return &CONFIG
|
return &CONFIG
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
type ImageFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatJPG ImageFormat = "jpg"
|
||||||
|
FormatPNG ImageFormat = "png"
|
||||||
|
FormatWEBP ImageFormat = "webp"
|
||||||
|
)
|
||||||
@@ -2,14 +2,14 @@ package container
|
|||||||
|
|
||||||
import "rear/internal/repositories"
|
import "rear/internal/repositories"
|
||||||
|
|
||||||
type Container struct {
|
type DbContainer struct {
|
||||||
LibraryRepo *repositories.LibraryRepository
|
LibraryRepo *repositories.LibraryRepository
|
||||||
UserRepo *repositories.UserService
|
UserRepo *repositories.UserService
|
||||||
// 其他服务...
|
// 其他服务...
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewContainer() *Container {
|
func NewContainer() *DbContainer {
|
||||||
return &Container{
|
return &DbContainer{
|
||||||
LibraryRepo: repositories.NewLibraryRepository(),
|
LibraryRepo: repositories.NewLibraryRepository(),
|
||||||
UserRepo: repositories.NewUserService(),
|
UserRepo: repositories.NewUserService(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ type TaskContainer struct {
|
|||||||
// 照片任务处理管理
|
// 照片任务处理管理
|
||||||
ImgTaskManager *workflow.ImgTaskManager
|
ImgTaskManager *workflow.ImgTaskManager
|
||||||
// 其他服务...
|
// 其他服务...
|
||||||
|
|
||||||
|
// 数据库服务
|
||||||
|
DbContainer *DbContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTaskContainer() *TaskContainer {
|
func NewTaskContainer(con *DbContainer) *TaskContainer {
|
||||||
return &TaskContainer{
|
return &TaskContainer{
|
||||||
ImgTaskManager: workflow.NewImgTaskManager(10),
|
DbContainer: con,
|
||||||
|
ImgTaskManager: workflow.NewImgTaskManager(5),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go.uber.org/zap"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
// 全局变量存储工具路径
|
// 全局变量存储工具路径
|
||||||
var (
|
var (
|
||||||
ImageMagickPath string
|
ImageMagickPath string
|
||||||
|
VipsPath string
|
||||||
ExifToolPath string
|
ExifToolPath string
|
||||||
toolsInitOnce sync.Once
|
toolsInitOnce sync.Once
|
||||||
toolsInitErr error
|
toolsInitErr error
|
||||||
@@ -27,6 +29,7 @@ type Config struct {
|
|||||||
// 工具路径(如果为空,会自动检测)
|
// 工具路径(如果为空,会自动检测)
|
||||||
ImageMagickPath string
|
ImageMagickPath string
|
||||||
ExifToolPath string
|
ExifToolPath string
|
||||||
|
VipsPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize 初始化工具路径(可选调用,如果不调用会在第一次使用时自动初始化)
|
// Initialize 初始化工具路径(可选调用,如果不调用会在第一次使用时自动初始化)
|
||||||
@@ -38,6 +41,7 @@ func Initialize(config *Config) error {
|
|||||||
toolsInitOnce.Do(func() {
|
toolsInitOnce.Do(func() {
|
||||||
ImageMagickPath = config.ImageMagickPath
|
ImageMagickPath = config.ImageMagickPath
|
||||||
ExifToolPath = config.ExifToolPath
|
ExifToolPath = config.ExifToolPath
|
||||||
|
VipsPath = config.VipsPath
|
||||||
toolsInitErr = detectTools()
|
toolsInitErr = detectTools()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,6 +76,14 @@ func detectTools() error {
|
|||||||
return fmt.Errorf("ExifTool not found")
|
return fmt.Errorf("ExifTool not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测 libvips
|
||||||
|
if VipsPath == "" {
|
||||||
|
VipsPath = findTool("vips", execDir)
|
||||||
|
}
|
||||||
|
if VipsPath == "" {
|
||||||
|
return fmt.Errorf("libvips not found")
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +106,11 @@ func findTool(name string, execDir string) string {
|
|||||||
filepath.Join(execDir, "bin", exeName),
|
filepath.Join(execDir, "bin", exeName),
|
||||||
filepath.Join(execDir, "tools", exeName),
|
filepath.Join(execDir, "tools", exeName),
|
||||||
filepath.Join(execDir, "tools", name, exeName),
|
filepath.Join(execDir, "tools", name, exeName),
|
||||||
|
filepath.Join(execDir, "tools", name, "bin", exeName),
|
||||||
}
|
}
|
||||||
jsonBytes, _ := json.Marshal(searchPaths)
|
jsonBytes, _ := json.Marshal(searchPaths)
|
||||||
msg := string(jsonBytes)
|
msg := string(jsonBytes)
|
||||||
logger.Warn(msg)
|
logger.Warn("查找工具路径", zap.String("paths", msg))
|
||||||
|
|
||||||
for _, path := range searchPaths {
|
for _, path := range searchPaths {
|
||||||
if _, err := os.Stat(path); err == nil {
|
if _, err := os.Stat(path); err == nil {
|
||||||
@@ -115,7 +128,7 @@ func findTool(name string, execDir string) string {
|
|||||||
|
|
||||||
// EnsureInitialized 确保工具已初始化
|
// EnsureInitialized 确保工具已初始化
|
||||||
func EnsureInitialized() error {
|
func EnsureInitialized() error {
|
||||||
if toolsInitErr == nil && ImageMagickPath == "" && ExifToolPath == "" {
|
if toolsInitErr == nil && ImageMagickPath == "" && ExifToolPath == "" && VipsPath == "" {
|
||||||
return Initialize(nil)
|
return Initialize(nil)
|
||||||
}
|
}
|
||||||
return toolsInitErr
|
return toolsInitErr
|
||||||
|
|||||||
File renamed without changes.
@@ -0,0 +1,337 @@
|
|||||||
|
package tools
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"rear/internal/utils"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProcessOptions 图像处理选项
|
||||||
|
type ProcessOptions struct {
|
||||||
|
// 基础选项
|
||||||
|
MaxSize int // 最长边的最大尺寸(保持比例)
|
||||||
|
Quality int // 压缩质量 (1-100)
|
||||||
|
Format string // 输出格式 (jpeg, png, webp, avif等)
|
||||||
|
|
||||||
|
// 高级选项
|
||||||
|
Strip bool // 是否移除EXIF等元数据
|
||||||
|
Interlace bool // 是否启用渐进式JPEG
|
||||||
|
Optimize bool // 是否启用优化
|
||||||
|
Background string // 背景色(处理透明图像时)
|
||||||
|
Sharpen bool // 是否启用锐化
|
||||||
|
AutoRotate bool // 是否根据EXIF自动旋转
|
||||||
|
|
||||||
|
// WebP特定选项
|
||||||
|
WebPLossless bool // WebP无损压缩
|
||||||
|
WebPEffort int // WebP压缩努力程度 (0-6)
|
||||||
|
|
||||||
|
// 尺寸控制
|
||||||
|
Width int // 固定宽度(如果设置则忽略MaxSize)
|
||||||
|
Height int // 固定高度(如果设置则忽略MaxSize)
|
||||||
|
Crop bool // 是否裁剪以填充指定尺寸
|
||||||
|
}
|
||||||
|
|
||||||
|
// VipsImageInfo vips图片信息
|
||||||
|
type VipsImageInfo struct {
|
||||||
|
Format string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Channels int
|
||||||
|
ColorSpace string
|
||||||
|
FileSize string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultOptions 返回默认处理选项
|
||||||
|
func DefaultOptions() *ProcessOptions {
|
||||||
|
return &ProcessOptions{
|
||||||
|
MaxSize: 512,
|
||||||
|
Quality: 85,
|
||||||
|
Format: "jpeg",
|
||||||
|
Strip: true,
|
||||||
|
Interlace: false,
|
||||||
|
Optimize: true,
|
||||||
|
Background: "white",
|
||||||
|
Sharpen: false,
|
||||||
|
AutoRotate: true,
|
||||||
|
WebPLossless: false,
|
||||||
|
WebPEffort: 4,
|
||||||
|
Width: 0,
|
||||||
|
Height: 0,
|
||||||
|
Crop: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessImageWithVips 使用 libvips 处理图像
|
||||||
|
// 示例: ProcessImageWithVips(ctx, "input.jpg", "output.webp", DefaultOptions())
|
||||||
|
func ProcessImageWithVips(ctx context.Context, inputPath, outputPath string, options *ProcessOptions) error {
|
||||||
|
if err := utils.EnsureInitialized(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if options == nil {
|
||||||
|
options = DefaultOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查输入文件是否存在
|
||||||
|
if _, err := os.Stat(inputPath); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("input file not found: %s", inputPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保输出目录存在
|
||||||
|
outputDir := filepath.Dir(outputPath)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create output directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建vips命令参数
|
||||||
|
args := buildVipsArgs(inputPath, outputPath, options)
|
||||||
|
|
||||||
|
// 执行命令
|
||||||
|
result, err := utils.ExecuteCommand(ctx, utils.VipsPath, args...)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("vips command failed: %w, stderr: %s", err, string(result.Stderr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ThumbnailImage 生成缩略图(使用vips thumbnail命令)
|
||||||
|
func ThumbnailImage(ctx context.Context, input, output string, size int) error {
|
||||||
|
return ProcessImageWithVips(ctx, input, output, &ProcessOptions{
|
||||||
|
MaxSize: size,
|
||||||
|
Quality: 85,
|
||||||
|
Strip: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeImageWithVips 使用vips调整图片大小
|
||||||
|
func ResizeImageWithVips(ctx context.Context, input, output string, width, height int) error {
|
||||||
|
return ProcessImageWithVips(ctx, input, output, &ProcessOptions{
|
||||||
|
Width: width,
|
||||||
|
Height: height,
|
||||||
|
Quality: 85,
|
||||||
|
Strip: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizeImageKeepAspectVips 使用vips按比例调整图片大小(保持宽高比)
|
||||||
|
func ResizeImageKeepAspectVips(ctx context.Context, input, output string, maxSize int) error {
|
||||||
|
return ProcessImageWithVips(ctx, input, output, &ProcessOptions{
|
||||||
|
MaxSize: maxSize,
|
||||||
|
Quality: 85,
|
||||||
|
Strip: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConvertImageFormat 转换图像格式
|
||||||
|
func ConvertImageFormat(ctx context.Context, input, output, format string) error {
|
||||||
|
return ProcessImageWithVips(ctx, input, output, &ProcessOptions{
|
||||||
|
Format: format,
|
||||||
|
Quality: 85,
|
||||||
|
Strip: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompressImage 压缩图像
|
||||||
|
func CompressImage(ctx context.Context, input, output string, quality int) error {
|
||||||
|
return ProcessImageWithVips(ctx, input, output, &ProcessOptions{
|
||||||
|
Quality: quality,
|
||||||
|
Strip: true,
|
||||||
|
Optimize: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVipsImageInfo 获取图像信息
|
||||||
|
func GetVipsImageInfo(ctx context.Context, imagePath string) (*VipsImageInfo, error) {
|
||||||
|
if err := utils.EnsureInitialized(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := utils.ExecuteCommand(ctx, utils.VipsPath, "identify", imagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get image info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析vips identify输出
|
||||||
|
info := &VipsImageInfo{}
|
||||||
|
lines := strings.Split(string(result.Stdout), "\n")
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, ":") {
|
||||||
|
parts := strings.SplitN(line, ":", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
switch key {
|
||||||
|
case "format":
|
||||||
|
info.Format = value
|
||||||
|
case "width":
|
||||||
|
if w, err := strconv.Atoi(value); err == nil {
|
||||||
|
info.Width = w
|
||||||
|
}
|
||||||
|
case "height":
|
||||||
|
if h, err := strconv.Atoi(value); err == nil {
|
||||||
|
info.Height = h
|
||||||
|
}
|
||||||
|
case "channels":
|
||||||
|
if c, err := strconv.Atoi(value); err == nil {
|
||||||
|
info.Channels = c
|
||||||
|
}
|
||||||
|
case "interpretation":
|
||||||
|
info.ColorSpace = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件大小
|
||||||
|
if stat, err := os.Stat(imagePath); err == nil {
|
||||||
|
info.FileSize = fmt.Sprintf("%d bytes", stat.Size())
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchProcessImages 批量处理图像
|
||||||
|
func BatchProcessImages(ctx context.Context, inputDir, outputDir string, options *ProcessOptions) error {
|
||||||
|
if options == nil {
|
||||||
|
options = DefaultOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 支持的图像格式
|
||||||
|
supportedExts := map[string]bool{
|
||||||
|
".jpg": true,
|
||||||
|
".jpeg": true,
|
||||||
|
".png": true,
|
||||||
|
".gif": true,
|
||||||
|
".webp": true,
|
||||||
|
".tiff": true,
|
||||||
|
".bmp": true,
|
||||||
|
".tif": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Walk(inputDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(path))
|
||||||
|
if !supportedExts[ext] {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算相对路径
|
||||||
|
relPath, err := filepath.Rel(inputDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建输出路径
|
||||||
|
outputPath := filepath.Join(outputDir, relPath)
|
||||||
|
|
||||||
|
// 根据输出格式修改扩展名
|
||||||
|
if options.Format != "" {
|
||||||
|
outputPath = strings.TrimSuffix(outputPath, filepath.Ext(outputPath)) + "." + options.Format
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessImageWithVips(ctx, path, outputPath, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsVipsAvailable 检查 libvips 是否可用
|
||||||
|
func IsVipsAvailable() bool {
|
||||||
|
if err := utils.EnsureInitialized(); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return utils.VipsPath != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildVipsArgs 构建vips命令参数
|
||||||
|
func buildVipsArgs(inputPath, outputPath string, options *ProcessOptions) []string {
|
||||||
|
args := []string{"thumbnail"}
|
||||||
|
|
||||||
|
// 输入文件
|
||||||
|
args = append(args, inputPath)
|
||||||
|
|
||||||
|
// 输出文件
|
||||||
|
args = append(args, outputPath)
|
||||||
|
|
||||||
|
// 尺寸参数
|
||||||
|
if options.Width > 0 && options.Height > 0 {
|
||||||
|
if options.Crop {
|
||||||
|
// 裁剪模式:填充指定尺寸
|
||||||
|
args = append(args, fmt.Sprintf("%dx%d^", options.Width, options.Height))
|
||||||
|
args = append(args, "--crop", "centre")
|
||||||
|
} else {
|
||||||
|
// 适应模式:保持比例
|
||||||
|
args = append(args, fmt.Sprintf("%dx%d", options.Width, options.Height))
|
||||||
|
}
|
||||||
|
} else if options.Width > 0 {
|
||||||
|
args = append(args, strconv.Itoa(options.Width))
|
||||||
|
} else if options.Height > 0 {
|
||||||
|
args = append(args, fmt.Sprintf("x%d", options.Height))
|
||||||
|
} else {
|
||||||
|
// 使用MaxSize(最长边)
|
||||||
|
args = append(args, strconv.Itoa(options.MaxSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 质量设置
|
||||||
|
if options.Quality > 0 && options.Quality <= 100 {
|
||||||
|
args = append(args, "--quality", strconv.Itoa(options.Quality))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 格式转换
|
||||||
|
if options.Format != "" {
|
||||||
|
args = append(args, "--format", options.Format)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除元数据
|
||||||
|
if options.Strip {
|
||||||
|
args = append(args, "--strip")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渐进式JPEG
|
||||||
|
if options.Interlace {
|
||||||
|
args = append(args, "--interlace")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化
|
||||||
|
if options.Optimize {
|
||||||
|
args = append(args, "--optimize")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 背景色
|
||||||
|
if options.Background != "" {
|
||||||
|
args = append(args, "--background", options.Background)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 锐化
|
||||||
|
if options.Sharpen {
|
||||||
|
args = append(args, "--sharpen", "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动旋转
|
||||||
|
if options.AutoRotate {
|
||||||
|
args = append(args, "--auto-rotate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebP特定选项
|
||||||
|
if strings.ToLower(options.Format) == "webp" {
|
||||||
|
if options.WebPLossless {
|
||||||
|
args = append(args, "--webp-lossless")
|
||||||
|
}
|
||||||
|
if options.WebPEffort >= 0 && options.WebPEffort <= 6 {
|
||||||
|
args = append(args, "--webp-effort", strconv.Itoa(options.WebPEffort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
@@ -5,11 +5,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"crypto/sha1"
|
"crypto/sha1"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/h2non/filetype"
|
||||||
|
"go.uber.org/zap"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"rear/internal/consts"
|
||||||
|
"rear/internal/utils/tools"
|
||||||
|
"rear/pkg/logger"
|
||||||
|
"rear/pkg/utils"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -85,17 +90,100 @@ func (pt *PictureTask) Resume() {
|
|||||||
func (pt *PictureTask) Run() {
|
func (pt *PictureTask) Run() {
|
||||||
pt.setStatus(StatusRunning)
|
pt.setStatus(StatusRunning)
|
||||||
|
|
||||||
|
// 文件是否存在
|
||||||
if !fileExists(pt.Path) {
|
if !fileExists(pt.Path) {
|
||||||
pt.setError(errors.New("file does not exist"))
|
logger.Error(
|
||||||
|
"指定文件不存在!",
|
||||||
|
zap.String("path", pt.Path),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pt.waitIfPaused()
|
||||||
|
// 读取文件
|
||||||
|
buf, err := os.ReadFile(pt.Path)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(
|
||||||
|
"文件读取失败!",
|
||||||
|
zap.String("path", pt.Path),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pt.waitIfPaused()
|
||||||
|
// 检测照片格式
|
||||||
|
kind, err := filetype.Match(buf)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(
|
||||||
|
"文件类型匹配失败!",
|
||||||
|
zap.String("path", pt.Path),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pt.waitIfPaused()
|
||||||
|
|
||||||
|
// 不同图像类型不同的处理方式
|
||||||
|
fileType := kind.Extension
|
||||||
|
logger.Info("探测到的文件类型.", zap.String("fileType", fileType))
|
||||||
|
|
||||||
|
// 读取 hash
|
||||||
|
hash, err := utils.HashUtils.HashFile(pt.Path, utils.SHA256)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(
|
||||||
|
"Hash获取失败!",
|
||||||
|
zap.String("path", pt.Path),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("获取到 Hash", zap.String("hash", hash))
|
||||||
|
|
||||||
|
// 获取基本信息,如果图像的很小则不进行压缩
|
||||||
|
ctx := context.Background()
|
||||||
|
exifData, err := tools.GetExifData(ctx, pt.Path)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error(
|
||||||
|
"EXIF数据获取失败!",
|
||||||
|
zap.String("path", pt.Path),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
logger.Info("获取到 EXIF 数据", zap.Any("exifData", exifData))
|
||||||
|
|
||||||
|
return
|
||||||
|
// 如果是非常规格式或 raw 则转换为 png ;如果是 PNG 则无损转换为 webp 或 jpg;如果是 webp 或 jpg 则进行压缩和其他处理
|
||||||
|
if fileType == string(consts.FormatJPG) {
|
||||||
|
//options := tools.DefaultOptions()
|
||||||
|
//options.MaxSize = 800
|
||||||
|
//options.Quality = 90
|
||||||
|
//options.Format = "jpeg"
|
||||||
|
//tools.LibVipsUtil.ProcessImage(options)
|
||||||
|
} else if fileType == string(consts.FormatWEBP) {
|
||||||
|
} else if fileType == string(consts.FormatPNG) {
|
||||||
|
} else {
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分割 Hash 路径
|
||||||
|
//utils.HashUtils.HashThumbPath(config.CONFIG.AppDir, hash)
|
||||||
|
|
||||||
|
// 压缩图像
|
||||||
|
// 获取 exif
|
||||||
|
// 保存到数据库
|
||||||
|
|
||||||
pt.waitIfPaused()
|
pt.waitIfPaused()
|
||||||
data, err := os.ReadFile(pt.Path)
|
data, err := os.ReadFile(pt.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pt.setError(err)
|
pt.setError(err)
|
||||||
|
logger.Error(
|
||||||
|
"文件读取失败!",
|
||||||
|
zap.String("filename", pt.Path),
|
||||||
|
zap.Error(err),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
pt.ImageBuf = data
|
pt.ImageBuf = data
|
||||||
pt.Hash = calcHash(data)
|
pt.Hash = calcHash(data)
|
||||||
|
|
||||||
@@ -288,21 +376,23 @@ func getCurrentCPUUsage() int {
|
|||||||
// --- 工具函数 ---
|
// --- 工具函数 ---
|
||||||
|
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
return utils.FileUtils.Exists(path)
|
||||||
return !os.IsNotExist(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func calcHash(data []byte) string {
|
func calcHash(data []byte) string {
|
||||||
|
logger.Info("calcHash")
|
||||||
h := sha1.Sum(data)
|
h := sha1.Sum(data)
|
||||||
return hex.EncodeToString(h[:])
|
return hex.EncodeToString(h[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
func compressImage(data []byte) ([]byte, error) {
|
func compressImage(data []byte) ([]byte, error) {
|
||||||
|
logger.Info("compressImage")
|
||||||
time.Sleep(100 * time.Millisecond) // 模拟耗时
|
time.Sleep(100 * time.Millisecond) // 模拟耗时
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readExifViaCmd(filePath string) (map[string]string, error) {
|
func readExifViaCmd(filePath string) (map[string]string, error) {
|
||||||
|
logger.Info("readExifViaCmd")
|
||||||
cmd := exec.Command("exiftool", filePath)
|
cmd := exec.Command("exiftool", filePath)
|
||||||
out, err := cmd.Output()
|
out, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -322,6 +412,7 @@ func readExifViaCmd(filePath string) (map[string]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func saveToDB(hash string, exif map[string]string) error {
|
func saveToDB(hash string, exif map[string]string) error {
|
||||||
|
logger.Info("saveToDB")
|
||||||
fmt.Printf("Saving %s to DB with %d exif fields\n", hash, len(exif))
|
fmt.Printf("Saving %s to DB with %d exif fields\n", hash, len(exif))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
+39
-13
@@ -5,6 +5,7 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"go.uber.org/zap"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -30,6 +31,9 @@ import (
|
|||||||
var exiftoolFS embed.FS
|
var exiftoolFS embed.FS
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// 基础配置加载
|
||||||
|
config.InitConfig()
|
||||||
|
|
||||||
// 日志初始化
|
// 日志初始化
|
||||||
err := logger.InitDefaultLogger()
|
err := logger.InitDefaultLogger()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -55,12 +59,11 @@ func main() {
|
|||||||
// 初始化照片管理任务
|
// 初始化照片管理任务
|
||||||
newTaskContainer := container.NewTaskContainer(newContainer)
|
newTaskContainer := container.NewTaskContainer(newContainer)
|
||||||
|
|
||||||
// 创建运行目录
|
// 创建软件所需的缓存目录等内容
|
||||||
execPath, err := os.Executable()
|
createCachePath(config.CONFIG.AppDir)
|
||||||
execDir := filepath.Dir(execPath)
|
|
||||||
|
|
||||||
// 将 CLI 放到到运行目录
|
// 将 CLI 放到到运行目录
|
||||||
join := filepath.Join(execDir, "tools", "exiftool")
|
join := filepath.Join(config.CONFIG.AppDir, "tools", "exiftool")
|
||||||
srcDir := `.\tools\windows_amd64\exiftool\exiftool` // 源目录
|
srcDir := `.\tools\windows_amd64\exiftool\exiftool` // 源目录
|
||||||
|
|
||||||
// 复制整个目录
|
// 复制整个目录
|
||||||
@@ -69,6 +72,13 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
join1 := filepath.Join(config.CONFIG.AppDir, "tools", "vips")
|
||||||
|
srcDir1 := `.\tools\windows_amd64\libvips\vips` // 源目录
|
||||||
|
if err := utils.FileUtils.CopyDir(srcDir1, join1); err != nil {
|
||||||
|
fmt.Printf("复制失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
//if err := initDatabase(); err != nil {
|
//if err := initDatabase(); err != nil {
|
||||||
// logger.Fatalf("Failed to initialize database: %v", err)
|
// logger.Fatalf("Failed to initialize database: %v", err)
|
||||||
//}
|
//}
|
||||||
@@ -78,11 +88,27 @@ func main() {
|
|||||||
startHttp(newContainer, newTaskContainer)
|
startHttp(newContainer, newTaskContainer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 创建软件所需的缓存目录等内容
|
||||||
|
func createCachePath(dir string) {
|
||||||
|
// 缩略图目录
|
||||||
|
thumbnailPath := filepath.Join(dir, config.CONFIG.PathConfig.CachePath, config.CONFIG.PathConfig.ThumbnailPath)
|
||||||
|
err := utils.FileUtils.CreateDir(thumbnailPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("缩略图路径创建失败!", zap.String("path", dir), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 临时文件路径
|
||||||
|
tempPath := filepath.Join(dir, config.CONFIG.PathConfig.TempPath, config.CONFIG.PathConfig.PngTempPath)
|
||||||
|
err = utils.FileUtils.CreateDir(tempPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("临时文件夹创建失败!", zap.String("path", dir), zap.Error(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startHttp(con *container.DbContainer, imgContain *container.TaskContainer) {
|
func startHttp(con *container.DbContainer, imgContain *container.TaskContainer) {
|
||||||
// 配置加载
|
|
||||||
netConfig := config.InitConfig()
|
|
||||||
// 设置Gin模式
|
// 设置Gin模式
|
||||||
gin.SetMode(netConfig.Mode)
|
gin.SetMode(config.CONFIG.Mode)
|
||||||
|
|
||||||
// 创建Gin引擎
|
// 创建Gin引擎
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -101,7 +127,7 @@ func startHttp(con *container.DbContainer, imgContain *container.TaskContainer)
|
|||||||
r.Use(service.ErrorHandlerMiddleware()) // 最后处理错误
|
r.Use(service.ErrorHandlerMiddleware()) // 最后处理错误
|
||||||
|
|
||||||
// 性能分析 (仅在debug模式下)
|
// 性能分析 (仅在debug模式下)
|
||||||
if netConfig.Mode == "debug" {
|
if config.CONFIG.Mode == "debug" {
|
||||||
pprof.Register(r)
|
pprof.Register(r)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,11 +136,11 @@ func startHttp(con *container.DbContainer, imgContain *container.TaskContainer)
|
|||||||
|
|
||||||
// 创建HTTP服务器
|
// 创建HTTP服务器
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + netConfig.Port,
|
Addr: ":" + config.CONFIG.Port,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
ReadTimeout: netConfig.ReadTimeout,
|
ReadTimeout: config.CONFIG.ReadTimeout,
|
||||||
WriteTimeout: netConfig.WriteTimeout,
|
WriteTimeout: config.CONFIG.WriteTimeout,
|
||||||
IdleTimeout: netConfig.IdleTimeout,
|
IdleTimeout: config.CONFIG.IdleTimeout,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优雅关闭
|
// 优雅关闭
|
||||||
@@ -123,7 +149,7 @@ func startHttp(con *container.DbContainer, imgContain *container.TaskContainer)
|
|||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
go func() {
|
go func() {
|
||||||
logger.Infof("Server starting on port 127.0.0.1:%s", netConfig.Port)
|
logger.Infof("Server starting on port 127.0.0.1:%s", config.CONFIG.Port)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
logger.Fatalf("Failed to start server: %v", err)
|
logger.Fatalf("Failed to start server: %v", err)
|
||||||
// 发送信号给主goroutine,让它知道启动失败
|
// 发送信号给主goroutine,让它知道启动失败
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ type fileUtilsStruct struct{}
|
|||||||
// FileUtils 是对外暴露的文件工具对象
|
// FileUtils 是对外暴露的文件工具对象
|
||||||
var FileUtils = fileUtilsStruct{}
|
var FileUtils = fileUtilsStruct{}
|
||||||
|
|
||||||
// 1. 检查文件或目录是否存在
|
// Exists 1. 检查文件或目录是否存在
|
||||||
func (fileUtilsStruct) Exists(path string) bool {
|
func (fileUtilsStruct) Exists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return !os.IsNotExist(err)
|
return !os.IsNotExist(err)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"hash"
|
"hash"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -327,6 +328,31 @@ func (h hashUtilsStruct) HashFileMultipleAlgorithms(filename string, hashTypes [
|
|||||||
return results, nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashThumbPath 将 Hash 转换为具体路径
|
||||||
|
func (h hashUtilsStruct) HashThumbPath(
|
||||||
|
basePath string,
|
||||||
|
hash string,
|
||||||
|
filename string, // 文件名,不带扩展名
|
||||||
|
ext string, // 扩展名,不带点,如 "jpg", "webp"
|
||||||
|
) string {
|
||||||
|
var parts []string
|
||||||
|
start := 0
|
||||||
|
segments := []int{2, 2, 2}
|
||||||
|
for _, length := range segments {
|
||||||
|
end := start + length
|
||||||
|
if end > len(hash) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
parts = append(parts, hash[start:end])
|
||||||
|
start = end
|
||||||
|
}
|
||||||
|
parts = append(parts, hash) // 最后一级目录为完整 hash
|
||||||
|
|
||||||
|
dir := filepath.Join(append([]string{basePath}, parts...)...)
|
||||||
|
fullName := fmt.Sprintf("%s.%s", filename, ext)
|
||||||
|
return filepath.Join(dir, fullName)
|
||||||
|
}
|
||||||
|
|
||||||
// 便捷函数
|
// 便捷函数
|
||||||
func (h hashUtilsStruct) MD5File(filename string) (string, error) {
|
func (h hashUtilsStruct) MD5File(filename string) (string, error) {
|
||||||
return h.HashFile(filename, MD5)
|
return h.HashFile(filename, MD5)
|
||||||
|
|||||||
Binary file not shown.
Reference in new issue
Block a user