refactor: 按模块重构项目结构
- 删除 vars 全局状态,按职责拆分 internal/ 包:scanner/probe/classify/organize/mover/store/digest - 数据驱动归类规则替代图片/视频重复分支,store 改为结构体实例 - build.sh 按系统类型编译:Windows 用 upx 压缩,macOS/Linux 不压缩 - 忽略目录拆分:图片_已忽略 / 视频_已忽略 - 输出构建产物加入 .gitignore
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// probeBmp 读取 BMP 文件头中的宽高;自顶向下 BMP 的高度为负值,取绝对值
|
||||
func probeBmp(filePath string) (*ProbeResult, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var fileHeader struct {
|
||||
FileType [2]byte
|
||||
FileSize uint32
|
||||
Reserved1 uint16
|
||||
Reserved2 uint16
|
||||
DataOffset uint32
|
||||
}
|
||||
if err := binary.Read(f, binary.LittleEndian, &fileHeader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(fileHeader.FileType[:]) != "BM" {
|
||||
return nil, fmt.Errorf("not a valid BMP file")
|
||||
}
|
||||
var infoHeader struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitsPerPixel uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
}
|
||||
if err := binary.Read(f, binary.LittleEndian, &infoHeader); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ProbeResult{Width: abs(infoHeader.Width), Height: abs(infoHeader.Height)}, nil
|
||||
}
|
||||
|
||||
func abs(v int32) int {
|
||||
if v < 0 {
|
||||
return int(-v)
|
||||
}
|
||||
return int(v)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FFprobeExeName 释放到当前目录的解码器文件名
|
||||
const FFprobeExeName = "ffprobe.exe"
|
||||
|
||||
// FFprobePath 返回当前目录下的 ffprobe 可执行路径(Windows/Unix 均可直接执行)
|
||||
func FFprobePath() string {
|
||||
return "." + string(os.PathSeparator) + FFprobeExeName
|
||||
}
|
||||
|
||||
// HasEmbeddedFFprobe 当前平台是否内嵌了 ffprobe
|
||||
func HasEmbeddedFFprobe() bool {
|
||||
return len(embeddedFFprobe) > 0
|
||||
}
|
||||
|
||||
// ReleaseFFprobe 释放内嵌的 ffprobe 到当前目录(按当前系统自动选择对应版本)
|
||||
func ReleaseFFprobe() error {
|
||||
if len(embeddedFFprobe) == 0 {
|
||||
return fmt.Errorf("当前系统没有内嵌的ffprobe")
|
||||
}
|
||||
return os.WriteFile(FFprobeExeName, embeddedFFprobe, 0777)
|
||||
}
|
||||
|
||||
// RemoveFFprobe 删除当前目录下释放的 ffprobe
|
||||
func RemoveFFprobe() {
|
||||
_ = os.Remove(FFprobeExeName)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build darwin
|
||||
|
||||
package probe
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/ffprobe_mac
|
||||
var embeddedFFprobe []byte // macOS 版 ffprobe(未入库,见 .gitignore)
|
||||
@@ -0,0 +1,6 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package probe
|
||||
|
||||
// embeddedFFprobe 当前平台没有内嵌的 ffprobe
|
||||
var embeddedFFprobe []byte
|
||||
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
|
||||
package probe
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/ffprobe.exe
|
||||
var embeddedFFprobe []byte // Windows 版 ffprobe(未入库,见 .gitignore)
|
||||
@@ -0,0 +1,76 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// supportImageTypes 支持的标准图片扩展名(大小写不敏感)
|
||||
var supportImageTypes = []string{
|
||||
".bmp",
|
||||
".gif",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpe",
|
||||
".png",
|
||||
".webp",
|
||||
".psb",
|
||||
".jfif",
|
||||
}
|
||||
|
||||
// IsSupportImage 判断扩展名是否属于支持的图片格式
|
||||
func IsSupportImage(ext string) bool {
|
||||
for _, t := range supportImageTypes {
|
||||
if strings.EqualFold(t, ext) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ProbeImage 统一探测图片尺寸:
|
||||
// 1. 特殊格式(webp/bmp/psd/psb)走专用解码器
|
||||
// 2. 其余支持格式走 Go 标准库 image.DecodeConfig
|
||||
// 3. 标准库失败时用 ffprobe 兜底(ffmpeg 支持的图片格式更广,如 tiff/svg 等)
|
||||
//
|
||||
// ffprobePath 为空表示不启用 ffprobe 兜底;扩展名不属于支持格式时返回错误。
|
||||
func ProbeImage(filePath, ffprobePath string) (*ProbeResult, error) {
|
||||
ext := strings.ToLower(filepath.Ext(filePath))
|
||||
switch ext {
|
||||
case ".webp":
|
||||
return probeWebp(filePath)
|
||||
case ".bmp":
|
||||
return probeBmp(filePath)
|
||||
case ".psd", ".psb":
|
||||
return probePsd(filePath)
|
||||
}
|
||||
if !IsSupportImage(ext) {
|
||||
return nil, fmt.Errorf("不支持的图片格式: %s", ext)
|
||||
}
|
||||
if w, h, err := decodeConfig(filePath); err == nil {
|
||||
return &ProbeResult{Width: w, Height: h}, nil
|
||||
}
|
||||
if ffprobePath != "" {
|
||||
if res, err := ProbeMediaFile(ffprobePath, filePath); err == nil && res.HasVideo {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("无法读取图片尺寸: %s", filePath)
|
||||
}
|
||||
|
||||
// decodeConfig 用 Go 标准库读取图片宽高
|
||||
func decodeConfig(filePath string) (int, int, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, _, err := image.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Package probe 负责媒体信息探测:图片尺寸读取、ffprobe 视频/音频解析与内嵌解码器释放。
|
||||
package probe
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ProbeResult 媒体探测结果(图片/视频/音频通用)
|
||||
type ProbeResult struct {
|
||||
HasVideo bool
|
||||
HasAudio bool
|
||||
Width int
|
||||
Height int
|
||||
Duration float64
|
||||
VideoCodec string
|
||||
AudioCodec string
|
||||
Format string
|
||||
RawJSON string
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type formatInfo struct {
|
||||
Duration string `json:"duration"`
|
||||
Format string `json:"format_name"`
|
||||
}
|
||||
|
||||
type probeOutput struct {
|
||||
Streams []streamInfo `json:"streams"`
|
||||
Format formatInfo `json:"format"`
|
||||
}
|
||||
|
||||
// ProbeMediaFile 用 ffprobe 探测媒体文件,一次调用同时获取视频/音频流与时长
|
||||
func ProbeMediaFile(ffprobePath, filePath string) (*ProbeResult, error) {
|
||||
cmd := exec.Command(ffprobePath, "-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 out probeOutput
|
||||
if err := json.Unmarshal(output, &out); err != nil {
|
||||
return nil, fmt.Errorf("parse ffprobe output: %w", err)
|
||||
}
|
||||
res := &ProbeResult{
|
||||
Format: out.Format.Format,
|
||||
RawJSON: string(output),
|
||||
}
|
||||
if d, err := strconv.ParseFloat(out.Format.Duration, 64); err == nil {
|
||||
res.Duration = d
|
||||
}
|
||||
for _, s := range out.Streams {
|
||||
switch s.CodecType {
|
||||
case "video":
|
||||
res.HasVideo = true
|
||||
if res.VideoCodec == "" {
|
||||
res.VideoCodec = s.CodecName
|
||||
}
|
||||
if res.Width == 0 && s.Width > 0 && s.Height > 0 {
|
||||
res.Width, res.Height = s.Width, s.Height
|
||||
}
|
||||
case "audio":
|
||||
res.HasAudio = true
|
||||
if res.AudioCodec == "" {
|
||||
res.AudioCodec = s.CodecName
|
||||
}
|
||||
}
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// probePsd 读取 PSD/PSB 格式的宽高(两者共享相同的 8BPS 文件头)
|
||||
func probePsd(filePath string) (*ProbeResult, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var header [26]byte
|
||||
if _, err := io.ReadFull(f, header[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if string(header[0:4]) != "8BPS" {
|
||||
return nil, fmt.Errorf("not a valid PSD/PSB file")
|
||||
}
|
||||
version := binary.BigEndian.Uint16(header[4:6])
|
||||
if version != 1 && version != 2 {
|
||||
return nil, fmt.Errorf("unsupported PSD/PSB version: %d", version)
|
||||
}
|
||||
height := int(binary.BigEndian.Uint32(header[14:18]))
|
||||
width := int(binary.BigEndian.Uint32(header[18:22]))
|
||||
return &ProbeResult{Width: width, Height: height}, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package probe
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// probeWebp 读取 WebP 图片宽高(只解码配置,不加载完整像素数据)
|
||||
func probeWebp(filePath string) (*ProbeResult, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
cfg, err := webp.DecodeConfig(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode webp: %w", err)
|
||||
}
|
||||
return &ProbeResult{Width: cfg.Width, Height: cfg.Height}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user