feat: add outfit output directory
This commit is contained in:
@@ -245,7 +245,7 @@ class AiOutfitPanel(QWidget):
|
|||||||
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
|
self._excel_edit.setPlaceholderText("选择商品表 .xlsx")
|
||||||
col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel))
|
col.addLayout(self._inline_path_row("Excel", self._excel_edit, self._browse_excel))
|
||||||
self._output_edit = QLineEdit()
|
self._output_edit = QLineEdit()
|
||||||
self._output_edit.setPlaceholderText("默认:程序旁的「合并后的图片」")
|
self._output_edit.setPlaceholderText("默认:程序旁的「穿搭图片」")
|
||||||
col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output))
|
col.addLayout(self._inline_path_row("输出", self._output_edit, self._browse_output))
|
||||||
|
|
||||||
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
|
# 穿搭生成话术(多套模板;加大,随窗口高度拉伸)
|
||||||
@@ -467,7 +467,11 @@ class AiOutfitPanel(QWidget):
|
|||||||
def apply_config(self, config):
|
def apply_config(self, config):
|
||||||
"""Populate widgets from the merged app config + load models/prompt."""
|
"""Populate widgets from the merged app config + load models/prompt."""
|
||||||
self._excel_edit.setText(config.get("outfit_excel", ""))
|
self._excel_edit.setText(config.get("outfit_excel", ""))
|
||||||
self._output_edit.setText(config.get("outfit_output_dir", ""))
|
outfit_output_dir = str(config.get("outfit_output_dir", "") or "").strip()
|
||||||
|
if not outfit_output_dir:
|
||||||
|
from services.file_service import get_outfit_output_dir
|
||||||
|
outfit_output_dir = str(get_outfit_output_dir())
|
||||||
|
self._output_edit.setText(outfit_output_dir)
|
||||||
self._concurrency.setValue(int(config.get("outfit_concurrency", 1) or 1))
|
self._concurrency.setValue(int(config.get("outfit_concurrency", 1) or 1))
|
||||||
self._interval.setValue(float(config.get("outfit_request_interval", 2.0) or 0.0))
|
self._interval.setValue(float(config.get("outfit_request_interval", 2.0) or 0.0))
|
||||||
self._cooldown.setValue(float(config.get("outfit_task_cooldown", 1.0) or 0.0))
|
self._cooldown.setValue(float(config.get("outfit_task_cooldown", 1.0) or 0.0))
|
||||||
@@ -787,8 +791,8 @@ class AiOutfitPanel(QWidget):
|
|||||||
|
|
||||||
output_dir = self._output_edit.text().strip()
|
output_dir = self._output_edit.text().strip()
|
||||||
if not output_dir:
|
if not output_dir:
|
||||||
from services.file_service import get_output_dir
|
from services.file_service import get_outfit_output_dir
|
||||||
output_dir = str(get_output_dir())
|
output_dir = str(get_outfit_output_dir())
|
||||||
|
|
||||||
from core.outfit_batch import OutfitBatchOptions
|
from core.outfit_batch import OutfitBatchOptions
|
||||||
options = OutfitBatchOptions(
|
options = OutfitBatchOptions(
|
||||||
@@ -965,8 +969,8 @@ class AiOutfitPanel(QWidget):
|
|||||||
def _open_output_dir(self):
|
def _open_output_dir(self):
|
||||||
path = self._output_edit.text().strip()
|
path = self._output_edit.text().strip()
|
||||||
if not path:
|
if not path:
|
||||||
from services.file_service import get_output_dir
|
from services.file_service import get_outfit_output_dir
|
||||||
path = str(get_output_dir())
|
path = str(get_outfit_output_dir())
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(path))
|
||||||
|
|
||||||
def _export_failures(self):
|
def _export_failures(self):
|
||||||
|
|||||||
@@ -91,8 +91,9 @@ def _is_dir_writable(path):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# Default export folder name, placed next to Launcher.exe in the packaged layout.
|
# Default export folder names, placed next to Launcher.exe in the packaged layout.
|
||||||
_OUTPUT_DIR_NAME = "合并后的图片"
|
_OUTPUT_DIR_NAME = "合并后的图片"
|
||||||
|
_OUTFIT_OUTPUT_DIR_NAME = "穿搭图片"
|
||||||
|
|
||||||
|
|
||||||
def get_output_dir():
|
def get_output_dir():
|
||||||
@@ -114,6 +115,28 @@ def get_output_dir():
|
|||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def get_outfit_output_dir():
|
||||||
|
"""Return the default AI outfit output directory as a Path.
|
||||||
|
|
||||||
|
Packaged: <install root>\\穿搭图片 (next to Launcher.exe, not inside app\\).
|
||||||
|
Falls back to <data_dir>/output/穿搭图片 if the preferred location is not
|
||||||
|
writable. Development: <project root>/穿搭图片.
|
||||||
|
|
||||||
|
User-selected outfit_output_dir still takes precedence in the UI.
|
||||||
|
"""
|
||||||
|
if getattr(sys, "frozen", False):
|
||||||
|
candidate = get_app_dir().parent / _OUTFIT_OUTPUT_DIR_NAME
|
||||||
|
else:
|
||||||
|
candidate = get_app_dir() / _OUTFIT_OUTPUT_DIR_NAME
|
||||||
|
|
||||||
|
if _is_dir_writable(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
d = get_data_dir() / "output" / _OUTFIT_OUTPUT_DIR_NAME
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# 文件夹扫描
|
# 文件夹扫描
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1210,8 +1210,8 @@
|
|||||||
设计取舍:新增 AI 穿搭专用默认输出目录辅助函数,不改变添加印花页的 `get_output_dir()` 行为;用户已手动选择的 `outfit_output_dir` 继续优先,只有为空时才使用新默认目录。
|
设计取舍:新增 AI 穿搭专用默认输出目录辅助函数,不改变添加印花页的 `get_output_dir()` 行为;用户已手动选择的 `outfit_output_dir` 继续优先,只有为空时才使用新默认目录。
|
||||||
|
|
||||||
- [x] 文档已更新:`docs/11-ai-outfit.md` / `docs/10-lan-update.md` / `docs/ui-ai-outfit.html` 均指向 `穿搭图片\`
|
- [x] 文档已更新:`docs/11-ai-outfit.md` / `docs/10-lan-update.md` / `docs/ui-ai-outfit.html` 均指向 `穿搭图片\`
|
||||||
- [ ] `file_service.py` 增加 AI 穿搭默认输出目录辅助函数(如 `get_outfit_output_dir()`):打包态优先 `<安装根>\穿搭图片`,不可写回退 `get_data_dir()/output/穿搭图片`,开发态用项目目录下 `穿搭图片`
|
- [x] `file_service.py` 增加 AI 穿搭默认输出目录辅助函数(如 `get_outfit_output_dir()`):打包态优先 `<安装根>\穿搭图片`,不可写回退 `get_data_dir()/output/穿搭图片`,开发态用项目目录下 `穿搭图片`
|
||||||
- [ ] `ai_outfit_panel.py` 默认输出目录改用该 helper;`outfit_output_dir` 非空时仍使用用户保存值
|
- [x] `ai_outfit_panel.py` 默认输出目录改用该 helper;`outfit_output_dir` 非空时仍使用用户保存值
|
||||||
- [ ] `core/ai_outfit.py` 目录行输出保持 `AI 穿搭输出目录/<目录叶子名>/<源图名>.jpg`,单文件行仍按当前输出目录落盘
|
- [x] `core/ai_outfit.py` 目录行输出保持 `AI 穿搭输出目录/<目录叶子名>/<源图名>.jpg`,单文件行仍按当前输出目录落盘
|
||||||
- [ ] 补测试:默认目录路径、不可写回退、AI 穿搭面板默认值、目录行输出到 `穿搭图片/<目录名>/`
|
- [x] 补测试:默认目录路径、不可写回退、AI 穿搭面板默认值、目录行输出到 `穿搭图片/<目录名>/`
|
||||||
- [ ] 验证:相关单测和全套 `python -m unittest discover -s tests` 通过;离屏启动 AI 穿搭页时输出框默认显示 `穿搭图片`
|
- [~] 验证:相关单测通过,离屏启动 AI 穿搭页时输出框默认显示 `穿搭图片`;全套 `python -m unittest discover -s tests` 当前被工作区未提交的 `packaging/default_config/ai_models.json` 改动阻塞(模型顺序/API key 与出厂模板规范不一致),待清理该文件后重跑
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ class TestAiOutfitCore(unittest.TestCase):
|
|||||||
from core.ai_outfit import generate_outfit_image
|
from core.ai_outfit import generate_outfit_image
|
||||||
|
|
||||||
d = self._make_dir_with_images("a")
|
d = self._make_dir_with_images("a")
|
||||||
out = self.tmp / "out"
|
out = self.tmp / "穿搭图片"
|
||||||
client = _RecordingClient(self._image_bytes())
|
client = _RecordingClient(self._image_bytes())
|
||||||
|
|
||||||
result = generate_outfit_image(
|
result = generate_outfit_image(
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Smoke tests for AI outfit panel defaults (offscreen Qt)."""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
|
|
||||||
|
import services.file_service as fs
|
||||||
|
from services.config_service import DEFAULT_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
class TestAiOutfitPanelDefaults(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.tmp = Path(tempfile.mkdtemp())
|
||||||
|
self._env = os.environ.get("CMBOT_DATA_DIR")
|
||||||
|
os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data")
|
||||||
|
self.app_dir = self.tmp / "app"
|
||||||
|
self.app_dir.mkdir(parents=True)
|
||||||
|
self._orig_get_app_dir = fs.get_app_dir
|
||||||
|
fs.get_app_dir = lambda: self.app_dir
|
||||||
|
self.app = self._app()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
fs.get_app_dir = self._orig_get_app_dir
|
||||||
|
if self._env is None:
|
||||||
|
os.environ.pop("CMBOT_DATA_DIR", None)
|
||||||
|
else:
|
||||||
|
os.environ["CMBOT_DATA_DIR"] = self._env
|
||||||
|
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||||
|
|
||||||
|
def _app(self):
|
||||||
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is None:
|
||||||
|
app = QApplication([])
|
||||||
|
return app
|
||||||
|
|
||||||
|
def _panel(self):
|
||||||
|
from app.widgets.ai_outfit_panel import AiOutfitPanel
|
||||||
|
|
||||||
|
return AiOutfitPanel()
|
||||||
|
|
||||||
|
def test_empty_config_defaults_to_outfit_output_dir(self):
|
||||||
|
panel = self._panel()
|
||||||
|
cfg = dict(DEFAULT_CONFIG)
|
||||||
|
cfg["outfit_output_dir"] = ""
|
||||||
|
|
||||||
|
panel.apply_config(cfg)
|
||||||
|
|
||||||
|
self.assertEqual(Path(panel._output_edit.text()), self.app_dir / "穿搭图片")
|
||||||
|
|
||||||
|
def test_saved_outfit_output_dir_takes_precedence(self):
|
||||||
|
panel = self._panel()
|
||||||
|
saved = self.tmp / "custom-output"
|
||||||
|
cfg = dict(DEFAULT_CONFIG)
|
||||||
|
cfg["outfit_output_dir"] = str(saved)
|
||||||
|
|
||||||
|
panel.apply_config(cfg)
|
||||||
|
|
||||||
|
self.assertEqual(Path(panel._output_edit.text()), saved)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -103,5 +103,63 @@ class TestGetOutputDir(unittest.TestCase):
|
|||||||
fs._is_dir_writable = orig
|
fs._is_dir_writable = orig
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOutfitOutputDir(unittest.TestCase):
|
||||||
|
"""get_outfit_output_dir() default location (docs/11 §9)."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
import tempfile
|
||||||
|
self.tmp = Path(tempfile.mkdtemp())
|
||||||
|
self._env = os.environ.get("CMBOT_DATA_DIR")
|
||||||
|
self._frozen = getattr(sys, "frozen", None)
|
||||||
|
os.environ["CMBOT_DATA_DIR"] = str(self.tmp / "data")
|
||||||
|
if hasattr(sys, "frozen"):
|
||||||
|
del sys.frozen
|
||||||
|
self._orig_app_dir = fs.get_app_dir
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
import shutil
|
||||||
|
fs.get_app_dir = self._orig_app_dir
|
||||||
|
if self._env is None:
|
||||||
|
os.environ.pop("CMBOT_DATA_DIR", None)
|
||||||
|
else:
|
||||||
|
os.environ["CMBOT_DATA_DIR"] = self._env
|
||||||
|
if self._frozen is None:
|
||||||
|
if hasattr(sys, "frozen"):
|
||||||
|
del sys.frozen
|
||||||
|
else:
|
||||||
|
sys.frozen = self._frozen
|
||||||
|
shutil.rmtree(str(self.tmp), ignore_errors=True)
|
||||||
|
|
||||||
|
def test_dev_uses_project_outfit_folder(self):
|
||||||
|
app = self.tmp / "project"
|
||||||
|
app.mkdir()
|
||||||
|
fs.get_app_dir = lambda: app
|
||||||
|
|
||||||
|
self.assertEqual(fs.get_outfit_output_dir(), app / "穿搭图片")
|
||||||
|
|
||||||
|
def test_frozen_uses_install_root_outfit_folder(self):
|
||||||
|
app = self.tmp / "install" / "app"
|
||||||
|
app.mkdir(parents=True)
|
||||||
|
fs.get_app_dir = lambda: app
|
||||||
|
sys.frozen = True
|
||||||
|
|
||||||
|
self.assertEqual(fs.get_outfit_output_dir(), self.tmp / "install" / "穿搭图片")
|
||||||
|
|
||||||
|
def test_frozen_falls_back_when_not_writable(self):
|
||||||
|
app = self.tmp / "install" / "app"
|
||||||
|
app.mkdir(parents=True)
|
||||||
|
fs.get_app_dir = lambda: app
|
||||||
|
sys.frozen = True
|
||||||
|
orig = fs._is_dir_writable
|
||||||
|
fs._is_dir_writable = lambda p: False
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
fs.get_outfit_output_dir(),
|
||||||
|
Path(os.environ["CMBOT_DATA_DIR"]) / "output" / "穿搭图片",
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
fs._is_dir_writable = orig
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user