Upload 2 files
Browse files- models_manager_v7.bat +15 -0
- models_manager_v7.py +1681 -0
models_manager_v7.bat
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@echo off
|
| 2 |
+
chcp 65001 >nul
|
| 3 |
+
title LoRA 仓库管理工具
|
| 4 |
+
cd /d "%~dp0"
|
| 5 |
+
|
| 6 |
+
python models_manager_v7.py
|
| 7 |
+
|
| 8 |
+
:: 检查上一句命令的返回值,如果非 0(代表报错崩溃了),就暂停
|
| 9 |
+
if %errorlevel% neq 0 (
|
| 10 |
+
echo.
|
| 11 |
+
echo ==========================================
|
| 12 |
+
echo ❌ 程序异常退出!错误码: %errorlevel%
|
| 13 |
+
echo ==========================================
|
| 14 |
+
pause
|
| 15 |
+
)
|
models_manager_v7.py
ADDED
|
@@ -0,0 +1,1681 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# models_manager.py
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
import time
|
| 6 |
+
import threading
|
| 7 |
+
import traceback
|
| 8 |
+
import tkinter as tk
|
| 9 |
+
from tkinter import ttk, messagebox, filedialog
|
| 10 |
+
from tkinter.scrolledtext import ScrolledText
|
| 11 |
+
import urllib.request
|
| 12 |
+
import urllib.error
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
import subprocess
|
| 15 |
+
import tempfile
|
| 16 |
+
import pandas as pd
|
| 17 |
+
from datasets import Dataset, Features, Image as HFImage, Value
|
| 18 |
+
from huggingface_hub import HfApi, DatasetCard
|
| 19 |
+
from PIL import Image
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
for k in ["HTTP_PROXY", "HTTPS_PROXY", "http_proxy", "https_proxy", "ALL_PROXY", "all_proxy"]:
|
| 23 |
+
os.environ.pop(k, None)
|
| 24 |
+
|
| 25 |
+
# ================= 配置区 =================
|
| 26 |
+
SITE_CONFIG_FILE = "site_config.json"
|
| 27 |
+
SPACE_ID = "waveshaker/loras_gallery"
|
| 28 |
+
MODEL_REPO_ID = "waveshaker/loras"
|
| 29 |
+
DATASET_REPO_ID = "waveshaker/dataset_collections"
|
| 30 |
+
DATASET_BASE = "https://huggingface.co/datasets/waveshaker/dataset_collections/tree/main"
|
| 31 |
+
|
| 32 |
+
OUTPUT_FILE = "models.json"
|
| 33 |
+
TOKEN_FILE = "hf_token.txt"
|
| 34 |
+
LOG_FILE = "models_manager.log"
|
| 35 |
+
HTTP_TIMEOUT = 20
|
| 36 |
+
# ==========================================
|
| 37 |
+
|
| 38 |
+
TOP_ORDER = {"krea2": 1, "anima": 2, "flux1_dev": 3, "pony": 4, "sd15": 5}
|
| 39 |
+
SUB_ORDER = {"CN": 1, "KR": 2, "JP": 3, "Fictional": 4, "WE": 5}
|
| 40 |
+
IMG_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
|
| 41 |
+
|
| 42 |
+
def custom_sort_key(file_path):
|
| 43 |
+
parts = file_path.split('/')
|
| 44 |
+
top_dir = parts[0] if len(parts) > 0 else ""
|
| 45 |
+
sub_dir = parts[1] if len(parts) > 1 else ""
|
| 46 |
+
return (TOP_ORDER.get(top_dir, 999), SUB_ORDER.get(sub_dir, 999), file_path)
|
| 47 |
+
|
| 48 |
+
# ---------- 可选拖拽 ----------
|
| 49 |
+
try:
|
| 50 |
+
from tkinterdnd2 import DND_FILES, TkinterDnD
|
| 51 |
+
_HAS_DND = True
|
| 52 |
+
except Exception:
|
| 53 |
+
DND_FILES = None
|
| 54 |
+
TkinterDnD = None
|
| 55 |
+
_HAS_DND = False
|
| 56 |
+
|
| 57 |
+
class ModelsManager(tk.Tk):
|
| 58 |
+
def __init__(self):
|
| 59 |
+
super().__init__()
|
| 60 |
+
self.title("LoRA 仓库管理工具")
|
| 61 |
+
self.geometry("1250x850")
|
| 62 |
+
|
| 63 |
+
# 全局 Token
|
| 64 |
+
self.token_var = tk.StringVar(value=self._load_token())
|
| 65 |
+
|
| 66 |
+
# 🌟 新增:保存 site_config 数据
|
| 67 |
+
self.site_config_data = {"models": [], "categories": []}
|
| 68 |
+
|
| 69 |
+
# JSON 相关状态
|
| 70 |
+
self.items = {}
|
| 71 |
+
self.remote_files = []
|
| 72 |
+
self.new_paths = set()
|
| 73 |
+
self.stale_paths = set()
|
| 74 |
+
self._search_job = None
|
| 75 |
+
self._loading = False
|
| 76 |
+
self.api = None
|
| 77 |
+
|
| 78 |
+
# 🌟 Model 上传相关状态:自动获取当前脚本路径,并指向同级的 models 文件夹
|
| 79 |
+
if getattr(sys, 'frozen', False):
|
| 80 |
+
current_dir = os.path.dirname(sys.executable)
|
| 81 |
+
else:
|
| 82 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 83 |
+
|
| 84 |
+
default_models_dir = os.path.join(current_dir, "models")
|
| 85 |
+
if not os.path.exists(default_models_dir):
|
| 86 |
+
os.makedirs(default_models_dir, exist_ok=True)
|
| 87 |
+
|
| 88 |
+
self.local_folder_var = tk.StringVar(value=default_models_dir)
|
| 89 |
+
self.remote_dir_var = tk.StringVar()
|
| 90 |
+
|
| 91 |
+
# 全局 Token
|
| 92 |
+
self.token_var = tk.StringVar(value=self._load_token())
|
| 93 |
+
|
| 94 |
+
# ============ 初始化 UI ============
|
| 95 |
+
self._build_global_ui()
|
| 96 |
+
self._build_json_tab()
|
| 97 |
+
self._build_model_tab()
|
| 98 |
+
self._build_dataset_tab() # <--- 新增这一行
|
| 99 |
+
self._build_showcase_tab() # 🌟 加上这一行
|
| 100 |
+
|
| 101 |
+
self._refresh_api()
|
| 102 |
+
self._setup_dnd()
|
| 103 |
+
self.log_json("程序启动")
|
| 104 |
+
|
| 105 |
+
# ==========================================================
|
| 106 |
+
# ===================== 全局基础函数 =======================
|
| 107 |
+
# ==========================================================
|
| 108 |
+
def log_json(self, msg):
|
| 109 |
+
self._log_to_tab(msg, getattr(self, 'log_json_text', None))
|
| 110 |
+
|
| 111 |
+
def log_model(self, msg):
|
| 112 |
+
self._log_to_tab(msg, getattr(self, 'log_model_text', None))
|
| 113 |
+
|
| 114 |
+
def _log_to_tab(self, msg, target_text_widget):
|
| 115 |
+
if not target_text_widget: return
|
| 116 |
+
ts = time.strftime("%H:%M:%S")
|
| 117 |
+
line = f"[{ts}] {msg}"
|
| 118 |
+
try:
|
| 119 |
+
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
| 120 |
+
f.write(line + "\n")
|
| 121 |
+
except Exception: pass
|
| 122 |
+
|
| 123 |
+
def _append():
|
| 124 |
+
try:
|
| 125 |
+
target_text_widget.configure(state="normal")
|
| 126 |
+
target_text_widget.insert("end", line + "\n")
|
| 127 |
+
target_text_widget.see("end")
|
| 128 |
+
target_text_widget.configure(state="disabled")
|
| 129 |
+
except Exception: pass
|
| 130 |
+
|
| 131 |
+
if threading.current_thread() is threading.main_thread():
|
| 132 |
+
_append()
|
| 133 |
+
else:
|
| 134 |
+
self.after(0, _append)
|
| 135 |
+
|
| 136 |
+
def log_exc(self, prefix="", is_model=False):
|
| 137 |
+
tb = traceback.format_exc()
|
| 138 |
+
msg = f"{prefix}异常:\n{tb}"
|
| 139 |
+
if is_model:
|
| 140 |
+
self.log_model(msg)
|
| 141 |
+
else:
|
| 142 |
+
self.log_json(msg)
|
| 143 |
+
|
| 144 |
+
def _load_token(self):
|
| 145 |
+
env = os.environ.get("HF_TOKEN", "").strip()
|
| 146 |
+
if env: return env
|
| 147 |
+
try:
|
| 148 |
+
with open(TOKEN_FILE, "r", encoding="utf-8") as f:
|
| 149 |
+
return f.read().strip()
|
| 150 |
+
except FileNotFoundError:
|
| 151 |
+
return ""
|
| 152 |
+
|
| 153 |
+
def _save_token(self, token):
|
| 154 |
+
if token:
|
| 155 |
+
with open(TOKEN_FILE, "w", encoding="utf-8") as f: f.write(token)
|
| 156 |
+
else:
|
| 157 |
+
try: os.remove(TOKEN_FILE)
|
| 158 |
+
except FileNotFoundError: pass
|
| 159 |
+
|
| 160 |
+
def _refresh_api(self):
|
| 161 |
+
token = self.token_var.get().strip()
|
| 162 |
+
self.api = HfApi(token=token) if token else HfApi()
|
| 163 |
+
|
| 164 |
+
def _ensure_token(self):
|
| 165 |
+
token = self.token_var.get().strip()
|
| 166 |
+
if not token:
|
| 167 |
+
messagebox.showwarning("需要 Token", "请先在上方填写 HF Token 再操作。")
|
| 168 |
+
self.log_json("操作中止:未填写 Token")
|
| 169 |
+
return None
|
| 170 |
+
self._refresh_api()
|
| 171 |
+
return token
|
| 172 |
+
|
| 173 |
+
def _build_global_ui(self):
|
| 174 |
+
# 1. 顶部 Token 行
|
| 175 |
+
token_row = ttk.Frame(self)
|
| 176 |
+
token_row.pack(fill="x", padx=8, pady=(8, 2))
|
| 177 |
+
ttk.Label(token_row, text="HF Token:").pack(side="left")
|
| 178 |
+
ttk.Entry(token_row, textvariable=self.token_var, width=60, show="*").pack(side="left", padx=6)
|
| 179 |
+
ttk.Button(token_row, text="📥 保存 Token", command=self._on_save_token).pack(side="left")
|
| 180 |
+
ttk.Button(token_row, text="❌ 清除", command=self._on_clear_token).pack(side="left", padx=4)
|
| 181 |
+
|
| 182 |
+
ttk.Button(token_row, text="📄 打开日志", command=self.open_log_file).pack(side="right", padx=6)
|
| 183 |
+
|
| 184 |
+
# 1. 恢复 Windows 默认主题(看起来最舒服)
|
| 185 |
+
style = ttk.Style()
|
| 186 |
+
# 如果你在 Windows 11 上,可以用 'vista' 或 'winnative',如果想要更现代可以试试 'xpnative'
|
| 187 |
+
if sys.platform.startswith("win"):
|
| 188 |
+
style.theme_use('vista')
|
| 189 |
+
|
| 190 |
+
# 2. 强制扁平化 Tab 的渲染结构(屏蔽掉系统的边框和虚线焦点)
|
| 191 |
+
style.layout("TNotebook.Tab", [
|
| 192 |
+
('Notebook.tab', {'sticky': 'nswe', 'children': [
|
| 193 |
+
('Notebook.padding', {'side': 'top', 'sticky': 'nswe', 'children': [
|
| 194 |
+
('Notebook.label', {'side': 'top', 'sticky': ''})
|
| 195 |
+
]})
|
| 196 |
+
]})
|
| 197 |
+
])
|
| 198 |
+
|
| 199 |
+
# 3. 基础设置
|
| 200 |
+
style.configure("TNotebook.Tab",
|
| 201 |
+
padding=[12, 6],
|
| 202 |
+
font=("Microsoft YaHei", 9),
|
| 203 |
+
borderwidth=0,
|
| 204 |
+
background="#f0f0f0",
|
| 205 |
+
foreground="#333333"
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# 4. 锁定所有状态,防止任何像素级跳动
|
| 209 |
+
style.map("TNotebook.Tab",
|
| 210 |
+
padding=[("selected", [12, 6]), ("active", [12, 6]), ("!selected", [12, 6])],
|
| 211 |
+
borderwidth=[("selected", 0), ("active", 0), ("!selected", 0)],
|
| 212 |
+
# 焦点框设为空,去掉点击时的虚线框
|
| 213 |
+
focuscolor=[("selected", "none"), ("active", "none"), ("!selected", "none")],
|
| 214 |
+
# 选中和悬停时的颜色
|
| 215 |
+
background=[("selected", "#e1f5fe"), ("active", "#f5f5f5")],
|
| 216 |
+
foreground=[("selected", "#0277bd")]
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
# 3. 创建 Notebook 和 Tab 页面
|
| 220 |
+
self.notebook = ttk.Notebook(self)
|
| 221 |
+
self.notebook.pack(fill="both", expand=True, padx=8, pady=4)
|
| 222 |
+
|
| 223 |
+
self.tab_json = ttk.Frame(self.notebook)
|
| 224 |
+
self.tab_model = ttk.Frame(self.notebook)
|
| 225 |
+
self.tab_dataset = ttk.Frame(self.notebook)
|
| 226 |
+
|
| 227 |
+
# 4. 🌟 添加 Tab(Emoji + 文字)
|
| 228 |
+
self.notebook.add(self.tab_json, text=" 🚀 Space - JSON 管理 ")
|
| 229 |
+
self.notebook.add(self.tab_model, text=" 🧊 Models - 模型上传 ")
|
| 230 |
+
self.notebook.add(self.tab_dataset, text=" 📚 Dataset - 数据集上传 ")
|
| 231 |
+
self.tab_showcase = ttk.Frame(self.notebook)
|
| 232 |
+
self.notebook.add(self.tab_showcase, text=" ✨ Showcase - 案例上传 ")
|
| 233 |
+
|
| 234 |
+
def _on_save_token(self):
|
| 235 |
+
self._save_token(self.token_var.get().strip())
|
| 236 |
+
self._refresh_api()
|
| 237 |
+
self.log_json("Token 已保存")
|
| 238 |
+
messagebox.showinfo("完成", "Token 已保存到本地")
|
| 239 |
+
|
| 240 |
+
def _on_clear_token(self):
|
| 241 |
+
self.token_var.set("")
|
| 242 |
+
self._save_token("")
|
| 243 |
+
self._refresh_api()
|
| 244 |
+
self.log_json("Token 已清除")
|
| 245 |
+
messagebox.showinfo("完成", "Token 已清除")
|
| 246 |
+
|
| 247 |
+
def open_log_file(self):
|
| 248 |
+
try: os.startfile(LOG_FILE)
|
| 249 |
+
except Exception:
|
| 250 |
+
try:
|
| 251 |
+
import subprocess
|
| 252 |
+
subprocess.Popen(["notepad", LOG_FILE])
|
| 253 |
+
except Exception as e:
|
| 254 |
+
messagebox.showerror("错误", f"无法打开日志: {e}")
|
| 255 |
+
|
| 256 |
+
def _setup_dnd(self):
|
| 257 |
+
if not _HAS_DND: return
|
| 258 |
+
try:
|
| 259 |
+
TkinterDnD._require(self)
|
| 260 |
+
self.tree.drop_target_register(DND_FILES)
|
| 261 |
+
self.tree.dnd_bind("<<Drop>>", self._on_drop_json)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
self.log_json(f"DnD 初始化���败: {e}")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ==========================================================
|
| 267 |
+
# ==================== 区域 1: JSON 管理 ===================
|
| 268 |
+
# ==========================================================
|
| 269 |
+
def _build_json_tab(self):
|
| 270 |
+
parent = self.tab_json
|
| 271 |
+
|
| 272 |
+
top = ttk.Frame(parent)
|
| 273 |
+
top.pack(fill="x", padx=8, pady=4)
|
| 274 |
+
self.btn_load_remote = ttk.Button(top, text="🔄 拉取 models.json", command=self.load_remote)
|
| 275 |
+
self.btn_load_remote.pack(side="left")
|
| 276 |
+
ttk.Button(top, text="📂 加载本地 models.json", command=self.load_local).pack(side="left", padx=6)
|
| 277 |
+
ttk.Button(top, text="💾 保存 models.json 到本地", command=self.save_local).pack(side="left")
|
| 278 |
+
ttk.Button(top, text="🢁 上传 models.json", command=self.upload_models_json).pack(side="left", padx=6)
|
| 279 |
+
# 🌟 新增:配置同步按钮
|
| 280 |
+
ttk.Button(top, text="🌐 打开 Dataset Repo", compound="left", command=self.open_dataset_repo).pack(side="right", padx=6)
|
| 281 |
+
ttk.Button(top, text="⚙️ 编辑 site_config", command=self.open_site_config_editor).pack(side="right", padx=12)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
filt = ttk.Frame(parent)
|
| 285 |
+
filt.pack(fill="x", padx=8)
|
| 286 |
+
ttk.Label(filt, text="🔍 搜索:").pack(side="left")
|
| 287 |
+
self.search_var = tk.StringVar()
|
| 288 |
+
self.search_var.trace_add("write", lambda *_: self._debounced_refresh())
|
| 289 |
+
ttk.Entry(filt, textvariable=self.search_var, width=30).pack(side="left", padx=4)
|
| 290 |
+
|
| 291 |
+
self.only_new = tk.BooleanVar(value=False)
|
| 292 |
+
ttk.Checkbutton(filt, text="只看新增", variable=self.only_new, command=self.refresh_tree).pack(side="left", padx=6)
|
| 293 |
+
self.only_empty_trigger = tk.BooleanVar(value=False)
|
| 294 |
+
ttk.Checkbutton(filt, text="只看 trigger 为空", variable=self.only_empty_trigger, command=self.refresh_tree).pack(side="left")
|
| 295 |
+
|
| 296 |
+
body = ttk.Panedwindow(parent, orient="vertical")
|
| 297 |
+
body.pack(fill="both", expand=True, padx=8, pady=6)
|
| 298 |
+
|
| 299 |
+
tree_frame = ttk.Frame(body)
|
| 300 |
+
cols = ("title", "trigger", "dataset", "showcase") # 🌟 新增 showcase 列
|
| 301 |
+
self.tree = ttk.Treeview(tree_frame, columns=cols, show="tree headings", height=20)
|
| 302 |
+
self.tree.heading("#0", text="路径")
|
| 303 |
+
self.tree.heading("title", text="title")
|
| 304 |
+
self.tree.heading("trigger", text="trigger")
|
| 305 |
+
self.tree.heading("dataset", text="dataset")
|
| 306 |
+
self.tree.heading("showcase", text="showcase") # 🌟 新增
|
| 307 |
+
|
| 308 |
+
self.tree.column("#0", width=380, stretch=True)
|
| 309 |
+
self.tree.column("title", width=180)
|
| 310 |
+
self.tree.column("trigger", width=260)
|
| 311 |
+
self.tree.column("dataset", width=240)
|
| 312 |
+
self.tree.column("showcase", width=200) # 🌟 加入 showcase
|
| 313 |
+
|
| 314 |
+
vsb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
| 315 |
+
self.tree.configure(yscrollcommand=vsb.set)
|
| 316 |
+
self.tree.pack(side="left", fill="both", expand=True)
|
| 317 |
+
vsb.pack(side="left", fill="y")
|
| 318 |
+
self.tree.tag_configure("new", background="#d4f5d4")
|
| 319 |
+
self.tree.tag_configure("empty_trigger", background="#fff3c4")
|
| 320 |
+
self.tree.tag_configure("stale", foreground="#999999")
|
| 321 |
+
self.tree.bind("<Double-1>", self.on_double_click)
|
| 322 |
+
|
| 323 |
+
log_frame = ttk.LabelFrame(body, text="JSON 操作日志")
|
| 324 |
+
self.log_json_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
|
| 325 |
+
self.log_json_text.pack(fill="both", expand=True)
|
| 326 |
+
|
| 327 |
+
body.add(tree_frame, weight=3)
|
| 328 |
+
body.add(log_frame, weight=1)
|
| 329 |
+
|
| 330 |
+
def open_dataset_repo(self):
|
| 331 |
+
import webbrowser
|
| 332 |
+
webbrowser.open(f"https://huggingface.co/datasets/{DATASET_REPO_ID}/tree/main")
|
| 333 |
+
|
| 334 |
+
def _debounced_refresh(self):
|
| 335 |
+
if self._search_job: self.after_cancel(self._search_job)
|
| 336 |
+
self._search_job = self.after(250, self.refresh_tree)
|
| 337 |
+
|
| 338 |
+
def load_local(self):
|
| 339 |
+
self.log_json(f"加载本地 {OUTPUT_FILE}")
|
| 340 |
+
try:
|
| 341 |
+
with open(OUTPUT_FILE, "r", encoding="utf-8") as f:
|
| 342 |
+
data = json.load(f)
|
| 343 |
+
except FileNotFoundError:
|
| 344 |
+
messagebox.showwarning("提示", f"本地没有 {OUTPUT_FILE}")
|
| 345 |
+
return
|
| 346 |
+
except Exception as e:
|
| 347 |
+
self.log_json(f"读取失败: {e}")
|
| 348 |
+
return
|
| 349 |
+
|
| 350 |
+
self.items = data.get("items", {})
|
| 351 |
+
if not self.items and "titles" in data:
|
| 352 |
+
self.items = {k: {"title": v, "trigger": "", "dataset": ""} for k, v in data["titles"].items()}
|
| 353 |
+
self.new_paths = set()
|
| 354 |
+
self.stale_paths = set()
|
| 355 |
+
self.refresh_tree()
|
| 356 |
+
self.log_json(f"本地加载完成,{len(self.items)} 条")
|
| 357 |
+
|
| 358 |
+
def load_remote(self):
|
| 359 |
+
if self._loading: return
|
| 360 |
+
self._loading = True
|
| 361 |
+
self.btn_load_remote.configure(state="disabled")
|
| 362 |
+
token = self.token_var.get().strip()
|
| 363 |
+
self.log_json(f"开始拉取远端…")
|
| 364 |
+
threading.Thread(target=self._load_remote_worker, args=(token,), daemon=True).start()
|
| 365 |
+
|
| 366 |
+
def _load_remote_worker(self, token):
|
| 367 |
+
try:
|
| 368 |
+
api = HfApi(token=token) if token else HfApi()
|
| 369 |
+
t0 = time.time()
|
| 370 |
+
all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
|
| 371 |
+
remote_files = [f for f in all_files if f.endswith(".safetensors")]
|
| 372 |
+
self.log_json(f"步骤1: 远端文件 {len(all_files)},safetensors {len(remote_files)},{time.time()-t0:.2f}s")
|
| 373 |
+
|
| 374 |
+
space_subdomain = SPACE_ID.replace("/", "-").replace("_", "-").lower()
|
| 375 |
+
url = f"https://{space_subdomain}.static.hf.space/models.json"
|
| 376 |
+
remote_data = None
|
| 377 |
+
try:
|
| 378 |
+
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
| 379 |
+
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as response:
|
| 380 |
+
remote_data = json.loads(response.read().decode())
|
| 381 |
+
self.log_json(f"步骤2: JSON 获取成功")
|
| 382 |
+
except Exception as e:
|
| 383 |
+
self.log_json(f"步骤2 失败: {type(e).__name__}: {e}")
|
| 384 |
+
|
| 385 |
+
existing_items = {}
|
| 386 |
+
if remote_data:
|
| 387 |
+
existing_items = remote_data.get("items", {})
|
| 388 |
+
if not existing_items and "titles" in remote_data:
|
| 389 |
+
existing_items = {k: {"title": v, "trigger": "", "dataset": ""} for k, v in remote_data["titles"].items()}
|
| 390 |
+
|
| 391 |
+
merged, new_paths = {}, set()
|
| 392 |
+
remote_set = set(remote_files)
|
| 393 |
+
for path in remote_files:
|
| 394 |
+
if path in existing_items:
|
| 395 |
+
merged[path] = existing_items[path]
|
| 396 |
+
else:
|
| 397 |
+
merged[path] = {"title": "", "trigger": "", "dataset": ""}
|
| 398 |
+
new_paths.add(path)
|
| 399 |
+
stale_paths = set(existing_items.keys()) - remote_set
|
| 400 |
+
for p in stale_paths:
|
| 401 |
+
merged[p] = existing_items[p]
|
| 402 |
+
|
| 403 |
+
items = {k: merged[k] for k in sorted(merged.keys(), key=custom_sort_key)}
|
| 404 |
+
self.log_json(f"步骤3: 合并 {len(items)} 条,新增 {len(new_paths)},失效 {len(stale_paths)}")
|
| 405 |
+
|
| 406 |
+
self.after(0, lambda: self._apply_remote(items, remote_files, new_paths, stale_paths))
|
| 407 |
+
except Exception:
|
| 408 |
+
self.log_exc("拉取失败")
|
| 409 |
+
self.after(0, lambda: messagebox.showerror("错误", "拉取失败,详见日志"))
|
| 410 |
+
finally:
|
| 411 |
+
self.after(0, self._finish_loading)
|
| 412 |
+
|
| 413 |
+
def _apply_remote(self, items, remote_files, new_paths, stale_paths):
|
| 414 |
+
self.items = items
|
| 415 |
+
self.remote_files = remote_files
|
| 416 |
+
self.new_paths = new_paths
|
| 417 |
+
self.stale_paths = stale_paths
|
| 418 |
+
self.refresh_tree()
|
| 419 |
+
|
| 420 |
+
def _finish_loading(self):
|
| 421 |
+
self._loading = False
|
| 422 |
+
self.btn_load_remote.configure(state="normal")
|
| 423 |
+
|
| 424 |
+
def refresh_tree(self):
|
| 425 |
+
self.tree.delete(*self.tree.get_children())
|
| 426 |
+
kw = self.search_var.get().strip().lower()
|
| 427 |
+
only_new = self.only_new.get()
|
| 428 |
+
only_empty = self.only_empty_trigger.get()
|
| 429 |
+
|
| 430 |
+
for path, meta in self.items.items():
|
| 431 |
+
title = meta.get("title", "")
|
| 432 |
+
trigger = meta.get("trigger", "")
|
| 433 |
+
if kw and kw not in path.lower() and kw not in str(title).lower(): continue
|
| 434 |
+
if only_new and path not in self.new_paths: continue
|
| 435 |
+
if only_empty and trigger not in ("", [], None): continue
|
| 436 |
+
|
| 437 |
+
tags = []
|
| 438 |
+
if path in self.new_paths: tags.append("new")
|
| 439 |
+
elif path in self.stale_paths: tags.append("stale")
|
| 440 |
+
if trigger in ("", [], None): tags.append("empty_trigger")
|
| 441 |
+
|
| 442 |
+
trig_display = trigger if isinstance(trigger, str) else " | ".join(map(str, trigger))
|
| 443 |
+
self.tree.insert("", "end", iid=path, text=path, values=(title, trig_display, meta.get("dataset", ""), meta.get("showcase", "")), tags=tags)
|
| 444 |
+
|
| 445 |
+
def on_double_click(self, event):
|
| 446 |
+
sel = self.tree.focus()
|
| 447 |
+
if sel and sel in self.items:
|
| 448 |
+
self.open_manager(sel)
|
| 449 |
+
|
| 450 |
+
def open_manager(self, path):
|
| 451 |
+
meta = self.items[path]
|
| 452 |
+
win = tk.Toplevel(self)
|
| 453 |
+
win.title(f"编辑: {path}")
|
| 454 |
+
win.geometry("720x560")
|
| 455 |
+
win.transient(self); win.grab_set()
|
| 456 |
+
|
| 457 |
+
ttk.Label(win, text="路径").pack(anchor="w", padx=10, pady=(10, 0))
|
| 458 |
+
p_entry = ttk.Entry(win, width=90)
|
| 459 |
+
p_entry.insert(0, path); p_entry.configure(state="readonly"); p_entry.pack(padx=10, fill="x")
|
| 460 |
+
|
| 461 |
+
ttk.Label(win, text="title").pack(anchor="w", padx=10, pady=(8, 0))
|
| 462 |
+
title_var = tk.StringVar(value=meta.get("title", ""))
|
| 463 |
+
ttk.Entry(win, textvariable=title_var, width=90).pack(padx=10, fill="x")
|
| 464 |
+
|
| 465 |
+
ttk.Label(win, text="trigger").pack(anchor="w", padx=10, pady=(8, 0))
|
| 466 |
+
trig_text = tk.Text(win, height=8, wrap="word"); trig_text.pack(padx=10, fill="both")
|
| 467 |
+
trig = meta.get("trigger", "")
|
| 468 |
+
trig_text.insert("1.0", "\n".join(map(str, trig)) if isinstance(trig, list) else trig)
|
| 469 |
+
|
| 470 |
+
ttk.Label(win, text="dataset").pack(anchor="w", padx=10, pady=(8, 0))
|
| 471 |
+
ds_var = tk.StringVar(value=meta.get("dataset", ""))
|
| 472 |
+
ttk.Entry(win, textvariable=ds_var, width=90).pack(padx=10, fill="x")
|
| 473 |
+
|
| 474 |
+
# 🌟 新增:showcase 输入框
|
| 475 |
+
ttk.Label(win, text="showcase (URL)").pack(anchor="w", padx=10, pady=(8, 0))
|
| 476 |
+
sc_var = tk.StringVar(value=meta.get("showcase", ""))
|
| 477 |
+
ttk.Entry(win, textvariable=sc_var, width=90).pack(padx=10, fill="x")
|
| 478 |
+
|
| 479 |
+
def auto_dataset():
|
| 480 |
+
parts = path.split("/")
|
| 481 |
+
if len(parts) >= 3:
|
| 482 |
+
top, sub = parts[0], parts[1]
|
| 483 |
+
base = Path(parts[2]).stem
|
| 484 |
+
for noise in ("_onetrainer", "_Flux_512", "_Flux", "_512"):
|
| 485 |
+
base = base.replace(noise, "")
|
| 486 |
+
ds_var.set(f"{DATASET_BASE}/{top}/{sub}/{base}")
|
| 487 |
+
|
| 488 |
+
ttk.Button(win, text="自动生成 dataset", command=auto_dataset).pack(anchor="w", padx=10, pady=4)
|
| 489 |
+
|
| 490 |
+
def save_and_close():
|
| 491 |
+
title = title_var.get().strip()
|
| 492 |
+
raw = trig_text.get("1.0", "end").strip()
|
| 493 |
+
trigger_val = ([l.strip() for l in raw.splitlines() if l.strip()] if "\n" in raw else raw)
|
| 494 |
+
self.items[path] = {"title": title, "trigger": trigger_val, "dataset": ds_var.get().strip(),"showcase": sc_var.get().strip()}
|
| 495 |
+
self.refresh_tree()
|
| 496 |
+
win.destroy()
|
| 497 |
+
|
| 498 |
+
btn = ttk.Frame(win); btn.pack(fill="x", pady=10, padx=10)
|
| 499 |
+
ttk.Button(btn, text="保存", command=save_and_close).pack(side="right")
|
| 500 |
+
ttk.Button(btn, text="取消", command=win.destroy).pack(side="right", padx=6)
|
| 501 |
+
|
| 502 |
+
def save_local(self):
|
| 503 |
+
data_items = {k: v for k, v in self.items.items() if k not in self.stale_paths}
|
| 504 |
+
sorted_items = {k: data_items[k] for k in sorted(data_items.keys(), key=custom_sort_key)}
|
| 505 |
+
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
|
| 506 |
+
json.dump({"items": sorted_items}, f, indent=2, ensure_ascii=False)
|
| 507 |
+
self.log_json(f"已保存本地 {OUTPUT_FILE},{len(sorted_items)} 条")
|
| 508 |
+
messagebox.showinfo("完成", f"已保存到 {OUTPUT_FILE}")
|
| 509 |
+
|
| 510 |
+
def upload_models_json(self):
|
| 511 |
+
if not self._ensure_token(): return
|
| 512 |
+
if not messagebox.askyesno("确认", f"上传 models.json 到 {SPACE_ID}?"): return
|
| 513 |
+
|
| 514 |
+
data_items = {k: v for k, v in self.items.items() if k not in self.stale_paths}
|
| 515 |
+
sorted_items = {k: data_items[k] for k in sorted(data_items.keys(), key=custom_sort_key)}
|
| 516 |
+
|
| 517 |
+
tmp = "models.upload.json"
|
| 518 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 519 |
+
json.dump({"items": sorted_items}, f, indent=2, ensure_ascii=False)
|
| 520 |
+
|
| 521 |
+
self.log_json(f"开始上传 JSON ({len(sorted_items)} 条)")
|
| 522 |
+
api = self.api
|
| 523 |
+
|
| 524 |
+
def work():
|
| 525 |
+
try:
|
| 526 |
+
api.upload_file(path_or_fileobj=tmp, path_in_repo="models.json", repo_id=SPACE_ID, repo_type="space")
|
| 527 |
+
self.log_json("models.json 上传成功")
|
| 528 |
+
self.after(0, lambda: messagebox.showinfo("完成", "JSON 上传成功"))
|
| 529 |
+
except Exception:
|
| 530 |
+
self.log_exc("JSON 上传失败")
|
| 531 |
+
self.after(0, lambda: messagebox.showerror("失败", "详见日志"))
|
| 532 |
+
finally:
|
| 533 |
+
try: os.remove(tmp)
|
| 534 |
+
except OSError: pass
|
| 535 |
+
|
| 536 |
+
threading.Thread(target=work, daemon=True).start()
|
| 537 |
+
|
| 538 |
+
# ==========================================================
|
| 539 |
+
# ================= 新增:site_config 管理 =================
|
| 540 |
+
# ==========================================================
|
| 541 |
+
def open_site_config_editor(self):
|
| 542 |
+
if not self._ensure_token(): return
|
| 543 |
+
|
| 544 |
+
self.log_json("正在从 Space 拉取 site_config.json...")
|
| 545 |
+
|
| 546 |
+
def fetch_worker():
|
| 547 |
+
try:
|
| 548 |
+
# 直接从 Space 文件仓库下载
|
| 549 |
+
file_path = self.api.hf_hub_download(
|
| 550 |
+
repo_id=SPACE_ID,
|
| 551 |
+
filename=SITE_CONFIG_FILE,
|
| 552 |
+
repo_type="space"
|
| 553 |
+
)
|
| 554 |
+
with open(file_path, "r", encoding="utf-8") as f:
|
| 555 |
+
config_data = json.load(f)
|
| 556 |
+
|
| 557 |
+
self.after(0, lambda: self._show_config_window(config_data))
|
| 558 |
+
except Exception as e:
|
| 559 |
+
self.log_json(f"拉取 site_config 失败: {e}")
|
| 560 |
+
# 如果远端没有,就初始化一个空的
|
| 561 |
+
self.after(0, lambda: self._show_config_window({"models": [], "categories": []}))
|
| 562 |
+
|
| 563 |
+
threading.Thread(target=fetch_worker, daemon=True).start()
|
| 564 |
+
|
| 565 |
+
def _show_config_window(self, config_data):
|
| 566 |
+
win = tk.Toplevel(self)
|
| 567 |
+
win.title("编辑 site_config.json (保存后自动上传)")
|
| 568 |
+
win.geometry("600x550")
|
| 569 |
+
win.transient(self); win.grab_set()
|
| 570 |
+
|
| 571 |
+
ttk.Label(win, text="💡 提示:修改顺序即可改变前端左侧导航栏的展示顺序。", foreground="gray").pack(pady=5)
|
| 572 |
+
|
| 573 |
+
# 分成两个框:Models 和 Categories
|
| 574 |
+
frame_models = ttk.LabelFrame(win, text="Base Models (按顺序)")
|
| 575 |
+
frame_models.pack(fill="both", expand=True, padx=10, pady=5)
|
| 576 |
+
|
| 577 |
+
text_models = tk.Text(frame_models, height=10, wrap="none")
|
| 578 |
+
text_models.pack(fill="both", expand=True, padx=5, pady=5)
|
| 579 |
+
# 将 JSON 格式化为易读的字符串填入
|
| 580 |
+
text_models.insert("1.0", json.dumps(config_data.get("models", []), indent=2, ensure_ascii=False))
|
| 581 |
+
|
| 582 |
+
frame_cats = ttk.LabelFrame(win, text="Categories (按顺序)")
|
| 583 |
+
frame_cats.pack(fill="both", expand=True, padx=10, pady=5)
|
| 584 |
+
|
| 585 |
+
text_cats = tk.Text(frame_cats, height=10, wrap="none")
|
| 586 |
+
text_cats.pack(fill="both", expand=True, padx=5, pady=5)
|
| 587 |
+
text_cats.insert("1.0", json.dumps(config_data.get("categories", []), indent=2, ensure_ascii=False))
|
| 588 |
+
|
| 589 |
+
def save_and_upload():
|
| 590 |
+
try:
|
| 591 |
+
# 解析用户修改后的文本
|
| 592 |
+
new_models = json.loads(text_models.get("1.0", "end").strip())
|
| 593 |
+
new_cats = json.loads(text_cats.get("1.0", "end").strip())
|
| 594 |
+
|
| 595 |
+
new_config = {
|
| 596 |
+
"models": new_models,
|
| 597 |
+
"categories": new_cats
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
# 保存到本地临时文件
|
| 601 |
+
with open(SITE_CONFIG_FILE, "w", encoding="utf-8") as f:
|
| 602 |
+
json.dump(new_config, f, indent=2, ensure_ascii=False)
|
| 603 |
+
|
| 604 |
+
self.log_json("正在上传 site_config.json 到 Space...")
|
| 605 |
+
self.api.upload_file(
|
| 606 |
+
path_or_fileobj=SITE_CONFIG_FILE,
|
| 607 |
+
path_in_repo=SITE_CONFIG_FILE,
|
| 608 |
+
repo_id=SPACE_ID,
|
| 609 |
+
repo_type="space"
|
| 610 |
+
)
|
| 611 |
+
self.log_json("🎉 site_config.json 上传成功!")
|
| 612 |
+
messagebox.showinfo("成功", "配置已更新并上传到 Space!")
|
| 613 |
+
win.destroy()
|
| 614 |
+
except json.JSONDecodeError as e:
|
| 615 |
+
messagebox.showerror("JSON 格式错误", f"请检查括号、逗号是否漏了。\n报错信息: {e}")
|
| 616 |
+
except Exception as e:
|
| 617 |
+
self.log_json(f"上传失败: {e}")
|
| 618 |
+
messagebox.showerror("错误", f"上传失败: {e}")
|
| 619 |
+
|
| 620 |
+
btn_frame = ttk.Frame(win)
|
| 621 |
+
btn_frame.pack(fill="x", pady=10, padx=10)
|
| 622 |
+
ttk.Button(btn_frame, text="保存并上传", command=save_and_upload).pack(side="right")
|
| 623 |
+
ttk.Button(btn_frame, text="取消", command=win.destroy).pack(side="right", padx=6)
|
| 624 |
+
|
| 625 |
+
def _on_drop_json(self, event):
|
| 626 |
+
raw = event.data.strip("{}")
|
| 627 |
+
p = Path(raw)
|
| 628 |
+
if p.is_dir():
|
| 629 |
+
self.notebook.select(self.tab_model)
|
| 630 |
+
self.local_folder_var.set(str(p))
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
# ==========================================================
|
| 634 |
+
# ==================== 区域 2: 模型上传 ====================
|
| 635 |
+
# ==========================================================
|
| 636 |
+
def _build_model_tab(self):
|
| 637 |
+
parent = self.tab_model
|
| 638 |
+
|
| 639 |
+
# 1. 输入区
|
| 640 |
+
frame = ttk.Frame(parent)
|
| 641 |
+
frame.pack(fill="x", padx=10, pady=10)
|
| 642 |
+
|
| 643 |
+
# 本地文件夹
|
| 644 |
+
ttk.Label(frame, text="本地文件夹:").grid(row=0, column=0, sticky="w", pady=4)
|
| 645 |
+
ttk.Entry(frame, textvariable=self.local_folder_var, width=70).grid(row=0, column=1, padx=6, sticky="we")
|
| 646 |
+
ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_model_local).grid(row=0, column=2)
|
| 647 |
+
|
| 648 |
+
# 远端目录 (Treeview 改造)
|
| 649 |
+
ttk.Label(frame, text="上传到 HF 目录:").grid(row=1, column=0, sticky="nw", pady=4)
|
| 650 |
+
|
| 651 |
+
tree_frame = ttk.Frame(frame)
|
| 652 |
+
tree_frame.grid(row=1, column=1, padx=6, sticky="nsew")
|
| 653 |
+
|
| 654 |
+
# 滚动条
|
| 655 |
+
vsb = ttk.Scrollbar(tree_frame, orient="vertical")
|
| 656 |
+
vsb.pack(side="right", fill="y")
|
| 657 |
+
|
| 658 |
+
# 树形列表
|
| 659 |
+
self.remote_tree = ttk.Treeview(tree_frame, height=6, show="tree",
|
| 660 |
+
yscrollcommand=vsb.set, selectmode="browse")
|
| 661 |
+
self.remote_tree.pack(side="left", fill="both", expand=True)
|
| 662 |
+
vsb.config(command=self.remote_tree.yview)
|
| 663 |
+
|
| 664 |
+
# 绑定树形点击和双击事件
|
| 665 |
+
self.remote_tree.bind("<<TreeviewSelect>>", self._on_remote_tree_select)
|
| 666 |
+
self.remote_tree.bind("<Double-1>", lambda e: self._start_model_upload())
|
| 667 |
+
|
| 668 |
+
# 刷新按钮
|
| 669 |
+
ttk.Button(frame, text="🔄 拉取 Models 目录", width=18, command=self._fetch_hf_dirs).grid(row=1, column=2, sticky="n")
|
| 670 |
+
|
| 671 |
+
# 按钮区
|
| 672 |
+
btn_row = ttk.Frame(frame)
|
| 673 |
+
btn_row.grid(row=2, column=0, columnspan=3, pady=15, sticky="w")
|
| 674 |
+
|
| 675 |
+
# 🌟 开始上传按钮 (绑定 self.btn_upload,方便后面禁用/恢复)
|
| 676 |
+
self.btn_upload = ttk.Button(btn_row, text="🢁 开始上传", command=self._start_model_upload)
|
| 677 |
+
self.btn_upload.pack(side="left")
|
| 678 |
+
|
| 679 |
+
# 状态文字
|
| 680 |
+
self.status_model_var = tk.StringVar(value="就绪")
|
| 681 |
+
ttk.Label(btn_row, textvariable=self.status_model_var).pack(side="left", padx=10)
|
| 682 |
+
|
| 683 |
+
# 🌟 进度条
|
| 684 |
+
self.progress_var = tk.DoubleVar()
|
| 685 |
+
self.progress_bar = ttk.Progressbar(btn_row, variable=self.progress_var, maximum=100, length=200, mode='determinate')
|
| 686 |
+
self.progress_bar.pack(side="left", padx=10)
|
| 687 |
+
|
| 688 |
+
frame.columnconfigure(1, weight=1)
|
| 689 |
+
# 让 Treeview 高度自适应
|
| 690 |
+
frame.rowconfigure(1, weight=1)
|
| 691 |
+
|
| 692 |
+
# 2. 日志区
|
| 693 |
+
log_frame = ttk.LabelFrame(parent, text="模型上传日志")
|
| 694 |
+
log_frame.pack(fill="both", expand=True, padx=10, pady=6)
|
| 695 |
+
self.log_model_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
|
| 696 |
+
self.log_model_text.pack(fill="both", expand=True)
|
| 697 |
+
|
| 698 |
+
|
| 699 |
+
def _browse_model_local(self):
|
| 700 |
+
# 🌟 强制从当前脚本所在目录开始(即 zz_huggingface 这一层)
|
| 701 |
+
if getattr(sys, 'frozen', False):
|
| 702 |
+
current_dir = os.path.dirname(sys.executable)
|
| 703 |
+
else:
|
| 704 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 705 |
+
|
| 706 |
+
folder = filedialog.askdirectory(title="选择包含 .safetensors 的文件夹", initialdir=current_dir)
|
| 707 |
+
if folder:
|
| 708 |
+
self.local_folder_var.set(folder)
|
| 709 |
+
|
| 710 |
+
def _fetch_hf_dirs(self):
|
| 711 |
+
if not self._ensure_token(): return
|
| 712 |
+
self.status_model_var.set("正在读取远端目录...")
|
| 713 |
+
self.log_model("正在读取远端仓库目录列表...")
|
| 714 |
+
|
| 715 |
+
def work():
|
| 716 |
+
try:
|
| 717 |
+
api = self.api
|
| 718 |
+
all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
|
| 719 |
+
dirs = set()
|
| 720 |
+
for f in all_files:
|
| 721 |
+
d = str(Path(f).parent)
|
| 722 |
+
if d != ".":
|
| 723 |
+
dirs.add(d)
|
| 724 |
+
|
| 725 |
+
sorted_dirs = sorted(list(dirs))
|
| 726 |
+
self.after(0, lambda: self._update_remote_tree(sorted_dirs))
|
| 727 |
+
except Exception:
|
| 728 |
+
self.log_exc("读取远端目录失败", is_model=True)
|
| 729 |
+
finally:
|
| 730 |
+
self.after(0, lambda: self.status_model_var.set("就绪"))
|
| 731 |
+
|
| 732 |
+
threading.Thread(target=work, daemon=True).start()
|
| 733 |
+
|
| 734 |
+
def _update_remote_tree(self, dirs):
|
| 735 |
+
# 清空旧的树
|
| 736 |
+
for item in self.remote_tree.get_children():
|
| 737 |
+
self.remote_tree.delete(item)
|
| 738 |
+
|
| 739 |
+
tree_nodes = {} # 用于记录路径对应的 treeview item id
|
| 740 |
+
|
| 741 |
+
for d in dirs:
|
| 742 |
+
# 把 Windows 反斜杠统一替换成正斜杠,方便拆分
|
| 743 |
+
safe_d = d.replace("\\", "/")
|
| 744 |
+
parts = safe_d.split("/")
|
| 745 |
+
|
| 746 |
+
parent_id = ""
|
| 747 |
+
current_path = ""
|
| 748 |
+
|
| 749 |
+
for i, part in enumerate(parts):
|
| 750 |
+
current_path = f"{current_path}/{part}" if current_path else part
|
| 751 |
+
|
| 752 |
+
# 如果这个节点还没创建,就创建它
|
| 753 |
+
if current_path not in tree_nodes:
|
| 754 |
+
# 判断是不是最后一级目录(如果是最后一级,加上图标和展开符)
|
| 755 |
+
is_leaf = (i == len(parts) - 1)
|
| 756 |
+
display_text = f"📁 {part}" if is_leaf else part
|
| 757 |
+
|
| 758 |
+
node_id = self.remote_tree.insert(
|
| 759 |
+
parent_id, "end",
|
| 760 |
+
text=display_text,
|
| 761 |
+
values=(current_path,) # 把真实路径藏在 values 里
|
| 762 |
+
)
|
| 763 |
+
tree_nodes[current_path] = node_id
|
| 764 |
+
|
| 765 |
+
# 下一级的父节点变成了当前节点
|
| 766 |
+
parent_id = tree_nodes[current_path]
|
| 767 |
+
|
| 768 |
+
# 默认展开所有根节点
|
| 769 |
+
for root_node in self.remote_tree.get_children(""):
|
| 770 |
+
self.remote_tree.item(root_node, open=True)
|
| 771 |
+
|
| 772 |
+
self.log_model(f"已加载 {len(dirs)} 个远端目录结构")
|
| 773 |
+
|
| 774 |
+
def _on_remote_tree_select(self, event):
|
| 775 |
+
# 当用户在树上点击时,提取路径存到 self.remote_dir_var
|
| 776 |
+
selection = self.remote_tree.selection()
|
| 777 |
+
if selection:
|
| 778 |
+
item_id = selection[0]
|
| 779 |
+
# 从 values 里拿我们存的真实路径
|
| 780 |
+
values = self.remote_tree.item(item_id, "values")
|
| 781 |
+
if values:
|
| 782 |
+
self.remote_dir_var.set(values[0])
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def _start_model_upload(self):
|
| 786 |
+
local_dir = self.local_folder_var.get().strip()
|
| 787 |
+
# 如果用户没点选树,默认上传到根目录;如果点了,就是选中的路径
|
| 788 |
+
remote_dir = self.remote_dir_var.get().strip().strip("/")
|
| 789 |
+
|
| 790 |
+
if not local_dir or not os.path.exists(local_dir):
|
| 791 |
+
messagebox.showwarning("错误", "请选择有效的本地文件夹。")
|
| 792 |
+
return
|
| 793 |
+
if not remote_dir:
|
| 794 |
+
if not messagebox.askyesno("确认", "远端目录为空,将上传到仓库根目录,继续?"):
|
| 795 |
+
return
|
| 796 |
+
if not self._ensure_token(): return
|
| 797 |
+
|
| 798 |
+
self.status_model_var.set("正在扫描并比对文件...")
|
| 799 |
+
self.log_model(f"开始扫描: {local_dir} -> {MODEL_REPO_ID}/{remote_dir}")
|
| 800 |
+
|
| 801 |
+
def scan_worker():
|
| 802 |
+
try:
|
| 803 |
+
api = self.api
|
| 804 |
+
|
| 805 |
+
# 1. 清洗本地图片 (png/webp -> jpg)
|
| 806 |
+
local_files = [f for f in Path(local_dir).iterdir() if f.is_file()]
|
| 807 |
+
for f in local_files:
|
| 808 |
+
if f.suffix.lower() in [".png", ".webp"]:
|
| 809 |
+
jpg_path = f.with_suffix(".jpg")
|
| 810 |
+
try:
|
| 811 |
+
self.log_model(f"清洗图片并转码: {f.name} -> {jpg_path.name}")
|
| 812 |
+
with Image.open(f) as img:
|
| 813 |
+
if img.mode in ("RGBA", "P"):
|
| 814 |
+
img = img.convert("RGB")
|
| 815 |
+
img.save(jpg_path, format="JPEG", quality=95)
|
| 816 |
+
os.remove(f) # 清理原图
|
| 817 |
+
except Exception as e:
|
| 818 |
+
self.log_model(f"转码失败 {f.name}: {e}")
|
| 819 |
+
|
| 820 |
+
# 2. 重新读取清洗后的文件列表
|
| 821 |
+
local_files = [f for f in Path(local_dir).iterdir() if f.is_file()]
|
| 822 |
+
if not local_files:
|
| 823 |
+
self.after(0, lambda: messagebox.showwarning("提示", "本地文件夹是空的。"))
|
| 824 |
+
return
|
| 825 |
+
|
| 826 |
+
# 3. 拉取远端文件用于比对
|
| 827 |
+
remote_all = set(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
|
| 828 |
+
|
| 829 |
+
to_upload = []
|
| 830 |
+
for f in local_files:
|
| 831 |
+
if f.suffix.lower() not in [".safetensors"] + list(IMG_EXTS):
|
| 832 |
+
continue
|
| 833 |
+
|
| 834 |
+
remote_path = f"{remote_dir}/{f.name}" if remote_dir else f.name
|
| 835 |
+
if remote_path not in remote_all:
|
| 836 |
+
to_upload.append((f, remote_path))
|
| 837 |
+
|
| 838 |
+
if not to_upload:
|
| 839 |
+
self.after(0, lambda: messagebox.showinfo("完成", "远端已存在所有文件,无需上传。"))
|
| 840 |
+
self.after(0, lambda: self.status_model_var.set("无需上传"))
|
| 841 |
+
self.log_model("扫描完毕,无新增文件。")
|
| 842 |
+
return
|
| 843 |
+
|
| 844 |
+
msg = f"本地清理完毕。找到 {len(to_upload)} 个新文件待上传。\n\n目标目录: {remote_dir}\n\n继续?"
|
| 845 |
+
self.after(0, lambda: self._do_model_upload_confirm(msg, to_upload, api))
|
| 846 |
+
|
| 847 |
+
except Exception:
|
| 848 |
+
self.log_exc("扫描失败", is_model=True)
|
| 849 |
+
self.after(0, lambda: self.status_model_var.set("扫描失败"))
|
| 850 |
+
|
| 851 |
+
threading.Thread(target=scan_worker, daemon=True).start()
|
| 852 |
+
|
| 853 |
+
def _do_model_upload_confirm(self, msg, to_upload, api):
|
| 854 |
+
if not messagebox.askyesno("确认上传", msg):
|
| 855 |
+
self.status_model_var.set("已取消")
|
| 856 |
+
return
|
| 857 |
+
|
| 858 |
+
def upload_worker():
|
| 859 |
+
try:
|
| 860 |
+
from huggingface_hub import CommitOperationAdd
|
| 861 |
+
|
| 862 |
+
# 🌟 开始上传,禁用按钮,重置进度条
|
| 863 |
+
self.after(0, lambda: self.btn_upload.configure(state="disabled"))
|
| 864 |
+
self.after(0, lambda: self.progress_var.set(0))
|
| 865 |
+
self.after(0, lambda: self.status_model_var.set("正在打包并上传..."))
|
| 866 |
+
|
| 867 |
+
# 1. 准备操作列表
|
| 868 |
+
operations = []
|
| 869 |
+
for local_file, remote_path in to_upload:
|
| 870 |
+
# 极度关键:防止 Windows 反斜杠导致远端多建文件夹
|
| 871 |
+
safe_remote_path = remote_path.replace("\\", "/")
|
| 872 |
+
operations.append(
|
| 873 |
+
CommitOperationAdd(
|
| 874 |
+
path_or_fileobj=str(local_file),
|
| 875 |
+
path_in_repo=safe_remote_path
|
| 876 |
+
)
|
| 877 |
+
)
|
| 878 |
+
|
| 879 |
+
# 2. 准备 commit message
|
| 880 |
+
remote_dir = self.remote_dir_var.get().strip().replace("\\", "/").strip("/")
|
| 881 |
+
commit_msg = f"Upload files to {remote_dir}" if remote_dir else "Upload files to root"
|
| 882 |
+
|
| 883 |
+
self.log_model(f"开始打包 {len(operations)} 个文件,一次性 Commit...")
|
| 884 |
+
self.log_model(f"Commit message: {commit_msg}")
|
| 885 |
+
|
| 886 |
+
# 🌟 3. 假进度条控制变量
|
| 887 |
+
is_uploading = [True]
|
| 888 |
+
def update_progress():
|
| 889 |
+
if not is_uploading[0]: return # 如果上传结束,停止动画
|
| 890 |
+
current = self.progress_var.get()
|
| 891 |
+
# 慢慢爬升到 95%,最后留 5% 给真正完成时冲刺
|
| 892 |
+
if current < 95:
|
| 893 |
+
self.progress_var.set(current + 2)
|
| 894 |
+
self.after(300, update_progress)
|
| 895 |
+
|
| 896 |
+
self.after(100, update_progress) # 启动动画
|
| 897 |
+
|
| 898 |
+
# 4. 一次性提交多个文件 (同步阻塞操作)
|
| 899 |
+
api.create_commit(
|
| 900 |
+
repo_id=MODEL_REPO_ID,
|
| 901 |
+
repo_type="model",
|
| 902 |
+
operations=operations,
|
| 903 |
+
commit_message=commit_msg
|
| 904 |
+
)
|
| 905 |
+
|
| 906 |
+
# 🌟 5. 上传成功:停止动画,进度条瞬间满格
|
| 907 |
+
is_uploading[0] = False
|
| 908 |
+
self.after(0, lambda: self.progress_var.set(100))
|
| 909 |
+
self.after(0, lambda: self.status_model_var.set("上传完成 ✅"))
|
| 910 |
+
self.after(0, lambda: self.btn_upload.configure(state="normal"))
|
| 911 |
+
|
| 912 |
+
self.log_model("打包上传成功!")
|
| 913 |
+
self.after(0, lambda: messagebox.showinfo("完成", "上传成功!"))
|
| 914 |
+
self.after(0, self.load_remote) # 自动刷新 JSON
|
| 915 |
+
|
| 916 |
+
except Exception as e:
|
| 917 |
+
# 🌟 失败时:停止动画,重置进度条,恢复按钮
|
| 918 |
+
is_uploading[0] = False
|
| 919 |
+
self.after(0, lambda: self.progress_var.set(0))
|
| 920 |
+
self.after(0, lambda: self.btn_upload.configure(state="normal"))
|
| 921 |
+
self.after(0, lambda: self.status_model_var.set("上传失败 ❌"))
|
| 922 |
+
|
| 923 |
+
self.log_exc("打包上传失败", is_model=True)
|
| 924 |
+
self.after(0, lambda: messagebox.showerror("失败", "详见日志"))
|
| 925 |
+
|
| 926 |
+
threading.Thread(target=upload_worker, daemon=True).start()
|
| 927 |
+
|
| 928 |
+
# ==========================================================
|
| 929 |
+
# ==================== 区域 3: Dataset 上传 =================
|
| 930 |
+
# ==========================================================
|
| 931 |
+
def _build_dataset_tab(self):
|
| 932 |
+
parent = self.tab_dataset
|
| 933 |
+
|
| 934 |
+
frame = ttk.Frame(parent)
|
| 935 |
+
frame.pack(fill="x", padx=10, pady=10)
|
| 936 |
+
|
| 937 |
+
# 1. 本地母文件夹
|
| 938 |
+
ttk.Label(frame, text="本地母文件夹:").grid(row=0, column=0, sticky="w", pady=4)
|
| 939 |
+
self.ds_local_folder_var = tk.StringVar()
|
| 940 |
+
ttk.Entry(frame, textvariable=self.ds_local_folder_var, width=70).grid(row=0, column=1, columnspan=2, padx=6, sticky="we")
|
| 941 |
+
ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_ds_local).grid(row=0, column=3)
|
| 942 |
+
|
| 943 |
+
# 2. 地区/分类前缀 (动态读取改造)
|
| 944 |
+
ttk.Label(frame, text="Region 前缀:").grid(row=1, column=0, sticky="w", pady=8)
|
| 945 |
+
|
| 946 |
+
prefix_frame = ttk.Frame(frame)
|
| 947 |
+
prefix_frame.grid(row=1, column=1, columnspan=3, sticky="w", padx=6, pady=8)
|
| 948 |
+
|
| 949 |
+
# 核心变量初始化
|
| 950 |
+
self.ds_region_var = tk.StringVar(value="KR")
|
| 951 |
+
self.ds_custom_region_var = tk.StringVar() # 必须保留这个变量用于输入框
|
| 952 |
+
|
| 953 |
+
# 按钮:刷新 Dataset 目录
|
| 954 |
+
ttk.Button(prefix_frame, text="🔄 拉取 datasets 目录", command=self._fetch_dataset_dirs).pack(side="left", padx=(0, 10))
|
| 955 |
+
|
| 956 |
+
# 用子 Frame 专门放单选框,刷新时只清空这里的控件
|
| 957 |
+
self.ds_radio_container = ttk.Frame(prefix_frame)
|
| 958 |
+
self.ds_radio_container.pack(side="left")
|
| 959 |
+
|
| 960 |
+
# 初始默认放几个,防止还没拉取时界面太难看
|
| 961 |
+
self._render_dataset_radios(["CN", "KR", "JP", "Fictional", "WE"])
|
| 962 |
+
|
| 963 |
+
# 3. 按钮区
|
| 964 |
+
btn_row = ttk.Frame(frame)
|
| 965 |
+
btn_row.grid(row=2, column=0, columnspan=4, pady=15, sticky="w")
|
| 966 |
+
self.btn_ds_upload = ttk.Button(btn_row, text="🢁 开始 5合1 处理并上传", command=self._start_dataset_pipeline)
|
| 967 |
+
self.btn_ds_upload.pack(side="left")
|
| 968 |
+
|
| 969 |
+
self.status_ds_var = tk.StringVar(value="就绪")
|
| 970 |
+
ttk.Label(btn_row, textvariable=self.status_ds_var).pack(side="left", padx=10)
|
| 971 |
+
|
| 972 |
+
frame.columnconfigure(1, weight=1)
|
| 973 |
+
frame.columnconfigure(2, weight=1)
|
| 974 |
+
|
| 975 |
+
# 4. 日志区
|
| 976 |
+
log_frame = ttk.LabelFrame(parent, text="Dataset 自动化流水线日志")
|
| 977 |
+
log_frame.pack(fill="both", expand=True, padx=10, pady=6)
|
| 978 |
+
self.log_dataset_text = ScrolledText(log_frame, height=15, state="disabled", wrap="word")
|
| 979 |
+
self.log_dataset_text.pack(fill="both", expand=True)
|
| 980 |
+
|
| 981 |
+
def _fetch_dataset_dirs(self):
|
| 982 |
+
if not self._ensure_token(): return
|
| 983 |
+
self.log_dataset("正在读取 Dataset 仓库根目录...")
|
| 984 |
+
|
| 985 |
+
def work():
|
| 986 |
+
try:
|
| 987 |
+
files = self.api.list_repo_files(repo_id=DATASET_REPO_ID, repo_type="dataset")
|
| 988 |
+
dirs = set()
|
| 989 |
+
for f in files:
|
| 990 |
+
parts = f.split("/")
|
| 991 |
+
# 过滤掉 tool 和根目录文件,只保留文件夹
|
| 992 |
+
if len(parts) > 1 and parts[0] not in ["tool", ".git"]:
|
| 993 |
+
dirs.add(parts[0])
|
| 994 |
+
|
| 995 |
+
sorted_dirs = sorted(list(dirs))
|
| 996 |
+
|
| 997 |
+
# 🌟 新增:在主线程更新 UI 和打印日志
|
| 998 |
+
def update_ui():
|
| 999 |
+
self._render_dataset_radios(sorted_dirs)
|
| 1000 |
+
self.log_dataset(f"✅ 远端目录刷新成功!共发现 {len(sorted_dirs)} 个分类: {', '.join(sorted_dirs)}")
|
| 1001 |
+
|
| 1002 |
+
self.after(0, update_ui)
|
| 1003 |
+
|
| 1004 |
+
except Exception as e:
|
| 1005 |
+
self.log_exc("读取 Dataset 目录失败", is_model=True)
|
| 1006 |
+
# 🌟 新增:报错时更新状态栏
|
| 1007 |
+
self.after(0, lambda: self.status_ds_var.set("读取目录失败"))
|
| 1008 |
+
|
| 1009 |
+
threading.Thread(target=work, daemon=True).start()
|
| 1010 |
+
|
| 1011 |
+
def _render_dataset_radios(self, dirs):
|
| 1012 |
+
# 清空旧的单选框和输入框
|
| 1013 |
+
for widget in self.ds_radio_container.winfo_children():
|
| 1014 |
+
widget.destroy()
|
| 1015 |
+
|
| 1016 |
+
# 保持原本选中的值,如果不存在了,默认选第一个
|
| 1017 |
+
current_val = self.ds_region_var.get()
|
| 1018 |
+
if current_val not in dirs and current_val != "Custom" and dirs:
|
| 1019 |
+
self.ds_region_var.set(dirs[0])
|
| 1020 |
+
|
| 1021 |
+
# 动态生成分类单选框
|
| 1022 |
+
for region in dirs:
|
| 1023 |
+
ttk.Radiobutton(self.ds_radio_container, text=region, variable=self.ds_region_var, value=region).pack(side="left", padx=(0, 10))
|
| 1024 |
+
|
| 1025 |
+
# 加上自定义部分 (注意:父级是 self.ds_radio_container)
|
| 1026 |
+
ttk.Radiobutton(self.ds_radio_container, text="自定义:", variable=self.ds_region_var, value="Custom").pack(side="left", padx=(10, 2))
|
| 1027 |
+
self.ds_custom_entry = ttk.Entry(self.ds_radio_container, textvariable=self.ds_custom_region_var, width=15, state="disabled")
|
| 1028 |
+
self.ds_custom_entry.pack(side="left")
|
| 1029 |
+
|
| 1030 |
+
# 🌟 重新绑定输入框激活逻辑 (因为输入框是刚刚重新创建的)
|
| 1031 |
+
def on_region_change(*args):
|
| 1032 |
+
if self.ds_region_var.get() == "Custom":
|
| 1033 |
+
self.ds_custom_entry.configure(state="normal")
|
| 1034 |
+
else:
|
| 1035 |
+
self.ds_custom_entry.configure(state="disabled")
|
| 1036 |
+
|
| 1037 |
+
# 清理之前的 trace,防止多次刷新后绑定重复导致卡顿或报错
|
| 1038 |
+
if hasattr(self, '_region_trace_id'):
|
| 1039 |
+
try:
|
| 1040 |
+
self.ds_region_var.trace_remove("write", self._region_trace_id)
|
| 1041 |
+
except Exception:
|
| 1042 |
+
pass
|
| 1043 |
+
|
| 1044 |
+
self._region_trace_id = self.ds_region_var.trace_add("write", on_region_change)
|
| 1045 |
+
|
| 1046 |
+
|
| 1047 |
+
def log_dataset(self, msg):
|
| 1048 |
+
self._log_to_tab(msg, getattr(self, 'log_dataset_text', None))
|
| 1049 |
+
|
| 1050 |
+
def _browse_ds_local(self):
|
| 1051 |
+
# 🌟 初始目录设置为当前脚本所在目录,方便用户找
|
| 1052 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 1053 |
+
folder = filedialog.askdirectory(title="选择包含子文件夹的数据集母目录", initialdir=current_dir)
|
| 1054 |
+
if folder:
|
| 1055 |
+
self.ds_local_folder_var.set(folder)
|
| 1056 |
+
|
| 1057 |
+
def _start_dataset_pipeline(self):
|
| 1058 |
+
local_dir = self.ds_local_folder_var.get().strip()
|
| 1059 |
+
|
| 1060 |
+
# ==== 修改这里:获取前缀逻辑 ====
|
| 1061 |
+
selected_region = self.ds_region_var.get()
|
| 1062 |
+
if selected_region == "Custom":
|
| 1063 |
+
region_prefix = self.ds_custom_region_var.get().strip().replace(" ", "_").replace("-", "_")
|
| 1064 |
+
if not region_prefix:
|
| 1065 |
+
messagebox.showwarning("错误", "您选择了自定义前缀,但输入框为空。")
|
| 1066 |
+
return
|
| 1067 |
+
else:
|
| 1068 |
+
region_prefix = selected_region
|
| 1069 |
+
# ===============================
|
| 1070 |
+
|
| 1071 |
+
if not local_dir or not os.path.exists(local_dir):
|
| 1072 |
+
messagebox.showwarning("错误", "请选择有效的本地母文件夹。")
|
| 1073 |
+
return
|
| 1074 |
+
if not self._ensure_token(): return
|
| 1075 |
+
|
| 1076 |
+
if not messagebox.askyesno("确认", f"即将对以下目录执行 5合1 流水线:\n{local_dir}\n前缀:{region_prefix}\n\n继续?"):
|
| 1077 |
+
return
|
| 1078 |
+
|
| 1079 |
+
self.btn_ds_upload.configure(state="disabled")
|
| 1080 |
+
self.status_ds_var.set("处理中...")
|
| 1081 |
+
self.log_dataset(f"================ 开始 5合1 流水线 ================")
|
| 1082 |
+
self.log_dataset(f"目标目录: {local_dir} | 前缀: {region_prefix}")
|
| 1083 |
+
|
| 1084 |
+
def worker():
|
| 1085 |
+
api = self.api
|
| 1086 |
+
try:
|
| 1087 |
+
# 1. 清理 txt 换行符
|
| 1088 |
+
self._clean_txt_newlines(local_dir)
|
| 1089 |
+
|
| 1090 |
+
# 2. 清理 Mask 元数据
|
| 1091 |
+
self._remove_mask_metadata(local_dir)
|
| 1092 |
+
|
| 1093 |
+
# 3. 转换 PNG -> JPG
|
| 1094 |
+
self._convert_png_to_jpg(local_dir)
|
| 1095 |
+
|
| 1096 |
+
# 4. 打包 Parquet 并更新 YAML
|
| 1097 |
+
temp_readme_path, valid_folders = self._generate_parquet_and_update_yaml(api, local_dir, region_prefix)
|
| 1098 |
+
|
| 1099 |
+
# 5. 上传
|
| 1100 |
+
self._upload_files_and_readme(api, local_dir, region_prefix, temp_readme_path, valid_folders)
|
| 1101 |
+
|
| 1102 |
+
self.log_dataset("🎉 5合1 全流程处理完毕!")
|
| 1103 |
+
self.after(0, lambda: messagebox.showinfo("完成", "Dataset 流水线执行成功!"))
|
| 1104 |
+
self.after(0, lambda: self.status_ds_var.set("上传完成"))
|
| 1105 |
+
|
| 1106 |
+
# 刷新 JSON 的拉取(可选,如果需要马上更新界面的话)
|
| 1107 |
+
self.after(0, self.load_remote)
|
| 1108 |
+
|
| 1109 |
+
except Exception as e:
|
| 1110 |
+
self.log_exc("Dataset 流水线异常", is_model=True)
|
| 1111 |
+
self.after(0, lambda: self.status_ds_var.set("执行失败"))
|
| 1112 |
+
finally:
|
| 1113 |
+
self.after(0, lambda: self.btn_ds_upload.configure(state="normal"))
|
| 1114 |
+
|
| 1115 |
+
threading.Thread(target=worker, daemon=True).start()
|
| 1116 |
+
|
| 1117 |
+
# ---------------- 原有脚本的函数改造 ----------------
|
| 1118 |
+
|
| 1119 |
+
def _clean_txt_newlines(self, current_dir):
|
| 1120 |
+
import glob
|
| 1121 |
+
self.log_dataset("🧹 [任务 1/5] 正在清理所有 TXT 文件中的换行符...")
|
| 1122 |
+
txt_files = glob.glob(os.path.join(current_dir, "**", "*.txt"), recursive=True)
|
| 1123 |
+
if not txt_files:
|
| 1124 |
+
self.log_dataset(" ✅ 没有找到需要处理的 TXT 文件。")
|
| 1125 |
+
return
|
| 1126 |
+
|
| 1127 |
+
cleaned_count = 0
|
| 1128 |
+
for txt_path in txt_files:
|
| 1129 |
+
if os.path.dirname(txt_path) == current_dir:
|
| 1130 |
+
continue
|
| 1131 |
+
try:
|
| 1132 |
+
with open(txt_path, 'r', encoding='utf-8') as f:
|
| 1133 |
+
content = ''.join(line.strip() for line in f)
|
| 1134 |
+
with open(txt_path, 'w', encoding='utf-8') as f:
|
| 1135 |
+
f.write(content)
|
| 1136 |
+
cleaned_count += 1
|
| 1137 |
+
except Exception as e:
|
| 1138 |
+
self.log_dataset(f" ⚠️ 处理 {os.path.basename(txt_path)} 失败: {e}")
|
| 1139 |
+
self.log_dataset(f" 🎉 TXT 清理完成!共处理了 {cleaned_count} 个文件。")
|
| 1140 |
+
|
| 1141 |
+
def _remove_mask_metadata(self, current_dir):
|
| 1142 |
+
import glob
|
| 1143 |
+
self.log_dataset("🛡️ [任务 2/5] 正在剥离 Mask 蒙版图的元数据...")
|
| 1144 |
+
png_files = glob.glob(os.path.join(current_dir, "**", "*.png"), recursive=True)
|
| 1145 |
+
mask_files = [f for f in png_files if "masklabel" in os.path.basename(f).lower()]
|
| 1146 |
+
|
| 1147 |
+
if not mask_files:
|
| 1148 |
+
self.log_dataset(" ✅ 没有找到 masklabel.png 文件,跳过此步骤。")
|
| 1149 |
+
return
|
| 1150 |
+
|
| 1151 |
+
success_count = 0
|
| 1152 |
+
for file_path in mask_files:
|
| 1153 |
+
try:
|
| 1154 |
+
with Image.open(file_path) as img:
|
| 1155 |
+
data = list(img.getdata())
|
| 1156 |
+
clean_img = Image.new(img.mode, img.size)
|
| 1157 |
+
clean_img.putdata(data)
|
| 1158 |
+
clean_img.save(file_path, "PNG")
|
| 1159 |
+
success_count += 1
|
| 1160 |
+
except Exception as e:
|
| 1161 |
+
self.log_dataset(f" ⚠️ 处理失败 [{os.path.basename(file_path)}]: {e}")
|
| 1162 |
+
self.log_dataset(f" 🎉 元数据清理完成!共处理了 {success_count} 张 Mask 图。")
|
| 1163 |
+
|
| 1164 |
+
def _convert_png_to_jpg(self, current_dir):
|
| 1165 |
+
import glob
|
| 1166 |
+
self.log_dataset("🎬 [任务 3/5] 正在批量转换常规 PNG 为 JPG...")
|
| 1167 |
+
png_files = glob.glob(os.path.join(current_dir, "**", "*.png"), recursive=True)
|
| 1168 |
+
if not png_files:
|
| 1169 |
+
self.log_dataset(" ✅ 没有找到需要转换的 PNG 文件。")
|
| 1170 |
+
return
|
| 1171 |
+
|
| 1172 |
+
converted_count = 0
|
| 1173 |
+
for png_path in png_files:
|
| 1174 |
+
if "masklabel" in png_path.lower():
|
| 1175 |
+
continue
|
| 1176 |
+
jpg_path = os.path.splitext(png_path)[0] + ".jpg"
|
| 1177 |
+
try:
|
| 1178 |
+
subprocess.run(
|
| 1179 |
+
["ffmpeg", "-y", "-i", png_path, "-q:v", "2", jpg_path],
|
| 1180 |
+
stdout=subprocess.DEVNULL,
|
| 1181 |
+
stderr=subprocess.DEVNULL,
|
| 1182 |
+
check=True
|
| 1183 |
+
)
|
| 1184 |
+
os.remove(png_path)
|
| 1185 |
+
converted_count += 1
|
| 1186 |
+
self.log_dataset(f" 🔄 转换: {os.path.basename(png_path)} -> {os.path.basename(jpg_path)}")
|
| 1187 |
+
except subprocess.CalledProcessError:
|
| 1188 |
+
self.log_dataset(f" ⚠️ 转换失败: {png_path}")
|
| 1189 |
+
except FileNotFoundError:
|
| 1190 |
+
self.log_dataset(" ❌ 错误: 找不到 ffmpeg!请确保已安装并配置了环境变量。")
|
| 1191 |
+
return
|
| 1192 |
+
self.log_dataset(f" 🎉 转换完成!共转换 {converted_count} 张图片。")
|
| 1193 |
+
|
| 1194 |
+
def _generate_parquet_and_update_yaml(self, api, current_dir, region_prefix):
|
| 1195 |
+
import glob, tempfile, pandas as pd
|
| 1196 |
+
from datasets import Dataset, Features, Image as HFImage, Value
|
| 1197 |
+
from huggingface_hub import DatasetCard
|
| 1198 |
+
from PIL import Image as PILImage
|
| 1199 |
+
|
| 1200 |
+
self.log_dataset("📦 [任务 4/5] 正在本地打包 Parquet 并更新 YAML 配置...")
|
| 1201 |
+
local_folder_name = os.path.basename(current_dir).replace(" ", "_").replace("-", "_")
|
| 1202 |
+
|
| 1203 |
+
self.log_dataset(f" ☁️ 正在读取云端 {DATASET_REPO_ID} 的现有配置...")
|
| 1204 |
+
try:
|
| 1205 |
+
# 注意这里用 DATASET_REPO_ID 替换了原脚本的 REPO_ID
|
| 1206 |
+
remote_readme_path = api.hf_hub_download(repo_id=DATASET_REPO_ID, filename="README.md", repo_type="dataset")
|
| 1207 |
+
with open(remote_readme_path, "r", encoding="utf-8") as f:
|
| 1208 |
+
content = f.read()
|
| 1209 |
+
card = DatasetCard(content)
|
| 1210 |
+
self.log_dataset(" ✅ 成功读取云端 README。")
|
| 1211 |
+
except Exception as e:
|
| 1212 |
+
self.log_dataset(f" ⚠️ 找不到云端 README,将新建: {e}")
|
| 1213 |
+
card = DatasetCard("")
|
| 1214 |
+
|
| 1215 |
+
yaml_data = card.data if card.data else {}
|
| 1216 |
+
if "configs" not in yaml_data: yaml_data["configs"] = []
|
| 1217 |
+
|
| 1218 |
+
yaml_data["dataset_info"] = {
|
| 1219 |
+
"features": [
|
| 1220 |
+
{"name": "image", "dtype": "image"},
|
| 1221 |
+
{"name": "file_name", "dtype": "string"},
|
| 1222 |
+
{"name": "text", "dtype": "string"}
|
| 1223 |
+
]
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
existing_config_names = [c.get("config_name") for c in yaml_data["configs"]]
|
| 1227 |
+
new_configs_added = False
|
| 1228 |
+
valid_folders = []
|
| 1229 |
+
|
| 1230 |
+
for sub_folder_name in os.listdir(current_dir):
|
| 1231 |
+
sub_folder_path = os.path.join(current_dir, sub_folder_name)
|
| 1232 |
+
if os.path.isdir(sub_folder_path) and not sub_folder_name.startswith('.'):
|
| 1233 |
+
jpg_files = glob.glob(os.path.join(sub_folder_path, "*.jpg")) + glob.glob(os.path.join(sub_folder_path, "*.jpeg"))
|
| 1234 |
+
if not jpg_files:
|
| 1235 |
+
self.log_dataset(f" ⚠️ 跳过子文件夹 [{sub_folder_name}]: 未找到任何 .jpg 图片。")
|
| 1236 |
+
continue
|
| 1237 |
+
|
| 1238 |
+
self.log_dataset(f" 📦 正在打包: {sub_folder_name}")
|
| 1239 |
+
data_rows = []
|
| 1240 |
+
|
| 1241 |
+
for jpg_path in jpg_files:
|
| 1242 |
+
file_name = os.path.basename(jpg_path)
|
| 1243 |
+
base_name = os.path.splitext(file_name)[0]
|
| 1244 |
+
txt_path = os.path.join(sub_folder_path, f"{base_name}.txt")
|
| 1245 |
+
|
| 1246 |
+
text_content = ""
|
| 1247 |
+
if os.path.exists(txt_path):
|
| 1248 |
+
with open(txt_path, "r", encoding="utf-8") as f:
|
| 1249 |
+
text_content = f.read().strip()
|
| 1250 |
+
|
| 1251 |
+
try:
|
| 1252 |
+
with open(jpg_path, "rb") as f:
|
| 1253 |
+
image_bytes = {"bytes": f.read(), "path": None}
|
| 1254 |
+
except Exception as e:
|
| 1255 |
+
self.log_dataset(f" ⚠️ 跳过损坏图片 {file_name}: {e}")
|
| 1256 |
+
continue
|
| 1257 |
+
|
| 1258 |
+
data_rows.append({
|
| 1259 |
+
"image": image_bytes,
|
| 1260 |
+
"file_name": file_name,
|
| 1261 |
+
"text": text_content
|
| 1262 |
+
})
|
| 1263 |
+
|
| 1264 |
+
if data_rows:
|
| 1265 |
+
df = pd.DataFrame(data_rows)
|
| 1266 |
+
features = Features({
|
| 1267 |
+
"image": HFImage(),
|
| 1268 |
+
"file_name": Value("string"),
|
| 1269 |
+
"text": Value("string")
|
| 1270 |
+
})
|
| 1271 |
+
ds = Dataset.from_pandas(df, features=features)
|
| 1272 |
+
ds.to_parquet(os.path.join(sub_folder_path, "train.parquet"))
|
| 1273 |
+
valid_folders.append(sub_folder_name)
|
| 1274 |
+
self.log_dataset(f" ✅ 生成 train.parquet ({len(data_rows)} 条数据)")
|
| 1275 |
+
|
| 1276 |
+
config_name = f"{local_folder_name}_{sub_folder_name}".lower()
|
| 1277 |
+
path_str = f"{region_prefix}/{local_folder_name}/{sub_folder_name}/train.parquet"
|
| 1278 |
+
|
| 1279 |
+
if config_name not in existing_config_names:
|
| 1280 |
+
yaml_data["configs"].append({
|
| 1281 |
+
"config_name": config_name,
|
| 1282 |
+
"data_files": [{"split": "train", "path": path_str}]
|
| 1283 |
+
})
|
| 1284 |
+
new_configs_added = True
|
| 1285 |
+
self.log_dataset(f" ➕ 已添加配置: {config_name}")
|
| 1286 |
+
|
| 1287 |
+
def get_priority(config):
|
| 1288 |
+
data_files = config.get("data_files", [])
|
| 1289 |
+
path = data_files[0].get("path", "") if data_files else ""
|
| 1290 |
+
priority_dict = {"CN": 1, "KR": 2, "JP": 3, "Fictional": 4, "WE": 5}
|
| 1291 |
+
path_prefix = path.split('/')[0] if path else ""
|
| 1292 |
+
return priority_dict.get(path_prefix, 99)
|
| 1293 |
+
|
| 1294 |
+
yaml_data["configs"] = sorted(yaml_data["configs"], key=lambda c: (get_priority(c), c.get("config_name", "")))
|
| 1295 |
+
self.log_dataset(" 🔄 已根据 [CN > KR > JP > Fictional > WE] 的顺序重排 YAML。")
|
| 1296 |
+
|
| 1297 |
+
if new_configs_added:
|
| 1298 |
+
card.data = yaml_data
|
| 1299 |
+
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.md', delete=False) as temp_readme:
|
| 1300 |
+
temp_readme.write(str(card))
|
| 1301 |
+
return temp_readme.name, valid_folders
|
| 1302 |
+
return None, valid_folders
|
| 1303 |
+
|
| 1304 |
+
def _upload_files_and_readme(self, api, current_dir, region_prefix, temp_readme_path, valid_folders):
|
| 1305 |
+
self.log_dataset("🚀 [任务 5/5] 正在上传文件到 Hugging Face...")
|
| 1306 |
+
local_folder_name = os.path.basename(current_dir).replace(" ", "_").replace("-", "_")
|
| 1307 |
+
|
| 1308 |
+
for sub_folder_name in valid_folders:
|
| 1309 |
+
sub_folder_path = os.path.join(current_dir, sub_folder_name)
|
| 1310 |
+
path_in_repo = f"{region_prefix}/{local_folder_name}/{sub_folder_name}"
|
| 1311 |
+
|
| 1312 |
+
self.log_dataset(f" 📦 开始上传: {sub_folder_name} -> {path_in_repo}")
|
| 1313 |
+
try:
|
| 1314 |
+
api.upload_folder(
|
| 1315 |
+
folder_path=sub_folder_path,
|
| 1316 |
+
repo_id=DATASET_REPO_ID,
|
| 1317 |
+
repo_type="dataset",
|
| 1318 |
+
path_in_repo=path_in_repo,
|
| 1319 |
+
commit_message=f"Update files for {path_in_repo}",
|
| 1320 |
+
allow_patterns=["*.jpg", "*.jpeg", "*.txt", "*.png", "*.parquet"]
|
| 1321 |
+
)
|
| 1322 |
+
self.log_dataset(f" 🎉 {sub_folder_name} 上传完成!")
|
| 1323 |
+
except Exception as e:
|
| 1324 |
+
self.log_dataset(f" ❌ {sub_folder_name} 上传失败: {e}")
|
| 1325 |
+
|
| 1326 |
+
if temp_readme_path:
|
| 1327 |
+
self.log_dataset("\n 📝 正在推送最新的 README.md...")
|
| 1328 |
+
try:
|
| 1329 |
+
api.upload_file(
|
| 1330 |
+
path_or_fileobj=temp_readme_path,
|
| 1331 |
+
path_in_repo="README.md",
|
| 1332 |
+
repo_id=DATASET_REPO_ID,
|
| 1333 |
+
repo_type="dataset",
|
| 1334 |
+
commit_message="Auto-update README YAML configs"
|
| 1335 |
+
)
|
| 1336 |
+
self.log_dataset(" 🎉 README.md 已成功更新到云端!")
|
| 1337 |
+
except Exception as e:
|
| 1338 |
+
self.log_dataset(f" ❌ README.md 上传失败: {e}")
|
| 1339 |
+
finally:
|
| 1340 |
+
os.remove(temp_readme_path)
|
| 1341 |
+
# ==========================================================
|
| 1342 |
+
# ==================== 区域 4: Showcase 管理 ================
|
| 1343 |
+
# ==========================================================
|
| 1344 |
+
def _build_showcase_tab(self):
|
| 1345 |
+
parent = self.tab_showcase
|
| 1346 |
+
|
| 1347 |
+
# 1. 输入区
|
| 1348 |
+
frame = ttk.Frame(parent)
|
| 1349 |
+
frame.pack(fill="x", padx=10, pady=10)
|
| 1350 |
+
|
| 1351 |
+
# 本地文件夹
|
| 1352 |
+
ttk.Label(frame, text="本地文件夹:").grid(row=0, column=0, sticky="w", pady=4)
|
| 1353 |
+
self.sc_local_folder_var = tk.StringVar()
|
| 1354 |
+
ttk.Entry(frame, textvariable=self.sc_local_folder_var, width=70).grid(row=0, column=1, padx=6, sticky="we")
|
| 1355 |
+
ttk.Button(frame, text="📁 浏览...", width=18, command=self._browse_sc_local).grid(row=0, column=2)
|
| 1356 |
+
|
| 1357 |
+
# 远端目录 (Treeview 改造,完全复用 Models 的样式)
|
| 1358 |
+
ttk.Label(frame, text="上传到 Showcase 目录:").grid(row=1, column=0, sticky="nw", pady=4)
|
| 1359 |
+
|
| 1360 |
+
tree_frame = ttk.Frame(frame)
|
| 1361 |
+
tree_frame.grid(row=1, column=1, padx=6, sticky="nsew")
|
| 1362 |
+
|
| 1363 |
+
# 滚动条
|
| 1364 |
+
vsb = ttk.Scrollbar(tree_frame, orient="vertical")
|
| 1365 |
+
vsb.pack(side="right", fill="y")
|
| 1366 |
+
|
| 1367 |
+
# 树形列表
|
| 1368 |
+
self.sc_remote_tree = ttk.Treeview(tree_frame, height=6, show="tree",yscrollcommand=vsb.set, selectmode="browse")
|
| 1369 |
+
self.sc_remote_tree.pack(side="left", fill="both", expand=True)
|
| 1370 |
+
vsb.config(command=self.sc_remote_tree.yview)
|
| 1371 |
+
|
| 1372 |
+
# 绑定树形点击事件
|
| 1373 |
+
self.sc_remote_tree.bind("<<TreeviewSelect>>", self._on_sc_remote_tree_select)
|
| 1374 |
+
|
| 1375 |
+
# 刷新按钮
|
| 1376 |
+
ttk.Button(frame, text="🔄 拉取案例目录", width=18, command=self._fetch_showcase_dirs).grid(row=1, column=2, sticky="n")
|
| 1377 |
+
|
| 1378 |
+
# 按钮区
|
| 1379 |
+
btn_row = ttk.Frame(frame)
|
| 1380 |
+
btn_row.grid(row=2, column=0, columnspan=3, pady=15, sticky="w")
|
| 1381 |
+
|
| 1382 |
+
self.btn_sc_upload = ttk.Button(btn_row, text="🢁 生成 JSON 并上传", command=self._start_showcase_pipeline)
|
| 1383 |
+
self.btn_sc_upload.pack(side="left")
|
| 1384 |
+
|
| 1385 |
+
self.status_sc_var = tk.StringVar(value="就绪")
|
| 1386 |
+
ttk.Label(btn_row, textvariable=self.status_sc_var).pack(side="left", padx=10)
|
| 1387 |
+
|
| 1388 |
+
# 🌟 新增:Showcase 链接展示区
|
| 1389 |
+
result_row = ttk.Frame(frame)
|
| 1390 |
+
result_row.grid(row=3, column=0, columnspan=4, pady=(0, 10), sticky="we")
|
| 1391 |
+
|
| 1392 |
+
ttk.Label(result_row, text="Showcase 链接:").pack(side="left")
|
| 1393 |
+
|
| 1394 |
+
self.sc_result_var = tk.StringVar()
|
| 1395 |
+
self.sc_result_entry = ttk.Entry(result_row, textvariable=self.sc_result_var, state="readonly")
|
| 1396 |
+
self.sc_result_entry.pack(side="left", fill="x", expand=True, padx=6)
|
| 1397 |
+
|
| 1398 |
+
# 复制按钮
|
| 1399 |
+
ttk.Button(result_row, text="📋 复制", command=self._copy_sc_url).pack(side="left")
|
| 1400 |
+
|
| 1401 |
+
frame.columnconfigure(1, weight=1)
|
| 1402 |
+
frame.rowconfigure(1, weight=1)
|
| 1403 |
+
|
| 1404 |
+
frame.columnconfigure(1, weight=1)
|
| 1405 |
+
frame.rowconfigure(1, weight=1)
|
| 1406 |
+
|
| 1407 |
+
# 2. 日志区
|
| 1408 |
+
log_frame = ttk.LabelFrame(parent, text="Showcase 操作日志")
|
| 1409 |
+
log_frame.pack(fill="both", expand=True, padx=10, pady=6)
|
| 1410 |
+
self.log_sc_text = ScrolledText(log_frame, height=10, state="disabled", wrap="word")
|
| 1411 |
+
self.log_sc_text.pack(fill="both", expand=True)
|
| 1412 |
+
|
| 1413 |
+
def _copy_sc_url(self):
|
| 1414 |
+
url = self.sc_result_var.get()
|
| 1415 |
+
if url:
|
| 1416 |
+
self.clipboard_clear()
|
| 1417 |
+
self.clipboard_append(url)
|
| 1418 |
+
messagebox.showinfo("成功", "链接已复制到剪贴板!")
|
| 1419 |
+
|
| 1420 |
+
def log_showcase(self, msg):
|
| 1421 |
+
self._log_to_tab(msg, getattr(self, 'log_sc_text', None))
|
| 1422 |
+
|
| 1423 |
+
def _browse_sc_local(self):
|
| 1424 |
+
if getattr(sys, 'frozen', False):
|
| 1425 |
+
current_dir = os.path.dirname(sys.executable)
|
| 1426 |
+
else:
|
| 1427 |
+
current_dir = os.path.dirname(os.path.abspath(__file__))
|
| 1428 |
+
folder = filedialog.askdirectory(title="选择包含 PNG 的 Showcase 文件夹", initialdir=current_dir)
|
| 1429 |
+
if folder:
|
| 1430 |
+
self.sc_local_folder_var.set(folder)
|
| 1431 |
+
|
| 1432 |
+
def _fetch_showcase_dirs(self):
|
| 1433 |
+
if not self._ensure_token(): return
|
| 1434 |
+
self.status_sc_var.set("正在读取远端目录...")
|
| 1435 |
+
self.log_showcase("正在读取 Showcase 仓库目录列表...")
|
| 1436 |
+
|
| 1437 |
+
def work():
|
| 1438 |
+
try:
|
| 1439 |
+
api = self.api
|
| 1440 |
+
all_files = list(api.list_repo_files(repo_id=MODEL_REPO_ID, repo_type="model"))
|
| 1441 |
+
dirs = set()
|
| 1442 |
+
for f in all_files:
|
| 1443 |
+
# 核心修改:只提取 comfyui_showcase 下的文件夹
|
| 1444 |
+
if f.startswith("comfyui_showcase/"):
|
| 1445 |
+
d = str(Path(f).parent)
|
| 1446 |
+
if d != "comfyui_showcase":
|
| 1447 |
+
dirs.add(d)
|
| 1448 |
+
|
| 1449 |
+
sorted_dirs = sorted(list(dirs))
|
| 1450 |
+
self.after(0, lambda: self._update_sc_remote_tree(sorted_dirs))
|
| 1451 |
+
except Exception:
|
| 1452 |
+
self.log_exc("读取 Showcase 目录失败", is_model=True)
|
| 1453 |
+
finally:
|
| 1454 |
+
self.after(0, lambda: self.status_sc_var.set("就绪"))
|
| 1455 |
+
|
| 1456 |
+
threading.Thread(target=work, daemon=True).start()
|
| 1457 |
+
|
| 1458 |
+
def _update_sc_remote_tree(self, dirs):
|
| 1459 |
+
for item in self.sc_remote_tree.get_children():
|
| 1460 |
+
self.sc_remote_tree.delete(item)
|
| 1461 |
+
|
| 1462 |
+
tree_nodes = {}
|
| 1463 |
+
for d in dirs:
|
| 1464 |
+
safe_d = d.replace("\\", "/")
|
| 1465 |
+
parts = safe_d.split("/")
|
| 1466 |
+
parent_id = ""
|
| 1467 |
+
current_path = ""
|
| 1468 |
+
for i, part in enumerate(parts):
|
| 1469 |
+
current_path = f"{current_path}/{part}" if current_path else part
|
| 1470 |
+
if current_path not in tree_nodes:
|
| 1471 |
+
is_leaf = (i == len(parts) - 1)
|
| 1472 |
+
display_text = f"📁 {part}" if is_leaf else part
|
| 1473 |
+
node_id = self.sc_remote_tree.insert(parent_id, "end", text=display_text, values=(current_path,))
|
| 1474 |
+
tree_nodes[current_path] = node_id
|
| 1475 |
+
parent_id = tree_nodes[current_path]
|
| 1476 |
+
|
| 1477 |
+
for root_node in self.sc_remote_tree.get_children(""):
|
| 1478 |
+
self.sc_remote_tree.item(root_node, open=True)
|
| 1479 |
+
self.log_showcase(f"已加载 {len(dirs)} 个远端目录结构")
|
| 1480 |
+
|
| 1481 |
+
def _on_sc_remote_tree_select(self, event):
|
| 1482 |
+
selection = self.sc_remote_tree.selection()
|
| 1483 |
+
if selection:
|
| 1484 |
+
item_id = selection[0]
|
| 1485 |
+
values = self.sc_remote_tree.item(item_id, "values")
|
| 1486 |
+
if values:
|
| 1487 |
+
self.sc_remote_dir = values[0]
|
| 1488 |
+
|
| 1489 |
+
def _start_showcase_pipeline(self):
|
| 1490 |
+
local_dir = self.sc_local_folder_var.get().strip()
|
| 1491 |
+
remote_dir = getattr(self, 'sc_remote_dir', "").strip().strip("/")
|
| 1492 |
+
|
| 1493 |
+
if not local_dir or not os.path.exists(local_dir):
|
| 1494 |
+
messagebox.showwarning("错误", "请选择有效的本地文件夹。")
|
| 1495 |
+
return
|
| 1496 |
+
if not remote_dir:
|
| 1497 |
+
messagebox.showwarning("错误", "请先在右侧选择要上传到的远端目录。")
|
| 1498 |
+
return
|
| 1499 |
+
if not self._ensure_token(): return
|
| 1500 |
+
|
| 1501 |
+
# 提取本地文件夹名字,准备拼接到远端路径
|
| 1502 |
+
local_folder_name = os.path.basename(os.path.normpath(local_dir))
|
| 1503 |
+
final_target_path = f"{remote_dir}/{local_folder_name}"
|
| 1504 |
+
|
| 1505 |
+
if not messagebox.askyesno("确认", f"即将增量上传并更新 JSON:\n{MODEL_REPO_ID}/{final_target_path}\n\n继续?"):
|
| 1506 |
+
return
|
| 1507 |
+
|
| 1508 |
+
self.btn_sc_upload.configure(state="disabled")
|
| 1509 |
+
self.status_sc_var.set("处理中...")
|
| 1510 |
+
self.log_showcase("================ 开始 Showcase 增量流水线 ================")
|
| 1511 |
+
self.log_showcase(f"本地: {local_dir} -> 远端: {final_target_path}")
|
| 1512 |
+
|
| 1513 |
+
def worker():
|
| 1514 |
+
api = self.api
|
| 1515 |
+
try:
|
| 1516 |
+
# 1. 尝试拉取云端旧的 gallery.json
|
| 1517 |
+
remote_json_path = f"{final_target_path}/gallery.json"
|
| 1518 |
+
existing_data = []
|
| 1519 |
+
self.log_showcase(f"☁️ 正在检查云端是否已存在 gallery.json...")
|
| 1520 |
+
|
| 1521 |
+
try:
|
| 1522 |
+
local_tmp_json = api.hf_hub_download(
|
| 1523 |
+
repo_id=MODEL_REPO_ID,
|
| 1524 |
+
repo_type="model",
|
| 1525 |
+
filename=remote_json_path
|
| 1526 |
+
)
|
| 1527 |
+
with open(local_tmp_json, "r", encoding="utf-8") as f:
|
| 1528 |
+
existing_data = json.load(f)
|
| 1529 |
+
self.log_showcase(f" ✅ 发现云端旧数据,包含 {len(existing_data)} 条记录。")
|
| 1530 |
+
except Exception:
|
| 1531 |
+
self.log_showcase(" ℹ️ 云端暂无旧 JSON,将生成全新 JSON。")
|
| 1532 |
+
existing_data = []
|
| 1533 |
+
|
| 1534 |
+
# 2. 提取本地新图片的数据
|
| 1535 |
+
png_files = list(Path(local_dir).glob("*.png"))
|
| 1536 |
+
if not png_files:
|
| 1537 |
+
self.log_showcase("❌ 本地文件夹中没有找到 .png 图片。")
|
| 1538 |
+
self.after(0, lambda: self.status_sc_var.set("未找到图片"))
|
| 1539 |
+
return
|
| 1540 |
+
|
| 1541 |
+
self.log_showcase(f"📦 发现 {len(png_files)} 张本地 PNG,开始提取数据...")
|
| 1542 |
+
new_gallery_data = []
|
| 1543 |
+
for png in png_files:
|
| 1544 |
+
data = self._extract_single_png(png)
|
| 1545 |
+
if data:
|
| 1546 |
+
new_gallery_data.append(data)
|
| 1547 |
+
self.log_showcase(f" ✅ 解析: {png.name}")
|
| 1548 |
+
|
| 1549 |
+
# 3. 增量合并 (由于名字不同,这里直接追加)
|
| 1550 |
+
# 如果你想防止重复上传完全一样的图,可以用 file_name 去重,这里我们用字典去重然后转回列表
|
| 1551 |
+
merged_dict = {item["file_name"]: item for item in existing_data if "file_name" in item}
|
| 1552 |
+
for item in new_gallery_data:
|
| 1553 |
+
merged_dict[item["file_name"]] = item # 如果本地同名,直接覆盖老的;名字不同则追加
|
| 1554 |
+
|
| 1555 |
+
final_gallery_data = list(merged_dict.values())
|
| 1556 |
+
|
| 1557 |
+
json_path = os.path.join(local_dir, "gallery.json")
|
| 1558 |
+
with open(json_path, "w", encoding="utf-8") as f:
|
| 1559 |
+
json.dump(final_gallery_data, f, indent=2, ensure_ascii=False)
|
| 1560 |
+
self.log_showcase(f"📄 合并后的 gallery.json 已生成,共 {len(final_gallery_data)} 条数据。")
|
| 1561 |
+
|
| 1562 |
+
# 4. 上传(只传新的图片和合并后的 JSON,云端原有的图保持不变)
|
| 1563 |
+
self.log_showcase(f"🚀 开始上传新的图片和 JSON...")
|
| 1564 |
+
api.upload_folder(
|
| 1565 |
+
folder_path=local_dir,
|
| 1566 |
+
repo_id=MODEL_REPO_ID,
|
| 1567 |
+
repo_type="model",
|
| 1568 |
+
path_in_repo=final_target_path,
|
| 1569 |
+
commit_message=f"Add new images and update gallery.json at {final_target_path}",
|
| 1570 |
+
allow_patterns=["*.png", "*.json"] # 没有 delete_patterns,所以云端旧图绝不会被删!
|
| 1571 |
+
)
|
| 1572 |
+
|
| 1573 |
+
self.log_showcase("🎉 Showcase 增量上传完毕!")
|
| 1574 |
+
self.after(0, lambda: messagebox.showinfo("完成", "Showcase 增量更新成功!"))
|
| 1575 |
+
self.after(0, lambda: self.status_sc_var.set("上传完成"))
|
| 1576 |
+
|
| 1577 |
+
showcase_url = f"https://huggingface.co/waveshaker/loras/tree/main/{final_target_path}"
|
| 1578 |
+
self.log_showcase("-" * 50)
|
| 1579 |
+
self.log_showcase(f"🔗 Showcase 页面: {showcase_url}")
|
| 1580 |
+
self.log_showcase("-" * 50)
|
| 1581 |
+
|
| 1582 |
+
self.after(0, lambda: self.sc_result_var.set(showcase_url))
|
| 1583 |
+
|
| 1584 |
+
except Exception as e:
|
| 1585 |
+
self.log_exc("Showcase 流水线异常", is_model=True)
|
| 1586 |
+
self.after(0, lambda: self.status_sc_var.set("执行失败"))
|
| 1587 |
+
finally:
|
| 1588 |
+
self.after(0, lambda: self.btn_sc_upload.configure(state="normal"))
|
| 1589 |
+
|
| 1590 |
+
threading.Thread(target=worker, daemon=True).start()
|
| 1591 |
+
|
| 1592 |
+
# ==========================================================
|
| 1593 |
+
# ==================== Showcase 提取核心逻辑 ================
|
| 1594 |
+
# ==========================================================
|
| 1595 |
+
def _clean_showcase_name(self, filepath):
|
| 1596 |
+
if not filepath or not isinstance(filepath, str): return "-"
|
| 1597 |
+
filename = filepath.replace("\\", "/").split("/")[-1]
|
| 1598 |
+
return filename.replace(".safetensors", "").replace(".ckpt", "")
|
| 1599 |
+
|
| 1600 |
+
def _find_upstream_model(self, prompt_data, input_link, depth=0):
|
| 1601 |
+
if depth > 15: return "-"
|
| 1602 |
+
if not isinstance(input_link, list) or len(input_link) != 2: return "-"
|
| 1603 |
+
node = prompt_data.get(str(input_link[0]))
|
| 1604 |
+
if not node: return "-"
|
| 1605 |
+
|
| 1606 |
+
# 🌟 核心新增:如果节点被静音/禁用,它的连线被短路,直接跳过它继续找上游
|
| 1607 |
+
if node.get("mode") == 4:
|
| 1608 |
+
# 如果是 Bypass,输入口可能叫 model,继续往上找
|
| 1609 |
+
bypassed_input = node.get("inputs", {}).get("model")
|
| 1610 |
+
if bypassed_input:
|
| 1611 |
+
return self._find_upstream_model(prompt_data, bypassed_input, depth+1)
|
| 1612 |
+
return "-"
|
| 1613 |
+
|
| 1614 |
+
class_type = node.get("class_type", "")
|
| 1615 |
+
inputs = node.get("inputs", {})
|
| 1616 |
+
if class_type in ["CheckpointLoaderSimple", "UNETLoader"]:
|
| 1617 |
+
return self._clean_showcase_name(inputs.get("ckpt_name") or inputs.get("unet_name"))
|
| 1618 |
+
elif class_type in ["LoraLoader", "LoraLoaderModelOnly"]:
|
| 1619 |
+
return self._find_upstream_model(prompt_data, inputs.get("model"), depth+1)
|
| 1620 |
+
elif inputs.get("model"):
|
| 1621 |
+
return self._find_upstream_model(prompt_data, inputs.get("model"), depth+1)
|
| 1622 |
+
return "-"
|
| 1623 |
+
|
| 1624 |
+
def _find_specific_loader(self, prompt_data, target_class_types):
|
| 1625 |
+
for node in prompt_data.values():
|
| 1626 |
+
if node.get("class_type") in target_class_types:
|
| 1627 |
+
inputs = node.get("inputs", {})
|
| 1628 |
+
return self._clean_showcase_name(inputs.get("vae_name") or inputs.get("clip_name"))
|
| 1629 |
+
return "-"
|
| 1630 |
+
|
| 1631 |
+
def _extract_single_png(self, png_path):
|
| 1632 |
+
try:
|
| 1633 |
+
with Image.open(png_path) as img:
|
| 1634 |
+
raw_prompt = img.info.get('prompt', '')
|
| 1635 |
+
dasiwa_text = img.info.get('parameters', '') or img.info.get('Description', '')
|
| 1636 |
+
|
| 1637 |
+
report = {"size": "-", "loras": [], "model": "-", "vae": "-", "clip": "-","sampler": "-", "scheduler": "-", "seed": "-", "steps": "-", "cfg": "-", "denoise": "-", "pos": ""}
|
| 1638 |
+
|
| 1639 |
+
if raw_prompt and raw_prompt.strip().startswith("{"):
|
| 1640 |
+
prompt_data = json.loads(raw_prompt)
|
| 1641 |
+
for node in prompt_data.values():
|
| 1642 |
+
# 🌟 核心新增:跳过所有被禁用的节点
|
| 1643 |
+
if node.get("mode") == 4:
|
| 1644 |
+
continue
|
| 1645 |
+
|
| 1646 |
+
if node.get("class_type") in ["KSampler", "KSamplerAdvanced"]:
|
| 1647 |
+
inputs = node.get("inputs", {})
|
| 1648 |
+
report["sampler"] = inputs.get("sampler_name", "-")
|
| 1649 |
+
report["scheduler"] = inputs.get("scheduler", "-")
|
| 1650 |
+
report["steps"] = inputs.get("steps", "-")
|
| 1651 |
+
report["seed"] = inputs.get("seed", "-")
|
| 1652 |
+
report["cfg"] = inputs.get("cfg", 1.0)
|
| 1653 |
+
report["denoise"] = inputs.get("denoise", 1.0)
|
| 1654 |
+
report["model"] = self._find_upstream_model(prompt_data, inputs.get("model"))
|
| 1655 |
+
break
|
| 1656 |
+
report["vae"] = self._find_specific_loader(prompt_data, ["VAELoader"])
|
| 1657 |
+
report["clip"] = self._find_specific_loader(prompt_data, ["CLIPLoader", "DualCLIPLoader"])
|
| 1658 |
+
|
| 1659 |
+
neg_text = ""
|
| 1660 |
+
if dasiwa_text:
|
| 1661 |
+
pos_match = re.search(r"^(.*?)(?:Negative prompt:|$)", dasiwa_text, re.DOTALL | re.IGNORECASE)
|
| 1662 |
+
if pos_match: report["pos"] = pos_match.group(1).strip()
|
| 1663 |
+
neg_match = re.search(r"Negative prompt:\s*(.*?)(?:Steps:|$)", dasiwa_text, re.DOTALL | re.IGNORECASE)
|
| 1664 |
+
if neg_match: neg_text = neg_match.group(1).strip()
|
| 1665 |
+
size_match = re.search(r"Size:\s*(\d+x\d+)", dasiwa_text)
|
| 1666 |
+
if size_match: report["size"] = size_match.group(1)
|
| 1667 |
+
lora_matches = re.findall(r"<lora:([^:>]+)(?::[^>]+)?>", report["pos"])
|
| 1668 |
+
report["loras"] = [self._clean_showcase_name(l) for l in lora_matches]
|
| 1669 |
+
|
| 1670 |
+
if neg_text and "negative_prompt_not_found" not in neg_text:
|
| 1671 |
+
report["neg"] = neg_text
|
| 1672 |
+
|
| 1673 |
+
report["file_name"] = os.path.basename(png_path)
|
| 1674 |
+
return report
|
| 1675 |
+
except Exception as e:
|
| 1676 |
+
self.log_showcase(f" ⚠️ 提取失败 {os.path.basename(png_path)}: {e}")
|
| 1677 |
+
return None
|
| 1678 |
+
|
| 1679 |
+
if __name__ == "__main__":
|
| 1680 |
+
app = ModelsManager()
|
| 1681 |
+
app.mainloop()
|