refactor: 全面重构代码并加白话注释,移动全局并行,去掉去向/重复记录

- 重构后端与前端,全程大白话注释(不懂程序也能看懂)
- 移动阶段改为全局并行(MoveAll,工人数=CPU核心数)
- 去掉记录每个文件搬去哪了(删除去向回填,解决写库卡顿)
- 去掉重复文件标记与统计:重复判定改为 MD5+路径都相同才算,重复则原地忽略不移动;MD5 相同但路径不同仍照常搬
- 媒体库去掉归类目录列,看板去掉重复/归类分布统计
This commit is contained in:
2025-05-11 21:05:35 +08:00
parent f915a9b1ac
commit 8979c744a8
26 changed files with 1044 additions and 683 deletions
+12 -6
View File
@@ -2,12 +2,17 @@ package classify
import "fmt"
// neutralMargin 判定明暗主导的最小分差:两分差幅小于该值视为中性图
// neutralMargin 判定"中性图"的最小分差:
// 如果亮色占比和暗色占比相差不到这个值,就认为这张图不亮不暗,归为「mid」中性。
const neutralMargin = 0.15
// BrightnessName 判定图片明暗等级,返回如 "dark_70" / "bright_40" / "mid"。
// 以主导分差幅判定亮暗:lightScore 高于 darkScore neutralMargin 以上归 bright,
// 反之归 dark,两者接近视为中性 mid(不再强制二选一)。
// BrightnessName 根据暗色/亮色像素占比,给图片定"明暗等级",返回类似:
// - "dark_70" → 暗色像素占 65%~75% 的暗图
// - "bright_40" → 亮色像素占 35%~45% 的亮图
// - "mid" → 明暗差不多,不好说谁多,归中性
//
// 判断规则(大白话):谁占得多、而且多出明显一截(超过 neutralMargin),
// 就按谁命名,数字是占比取整到 10% 一档。
func BrightnessName(darkScore, lightScore float64) string {
switch {
case lightScore > darkScore+neutralMargin:
@@ -19,8 +24,9 @@ func BrightnessName(darkScore, lightScore float64) string {
}
}
// brightnessTier 将占比分档到最接近的 10%,表示命中区间 [N-5%, N+5%),最小 10、最大 100。
// 例如 0.30~0.39 归 "30",0.96 以上归 "100",避免占比不足却挂高档名。
// brightnessTier 把占比(0~1)取整到"最接近的 10%"作为档位数字,范围 10~100。
// 例:0.30~0.39 → "30";0.96 以上 → "100"。
// 这样文件夹名里的数字不会出现"明明很少却挂个大数字"的情况。
func brightnessTier(score float64) int {
tier := int(score*100+5) / 10 * 10
if tier < 10 {
+54 -15
View File
@@ -1,4 +1,15 @@
// Package classify 提供纯函数的分辨率归类规则,不依赖任何状态。
// Package classify 负责给媒体文件定"分辨率档位",决定它该进哪个分类文件夹。
//
// 大白话理解,就是定分类命名规则:
// - 画面是横的还是竖的?(横屏 / 竖屏 / 正方形)
// - 清晰度算哪个档?(720P / 1080P / 4K…;不标准的按长边大小给个约数,如 5K)
// - 图片还要看是暗调还是亮调(dark / mid / light)
//
// 最终拼出一个文件夹名,比如:
// - 「图片_横屏_1080P-light」 一张横向、1080P、偏亮的图片
// - 「视频_竖屏_4k」 一个竖向、4K 的视频
//
// 这个包全是"纯函数"——不依赖任何状态,给什么算什么,好理解也好测。
package classify
import (
@@ -6,16 +17,18 @@ import (
"strings"
)
// Orientation 画面方向
// ============================ 画面方向 ============================
// Orientation 画面方向。
type Orientation int
const (
OrientationSquare Orientation = iota
OrientationHorizontal
OrientationVertical
OrientationSquare Orientation = iota // 正方形(宽 = 高)
OrientationHorizontal // 横屏(宽 > 高)
OrientationVertical // 竖屏(宽 < 高)
)
// OrientationOf 由宽高判定画面方向
// OrientationOf 根据宽高判断画面是横是竖还是正方。
func OrientationOf(width, height int) Orientation {
switch {
case width > height:
@@ -27,6 +40,7 @@ func OrientationOf(width, height int) Orientation {
}
}
// String 把方向转成中文名,方便拼进文件夹名和前端展示。
func (o Orientation) String() string {
switch o {
case OrientationHorizontal:
@@ -38,8 +52,11 @@ func (o Orientation) String() string {
}
}
// ImageDir 返回图片归类子目录名,如 "图片_横屏_1080P-dark_70" / "图片_横屏_1080P-mid" / "图片_GIF"。
// darkScore/lightScore 用于明暗分类(GIF 不做明暗分类,忽略这两项)。
// ============================ 归类目录名 ============================
// ImageDir 返回一张图片该进的文件夹名,比如:
// - 「图片_GIF」:GIF 动图单独放(不做明暗分类)
// - 「图片_横屏_1080P-light」:普通图片 = 方向 + 清晰度 + 明暗
func ImageDir(ext string, width, height int, darkScore, lightScore float64) string {
if strings.EqualFold(ext, ".gif") {
return "图片_GIF"
@@ -48,19 +65,25 @@ func ImageDir(ext string, width, height int, darkScore, lightScore float64) stri
return dir + "-" + BrightnessName(darkScore, lightScore)
}
// VideoDir 返回视频归类子目录名,如 "视频_竖屏_4k" / "视频_横屏_GIF"
// VideoDir 返回一个视频该进的文件夹名,比如「视频_竖屏_4k」「视频_横屏_GIF」。
func VideoDir(ext string, width, height int) string {
return resolutionDir("视频", ext, width, height)
}
// resolutionDir 拼出"类型 + 方向 + 清晰度"这段目录名。
// 步骤:先看宽高是横是竖;再匹配标准清晰度(720P/1080P…),
// 匹配不上就按长边大小给个约数(普通 / 5K / 原画质)。
func resolutionDir(kind, ext string, width, height int) string {
ori := OrientationOf(width, height).String()
// GIF 特殊:不管多清楚,都归「_GIF」
if strings.EqualFold(ext, ".gif") {
return kind + "_" + ori + "_GIF"
}
// 先看是不是标准清晰度(精确匹配宽高)
if suffix := matchStandard(width, height); suffix != "" {
return kind + "_" + ori + "_" + suffix
}
// 不是标准清晰度:按长边给约数。图片叫"原图",视频叫"原画质"(超出档位时用)
overflow := "原图"
if kind == "视频" {
overflow = "原画质"
@@ -68,7 +91,7 @@ func resolutionDir(kind, ext string, width, height int) string {
return kind + "_" + ori + "_" + levelName(longEdge(width, height), overflow)
}
// longEdge 返回长边像素
// longEdge 返回宽和高中较长的那条边(像素)。
func longEdge(w, h int) int {
if w > h {
return w
@@ -76,8 +99,16 @@ func longEdge(w, h int) int {
return h
}
// ResLabel 返回分辨率档位标签(标准后缀 / 1K~19K / 普通 / 原图),无有效尺寸时返回空串。
// 供媒体库统计与筛选使用,图片/视频统一以"原图"作为超档标签。
// ============================ 分辨率档位(给前端筛选/统计用) ============================
// ResLabel 把一个宽高翻译成"分辨率档位标签",例如:
// - 1920×1080 → "1080P"(标准档直接叫标准名)
// - 2560×1440 → "2D5KP"
// - 1024×768 → "普通"
// - 5120×2880 → "5K"(约数)
// - 20000×10000 → "原图"(超出档位上限)
//
// 图片、视频统一用"原图"作为超出档位的标签。宽高无效时返回空串。
func ResLabel(width, height int) string {
if width <= 0 || height <= 0 {
return ""
@@ -88,24 +119,32 @@ func ResLabel(width, height int) string {
return levelName(longEdge(width, height), "原图")
}
// ResRange 解析分辨率档位标签,返回 (长边下限 minLong, 长边上限 maxLong(不含), 精确宽 exactW, 精确高 exactH)。
// minLong<0 表示未知标签;maxLong 为 -1 表示无上限;exactW/exactH 非零时表示标准分辨率精确匹配。
// 供数据层构造 SQL 过滤条件,规则与归类逻辑保持一致。
// ResRange 把"分辨率档位标签"翻译回具体的尺寸范围,给数据库查 SQL 用。
// 返回四样东西:
// - minLong:长边至少多少像素(-1 表示这个标签不认得)
// - maxLong:长边小于多少像素(-1 表示没有上限)
// - exactW / exactH:标准分辨率的精确宽高(非零表示要精确匹配,不用管范围)
//
// 规则和归类逻辑必须保持一致,否则筛选出来的和实际分类会对不上。
func ResRange(label string) (minLong, maxLong, exactW, exactH int) {
if label == "" {
return -1, -1, 0, 0
}
// 标准档:精确匹配宽高
for _, r := range standardResolutions {
if r.suffix == label {
return 0, 0, r.width, r.height
}
}
// 「普通」:长边不到 1 档(<1024px)
if label == "普通" {
return 0, bucketStep, 0, 0
}
// 「原图」:长边超出最大档(≥20480px),没有上限
if label == "原图" {
return (maxLevel + 1) * bucketStep, -1, 0, 0
}
// 「NK」档(如 5K):长边在第 N 档到第 N+1 档之间
if len(label) > 1 && label[len(label)-1] == 'K' {
var level int
if _, err := fmt.Sscanf(label, "%dK", &level); err == nil && level >= 1 && level <= maxLevel {
+18 -9
View File
@@ -2,16 +2,19 @@ package classify
import "fmt"
// standardResolution 标准分辨率规则:宽高精确匹配
// standardResolution 一条"标准分辨率"规则:宽高精确对上就算这个档位。
// 比如 1920×1080 就叫"1080P",而不是按长边算成"1K"。
type standardResolution struct {
width int
height int
suffix string
suffix string // 这个档位叫什么名(也用作文件夹名 / 筛选标签)
}
// 标准分辨率表:都是一些大家熟悉的清晰度档位。
// (1D5KP、2DKP、2D5KP 这些是介于常见档位之间的中间值,能对上就精确分类,对不上就按约数。)
var standardResolutions = []standardResolution{
{1280, 720, "720P"}, // 720p 标准高清
{1600, 900, "1D5KP"}, // 介于 HD 与 FHD 之间
{1600, 900, "1D5KP"}, // 介于 720P 与 1080P 之间
{1920, 1080, "1080P"}, // 1080p 全高清
{2048, 1080, "2DKP"}, // 影院 2K
{2560, 1440, "2D5KP"}, // 2.5K
@@ -19,17 +22,20 @@ var standardResolutions = []standardResolution{
{3440, 1440, "UltraWide3KP"}, // 带鱼屏
{5120, 2160, "UltraWide4KP"}, // 带鱼屏
{3840, 2160, "4KP"}, // 主流 4K
{4096, 2160, "4KHP"}, // DCI 4K
{4096, 2160, "4KHP"}, // DCI 影院 4K
{7680, 4320, "8KP"}, // 8K
}
// 非标分辨率分档参数
// 非标准分辨率的"约数"分档参数:
// - 长边不足 1024px 的,叫「普通」;
// - 每满 1024px 一档,叫 1K、2K … 19K;
// - 超过 19K 的,叫「原图」(视频叫「原画质」)。
const (
bucketStep = 1024 // 每 1024px 一档
maxLevel = 19 // 1K ~ 19K 共 19 档(普通另算 1 档,合计 20 档)
bucketStep = 1024 // 每 1024 像素算一档
maxLevel = 19 // 最高算到 19K
)
// matchStandard 精确匹配标准分辨率,命中返回后缀,否则返回空串
// matchStandard 精确匹配标准分辨率:宽高都对上就返回档位名,否则返回空串。
func matchStandard(width, height int) string {
for _, r := range standardResolutions {
if r.width == width && r.height == height {
@@ -39,7 +45,10 @@ func matchStandard(width, height int) string {
return ""
}
// levelName 按长边分档:普通(<1024) / 1K~19K(每1024一档) / overflow(≥20480)
// levelName 按长边分档:
// - 不到一档(<1024px):「普通」
// - 1K~19K:按 1024px 一档,几档就叫几 K
// - 超过 19K:用调用方给的兜底名(图片「原图」/ 视频「原画质」)
func levelName(longEdge int, overflow string) string {
if longEdge < bucketStep {
return "普通"