feat(core): 添加PSD/PSB图片格式支持并重构媒体文件处理

- 新增ReadPsdImage函数支持PSD/PSB格式图片信息读取
- 修改文件扫描逻辑,将视频处理改为媒体候选处理,使用ffprobe统一判定
- 添加对PSB格式的支持,与PSD共享相同8BPS文件头处理
- 重构handle_image.go中的图片处理逻辑,优化错误处理和记录保存
- 更新文件扫描过滤规则,支持更多图片格式并跳过数据库文件
- 添加文件MD5计算功能用于文件唯一性标识
- 修改媒体处理流程,支持音频文件识别和分类处理
- 移除废弃的视频时长转换函数和相关依赖
- 更新依赖版本并添加SQLite支持用于媒体信息存储
This commit is contained in:
2025-03-18 21:04:15 +08:00
parent f5e3f6b4ef
commit b276e8c56f
16 changed files with 770 additions and 311 deletions
+132 -107
View File
@@ -1,140 +1,165 @@
package util
import (
"bytes"
"encoding/json"
"fmt"
"io"
"math"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"kenaito-media-picker/vars"
"github.com/redmask-hb/GoSimplePrint/goPrint"
uuid "github.com/satori/go.uuid"
)
var supportVideoTypes = []string{
".ts",
".flv",
".rm",
".avi",
".mp4",
".mov",
".mpg",
".mkv",
".m4v",
".rmvb",
".3gp",
".3g2",
".webm",
".wmv",
// FFprobeExeName 内嵌解码器文件名
const FFprobeExeName = "ffprobe.exe"
// FFprobeExePath 返回带当前目录前缀的可执行路径(Windows/Unix均可直接执行)
func FFprobeExePath() string {
return "." + string(os.PathSeparator) + FFprobeExeName
}
func getVideoDuration(ffmpegExecPath string, videoPath string) (float64, error) {
// ffprobe命令,-v error 用于减少输出信息,-show_entries format=duration -of compact=p=0,nk=1 用于只输出时长
cmd := exec.Command(ffmpegExecPath, "-v", "error", "-show_entries", "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", videoPath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("ffprobe failed with error: %v, stderr: %q", err, stderr.String())
}
// 解析输出的时长字符串为浮点数
durationStr := strings.TrimSpace(string(output))
duration, err := strconv.ParseFloat(durationStr, 64)
if err != nil {
return 0, fmt.Errorf("failed to parse duration: %q, error: %v", durationStr, err)
}
return duration, nil
// ReleaseFFprobe 释放内嵌的ffprobe到当前目录
func ReleaseFFprobe() error {
return WriteByteArraysToFile(vars.VideoDecodeToolWin64, FFprobeExeName)
}
func getVideoResolution(ffmpegExecPath string, filePath string) (width int, height int, err error) {
// 构建ffprobe命令
cmd := exec.Command(ffmpegExecPath, "-v", "error", "-show_entries", "stream=width,height", "-of", "csv=p=0:s=x", filePath)
// 执行命令并捕获输出
output, err := cmd.Output()
if err != nil {
return 0, 0, fmt.Errorf("failed to run ffprobe: %w", err)
// RemoveFFprobe 删除当前目录下的ffprobe
func RemoveFFprobe() {
if CheckFileIsExist(FFprobeExeName) {
_ = os.Remove(FFprobeExeName)
}
// 解析输出字符串,格式应为 "宽度,高度"
resolutionStr := strings.TrimSpace(string(output))
parts := strings.Split(resolutionStr, ",")
if len(parts) == 2 {
width = String2int(parts[0])
height = String2int(parts[1])
return width, height, nil
}
parts = strings.Split(resolutionStr, "x")
if len(parts) == 2 {
width = String2int(parts[0])
height = String2int(parts[1])
return width, height, nil
}
parts = strings.Split(resolutionStr, "\r\n\r\n\r\n\r\n")
if len(parts) == 2 {
tempHw := parts[0]
parts = strings.Split(tempHw, "x")
if len(parts) == 2 {
width = String2int(parts[0])
height = String2int(parts[1])
return width, height, nil
}
// StreamInfo ffprobe流信息
type StreamInfo struct {
Index int `json:"index"`
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
Width int `json:"width"`
Height int `json:"height"`
}
// FormatInfo ffprobe容器信息
type FormatInfo struct {
Duration string `json:"duration"`
Format string `json:"format_name"`
}
// ProbeResult ffprobe探测结果
type ProbeResult struct {
Streams []StreamInfo `json:"streams"`
Format FormatInfo `json:"format"`
raw string
}
// VideoResolution 返回第一个有效的视频分辨率
func (p *ProbeResult) VideoResolution() (int, int) {
for _, s := range p.Streams {
if s.CodecType == "video" && s.Width > 0 && s.Height > 0 {
return s.Width, s.Height
}
}
parts = strings.Split(resolutionStr, "x")
if len(parts) == 3 {
width = String2int(parts[0])
height = String2int(parts[1])
return width, height, nil
}
resolutionStr = strings.ReplaceAll(resolutionStr, "\r", "")
resolutionStr = strings.ReplaceAll(resolutionStr, "\n", "")
resolutionStr = strings.ReplaceAll(resolutionStr, "N/AxN/A", "")
parts = strings.Split(resolutionStr, "x")
if len(parts) == 2 {
width = String2int(parts[0])
height = String2int(parts[1])
return width, height, nil
}
return 0, 0, fmt.Errorf("invalid resolution format: %s", resolutionStr)
return 0, 0
}
// ReadVideoDuration 获取视频的时长,单位秒
func ReadVideoDuration(videoFilePath string) int {
duration, err := getVideoDuration("./ffprobe.exe", videoFilePath)
if err != nil {
fmt.Println("=== Error getting video duration:", err)
return 0
}
//fmt.Printf("=== Video duration: %.2f seconds\n", duration)
return int(math.Floor(duration)) // 向下取整
}
// ReadVideoWidthHeight 获取视频的分辨率
func ReadVideoWidthHeight(videoFilePath string) (int, int, error) {
width, height, err := getVideoResolution("./ffprobe.exe", videoFilePath)
if err != nil {
fmt.Printf("=== Error getting resolution: %v\n", err)
return 0, 0, err
}
//fmt.Printf("=== Video resolution: %dx%d\n", width, height)
return width, height, nil
}
// IsSupportVideo 判断是否属于支持的视频
func IsSupportVideo(videoType string) bool {
for _, supportVideoType := range supportVideoTypes {
if strings.EqualFold(videoType, supportVideoType) {
// HasAudio 是否包含音频流
func (p *ProbeResult) HasAudio() bool {
for _, s := range p.Streams {
if s.CodecType == "audio" {
return true
}
}
return false
}
// VideoCodec 第一个视频流的编码名称
func (p *ProbeResult) VideoCodec() string {
for _, s := range p.Streams {
if s.CodecType == "video" {
return s.CodecName
}
}
return ""
}
// AudioCodec 第一个音频流的编码名称
func (p *ProbeResult) AudioCodec() string {
for _, s := range p.Streams {
if s.CodecType == "audio" {
return s.CodecName
}
}
return ""
}
// FormatName 容器格式名称
func (p *ProbeResult) FormatName() string {
return p.Format.Format
}
// RawJSON 原始ffprobe JSON输出
func (p *ProbeResult) RawJSON() string {
return p.raw
}
// DurationSeconds 容器时长(秒),无法解析时返回0
func (p *ProbeResult) DurationSeconds() float64 {
d, err := strconv.ParseFloat(p.Format.Duration, 64)
if err != nil {
return 0
}
return d
}
// ProbeMediaFile 用ffprobe探测媒体文件,一次调用同时获取视频/音频流与时长
func ProbeMediaFile(ffprobeExecPath string, filePath string) (*ProbeResult, error) {
cmd := exec.Command(ffprobeExecPath, "-v", "error",
"-show_entries", "stream=index,codec_type,codec_name,width,height:format=duration,format_name",
"-of", "json", filePath)
output, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("ffprobe failed: %w", err)
}
var result ProbeResult
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse ffprobe output: %w", err)
}
result.raw = string(output)
return &result, nil
}
// IsPotentialMedia 判断是否可能是媒体文件(交由ffprobe进一步确认)
func IsPotentialMedia(fileMime string) bool {
if fileMime == "" || fileMime == "application/octet-stream" {
return true
}
if strings.HasPrefix(fileMime, "text/") {
return false
}
if strings.HasPrefix(fileMime, "video/") || strings.HasPrefix(fileMime, "audio/") || strings.HasPrefix(fileMime, "image/") {
return true
}
switch fileMime {
case "application/zip", "application/gzip", "application/x-gzip", "application/pdf",
"application/json", "application/xml", "application/x-httpd-php", "application/x-sh",
"application/javascript", "application/x-7z-compressed", "application/x-rar-compressed",
"application/x-tar", "application/x-bzip2", "application/x-archive", "application/vnd.sqlite3",
"application/vnd.microsoft.portable-executable", "application/x-dosexec", "application/x-executable",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/msword", "application/vnd.ms-excel", "application/vnd.ms-powerpoint":
return false
}
return true
}
// DoMoveFileToDir 批量移动文件到目录
func DoMoveFileToDir(filePathList []string, videoDirPath string) {
total := len(filePathList)