feat(ai-studio): export final selections

This commit is contained in:
chengma
2026-07-11 14:26:01 +08:00
parent 5754e4831a
commit cddadf6364
9 changed files with 539 additions and 8 deletions
+1
View File
@@ -25,6 +25,7 @@ if QT_IMPORT_ERROR is None:
CollectWorker,
GenerateWorker,
ImageStudioDownloadOriginalWorker,
ImageStudioExportWorker,
ImageStudioGenerateJobsWorker,
ImageStudioPullImagesWorker,
WriteBackWorker,
+110 -1
View File
@@ -7,12 +7,13 @@ import os
from PySide6.QtCore import QMimeData
from PySide6.QtWidgets import QListWidget, QListWidgetItem
from ... import accounts, appconfig, db, image_studio, prompts
from ... import accounts, appconfig, db, image_studio, image_studio_export, prompts
from .. import file_manager
from ..widgets import *
from ..workers import (
ImageStudioDownloadOriginalWorker as _RealImageStudioDownloadOriginalWorker,
)
from ..workers import ImageStudioExportWorker as _RealImageStudioExportWorker
from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJobsWorker
from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker
@@ -47,6 +48,15 @@ def ImageStudioGenerateJobsWorker(*args, **kwargs):
)
def ImageStudioExportWorker(*args, **kwargs):
return _call_package_attr(
"ImageStudioExportWorker",
_RealImageStudioExportWorker,
*args,
**kwargs,
)
def _drop_event_position(event):
if hasattr(event, "position"):
return event.position().toPoint()
@@ -469,8 +479,20 @@ class ImageStudioTab(QWidget):
self.detail_selection_list.setMinimumHeight(96)
detail_layout.addWidget(self.detail_selection_label)
detail_layout.addWidget(self.detail_selection_list)
action_panel = QWidget()
action_layout = QVBoxLayout(action_panel)
action_layout.setContentsMargins(0, 0, 0, 0)
self.export_button = QPushButton("导出终选")
self.export_button.setObjectName("imageStudioExportButton")
self.export_hint_label = QLabel("可部分导出,不要求主图/详情图满额")
self.export_hint_label.setObjectName("imageStudioExportHintLabel")
self.export_hint_label.setWordWrap(True)
action_layout.addWidget(self.export_button)
action_layout.addWidget(self.export_hint_label)
action_layout.addStretch(1)
layout.addWidget(main_panel, 1)
layout.addWidget(detail_panel, 1)
layout.addWidget(action_panel, 0)
return panel
def _connect_signals(self):
@@ -491,6 +513,7 @@ class ImageStudioTab(QWidget):
self.prompt_edit.textChanged.connect(self._save_project_prompt)
self.start_button.clicked.connect(self.start_generation)
self.stop_button.clicked.connect(self.stop_generation)
self.export_button.clicked.connect(self.export_selections)
def refresh_accounts(self):
self.account_combo.clear()
@@ -1032,6 +1055,91 @@ class ImageStudioTab(QWidget):
self._append_log("[AI工场] 已请求停止,正在等待安全边界")
self._status("AI工场生成已请求停止", "warning")
def export_selections(self, checked=False):
if self.current_project is None:
self._message("项目未打开", "请先打开一个AI工场项目。")
return
main_count = len(self._selection_asset_ids("main"))
detail_count = len(self._selection_asset_ids("detail"))
if main_count + detail_count <= 0:
self._message("没有可导出图片", "主图和详情图终选都为空。")
return
parent_dir = QFileDialog.getExistingDirectory(self, "选择导出父目录")
if not parent_dir:
return
mode = self._export_existing_mode(parent_dir, main_count, detail_count)
if mode is None:
return
worker = ImageStudioExportWorker(
self.current_project.id,
parent_dir,
existing_mode=mode,
db_path=self.db_path,
config=self.config,
)
worker.log.connect(self._append_log)
worker.finished.connect(self._on_export_finished)
worker.failed.connect(self._on_worker_failed)
self._start_worker(worker, "AI工场导出终选")
def _export_existing_mode(self, parent_dir, main_count, detail_count):
try:
target = image_studio_export.target_dir_for_project(self.current_project, parent_dir)
except Exception as exc:
self._message("导出目录不可用", str(exc))
return None
if not os.path.exists(target):
return image_studio_export.EXISTING_FAIL
box = QMessageBox(self)
box.setWindowTitle("商品目录已存在")
box.setText(
f"目标目录已存在:{target}\n"
f"本次将导出主图 {main_count} 张、详情图 {detail_count} 张。请选择处理方式。"
)
overwrite_button = box.addButton("覆盖本软件导出的图片", QMessageBox.AcceptRole)
timestamp_button = box.addButton("新建带时间目录", QMessageBox.ActionRole)
box.addButton("取消", QMessageBox.RejectRole)
box.setDefaultButton(timestamp_button)
box.exec()
clicked = box.clickedButton()
if clicked is overwrite_button:
return image_studio_export.EXISTING_OVERWRITE_MANAGED
if clicked is timestamp_button:
return image_studio_export.EXISTING_TIMESTAMP
return None
def _on_export_finished(self, summary):
if summary.get("ok") is False:
if self._running_worker is not None:
self._on_worker_failed(-1, summary.get("error") or "导出终选失败")
return
self._finish_worker()
target_dir = summary.get("target_dir") or ""
main_count = int(summary.get("main_count") or 0)
detail_count = int(summary.get("detail_count") or 0)
file_count = int(summary.get("file_count") or 0)
self._append_log(f"[AI工场] 导出完成:主图{main_count},详情图{detail_count},文件{file_count}")
self._status(f"AI工场终选已导出:{file_count} 个文件", "success")
self._show_export_success(target_dir, main_count, detail_count, file_count)
def _show_export_success(self, target_dir, main_count, detail_count, file_count):
box = QMessageBox(self)
box.setWindowTitle("导出完成")
box.setText(
f"已导出 {file_count} 个 JPEG 文件。\n"
f"主图 {main_count} 张,详情图 {detail_count} 张。\n"
f"目录:{target_dir}"
)
open_button = box.addButton("打开目录", QMessageBox.AcceptRole)
box.addButton("确定", QMessageBox.RejectRole)
box.setDefaultButton(open_button)
box.exec()
if box.clickedButton() is open_button:
try:
file_manager.open_in_file_manager(target_dir)
except Exception as exc:
self._message("打开目录失败", str(exc))
def _on_generate_progress(self, payload):
total = max(1, int(payload.get("total") or self.progress_bar.maximum() or 1))
done = min(total, int(payload.get("done") or 0))
@@ -1096,6 +1204,7 @@ class ImageStudioTab(QWidget):
self.aspect_combo.setEnabled(not running)
self.start_button.setEnabled(not running)
self.stop_button.setEnabled(running)
self.export_button.setEnabled(not running)
def _set_account_combo(self, alias):
index = self.account_combo.findData(alias)
+39 -1
View File
@@ -6,7 +6,7 @@ import datetime as _dt
import re
import threading
from .. import image_studio, image_studio_generation, image_studio_images
from .. import image_studio, image_studio_export, image_studio_generation, image_studio_images
from .widgets import *
@@ -192,6 +192,44 @@ class ImageStudioGenerateJobsWorker(BaseWorker):
return summary
class ImageStudioExportWorker(BaseWorker):
"""Export AI studio final selections to local JPEG files."""
def __init__(
self,
project_id,
parent_dir,
*,
existing_mode=image_studio_export.EXISTING_FAIL,
db_path=None,
config=None,
):
super().__init__()
self.project_id = int(project_id)
self.parent_dir = parent_dir
self.existing_mode = existing_mode
self.db_path = db_path
self.config = config
def execute(self):
self.log.emit("[AI工场] 导出终选:开始")
result = image_studio_export.export_project_selection(
self.project_id,
self.parent_dir,
existing_mode=self.existing_mode,
path=self.db_path,
config=self.config,
)
self.log.emit("[AI工场] 导出终选:成功")
return {
"target_dir": result.target_dir,
"main_count": result.main_count,
"detail_count": result.detail_count,
"file_count": len(result.files),
"existing_mode": result.existing_mode,
}
def _generation_mode_label(mode):
mode = appconfig.normalize_generate_mode(mode)
return {
+210
View File
@@ -0,0 +1,210 @@
"""Safe local export helpers for AI image studio final selections."""
from __future__ import annotations
import os
import re
import shutil
import uuid
from dataclasses import dataclass
from datetime import datetime
from . import appconfig, image_studio
EXISTING_FAIL = "fail"
EXISTING_OVERWRITE_MANAGED = "overwrite_managed"
EXISTING_TIMESTAMP = "timestamp"
EXISTING_MODES = {EXISTING_FAIL, EXISTING_OVERWRITE_MANAGED, EXISTING_TIMESTAMP}
class ImageStudioExportError(RuntimeError):
"""Raised when AI studio selections cannot be exported safely."""
class ExportTargetExistsError(ImageStudioExportError):
"""Raised when the default target directory already exists."""
@dataclass(frozen=True)
class ExportedFile:
selection_type: str
asset_id: int
source_path: str
output_path: str
@dataclass(frozen=True)
class ExportResult:
target_dir: str
files: tuple[ExportedFile, ...]
main_count: int
detail_count: int
existing_mode: str
def target_dir_for_project(project, parent_dir, suffix=None):
parent = _existing_parent_dir(parent_dir)
item = _safe_item_id(getattr(project, "item_id", ""))
dirname = item if not suffix else f"{item}_{suffix}"
return os.path.abspath(os.path.join(parent, dirname))
def export_project_selection(
project_id,
parent_dir,
*,
existing_mode=EXISTING_FAIL,
path=None,
config=None,
timestamp=None,
):
"""Export current main/detail selections as ordered JPEG files."""
mode = str(existing_mode or EXISTING_FAIL)
if mode not in EXISTING_MODES:
raise ImageStudioExportError("导出目录处理方式无效")
cfg = appconfig.load_config() if config is None else config
database_path = path or appconfig.db_path(cfg)
project = image_studio.get_project(project_id, path=database_path)
if project is None:
raise ImageStudioExportError("AI工场项目不存在")
parent = _existing_parent_dir(parent_dir)
planned = _planned_files(project, database_path)
if not planned:
raise ImageStudioExportError("主图和详情图终选都为空,不能导出")
target = _choose_target_dir(project, parent, mode, timestamp=timestamp)
quality = int(appconfig.ai_config(cfg).get("jpg_quality", 90) or 90)
staging = os.path.join(parent, f".{_safe_item_id(project.item_id)}_staging_{uuid.uuid4().hex}")
try:
os.makedirs(staging, exist_ok=False)
staged = []
for item in planned:
output_path = os.path.join(staging, item["filename"])
_save_jpeg(item["source_path"], output_path, quality)
staged.append(ExportedFile(item["selection_type"], item["asset_id"], item["source_path"], output_path))
os.makedirs(target, exist_ok=True)
if os.path.exists(target) and mode == EXISTING_OVERWRITE_MANAGED:
_remove_managed_exports(target, project.item_id)
final_files = []
for staged_file in staged:
final_path = os.path.join(target, os.path.basename(staged_file.output_path))
os.replace(staged_file.output_path, final_path)
final_files.append(
ExportedFile(
staged_file.selection_type,
staged_file.asset_id,
staged_file.source_path,
final_path,
)
)
return ExportResult(
target_dir=target,
files=tuple(final_files),
main_count=sum(1 for item in final_files if item.selection_type == "main"),
detail_count=sum(1 for item in final_files if item.selection_type == "detail"),
existing_mode=mode,
)
except Exception as exc:
if isinstance(exc, ImageStudioExportError):
raise
raise ImageStudioExportError(f"导出终选图片失败:{exc}") from exc
finally:
if os.path.isdir(staging):
shutil.rmtree(staging, ignore_errors=True)
def _choose_target_dir(project, parent_dir, existing_mode, timestamp=None):
target = target_dir_for_project(project, parent_dir)
if not os.path.exists(target):
return target
if not os.path.isdir(target):
raise ImageStudioExportError(f"导出目标已存在但不是目录:{target}")
if existing_mode == EXISTING_FAIL:
raise ExportTargetExistsError(f"商品目录已存在:{target}")
if existing_mode == EXISTING_OVERWRITE_MANAGED:
return target
stamp = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
base = target_dir_for_project(project, parent_dir, suffix=stamp)
candidate = base
index = 2
while os.path.exists(candidate):
candidate = f"{base}_{index}"
index += 1
return candidate
def _planned_files(project, db_path):
item = _safe_item_id(project.item_id)
planned = []
for selection_type, label in (("main", "主图"), ("detail", "详情图")):
selections = image_studio.list_selections(project.id, selection_type, path=db_path)
for index, selection in enumerate(selections, start=1):
asset = image_studio.get_asset(selection.asset_id, path=db_path)
if asset is None:
raise ImageStudioExportError(f"终选照片不存在:#{selection.asset_id}")
source_path = str(asset.local_path or "")
if not source_path or not os.path.isfile(source_path):
raise ImageStudioExportError(f"终选照片本地文件缺失:#{asset.id}")
planned.append(
{
"selection_type": selection_type,
"asset_id": int(asset.id),
"source_path": os.path.abspath(source_path),
"filename": f"{item}_{label}_{index}.jpg",
}
)
return planned
def _save_jpeg(source_path, output_path, quality):
try:
from PIL import Image
with Image.open(source_path) as image:
if image.mode in {"RGBA", "LA"} or (
image.mode == "P" and "transparency" in getattr(image, "info", {})
):
rgba = image.convert("RGBA")
background = Image.new("RGBA", rgba.size, (255, 255, 255, 255))
background.alpha_composite(rgba)
final_image = background.convert("RGB")
else:
final_image = image.convert("RGB")
final_image.save(output_path, format="JPEG", quality=max(1, min(95, int(quality or 90))))
with Image.open(output_path) as check:
check.verify()
except Exception as exc:
raise ImageStudioExportError(f"JPEG 转码失败:{exc}") from exc
def _remove_managed_exports(target_dir, item_id):
item = re.escape(_safe_item_id(item_id))
pattern = re.compile(rf"^{item}_(主图|详情图)_\d+\.jpg$", re.IGNORECASE)
for filename in os.listdir(target_dir):
if not pattern.match(filename):
continue
path = os.path.join(target_dir, filename)
if os.path.isfile(path):
os.remove(path)
def _existing_parent_dir(parent_dir):
parent = os.path.abspath(str(parent_dir or ""))
if not os.path.isdir(parent):
raise ImageStudioExportError(f"导出父目录不存在:{parent}")
return parent
def _safe_item_id(item_id):
value = str(item_id or "").strip()
if not value:
raise ImageStudioExportError("商品ID不能为空")
if any(char in value for char in ('/', '\\', os.sep, os.altsep or "\0")):
raise ImageStudioExportError("商品ID包含非法路径字符")
if value in {".", ".."}:
raise ImageStudioExportError("商品ID不能是路径符号")
safe = "".join(ch if ch.isalnum() or ch in "_-" else "_" for ch in value).strip("_")
if not safe:
raise ImageStudioExportError("商品ID不能作为目录名")
return safe