"""Tab 6: AI image studio UI.""" from __future__ import annotations import os from ... import accounts, appconfig, db, image_studio, prompts from .. import file_manager from ..widgets import * from ..workers import ( ImageStudioDownloadOriginalWorker as _RealImageStudioDownloadOriginalWorker, ) from ..workers import ImageStudioGenerateJobsWorker as _RealImageStudioGenerateJobsWorker from ..workers import ImageStudioPullImagesWorker as _RealImageStudioPullImagesWorker def ImageStudioPullImagesWorker(*args, **kwargs): return _call_package_attr( "ImageStudioPullImagesWorker", _RealImageStudioPullImagesWorker, *args, **kwargs, ) def ImageStudioDownloadOriginalWorker(*args, **kwargs): return _call_package_attr( "ImageStudioDownloadOriginalWorker", _RealImageStudioDownloadOriginalWorker, *args, **kwargs, ) def ImageStudioGenerateJobsWorker(*args, **kwargs): return _call_package_attr( "ImageStudioGenerateJobsWorker", _RealImageStudioGenerateJobsWorker, *args, **kwargs, ) class ImageStudioPreviewDialog(QDialog): """Simple large image preview used by original and pool tables.""" def __init__(self, asset, parent=None): super().__init__(parent) self.asset = asset self.setWindowTitle(self._title_for_asset(asset)) layout = QVBoxLayout(self) scroll = QScrollArea() scroll.setWidgetResizable(False) image_label = QLabel() image_label.setAlignment(Qt.AlignCenter) path = str(getattr(asset, "local_path", "") or "") image = QImage(path) if path and os.path.isfile(path) else QImage() if image.isNull(): image_label.setText("图片尚未下载或读取失败") image_label.setMinimumSize(420, 260) else: image_label.setPixmap(QPixmap.fromImage(image)) image_label.resize(image.size()) self.setWindowTitle( f"{self._title_for_asset(asset)} · {image.width()}x{image.height()}" ) scroll.setWidget(image_label) layout.addWidget(scroll, 1) buttons = QHBoxLayout() buttons.addStretch(1) close_button = QPushButton("关闭") close_button.clicked.connect(self.reject) buttons.addWidget(close_button) layout.addLayout(buttons) self.resize(720, 520) def _title_for_asset(self, asset): badge = _asset_badge(getattr(asset, "kind", "")) asset_id = getattr(asset, "id", "") return f"AI工场图片预览:{badge} #{asset_id}" class ImageStudioTab(QWidget): """Sixth tab: project-based AI image studio.""" PROJECT_COLUMNS = ["项目", "账号", "商品ID", "更新时间"] ORIGINAL_COLUMNS = ["序号", "状态", "远程地址"] POOL_COLUMNS = ["类型", "比例", "状态", "来源", "本地文件"] JOB_STATUS_LABELS = { "pending": "排队中", "submitted": "已提交", "running": "生成中", "succeeded": "成功", "failed": "失败", "expired": "已过期", "cancelled": "已停止", } def __init__( self, parent=None, db_path=None, config=None, config_path=None, status_callback=None, prompts_dir=None, ): super().__init__(parent) self.setObjectName("imageStudioTab") self.config = appconfig.load_config(config_path or appconfig.CONFIG_PATH) if config is None else config self.db_path = db_path or appconfig.db_path(self.config) self.config_path = config_path or self.config.get("config_path") or appconfig.CONFIG_PATH self.prompts_dir = prompts_dir or appconfig.image_studio_prompts_dir(self.config) self.cmhub_config_path = self.config.get("cmhub_config_path") or appconfig.cmhub_config_file_path(self.config) self.status_callback = status_callback self.projects = [] self.accounts = [] self.current_project = None self.assets = [] self.jobs = [] self.selections = [] self.selected_source_asset_id = None self._running_worker = None self._running_thread = None self._download_open_after = {} self._build_ui() self._connect_signals() self.refresh_accounts() self.refresh_templates() self.refresh_projects() def _build_ui(self): root = QVBoxLayout(self) root.setContentsMargins(10, 8, 10, 8) root.setSpacing(8) splitter = QSplitter(Qt.Horizontal) splitter.setObjectName("imageStudioMainSplitter") splitter.addWidget(self._build_project_panel()) splitter.addWidget(self._build_pool_panel()) splitter.addWidget(self._build_generation_panel()) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 3) splitter.setStretchFactor(2, 2) root.addWidget(splitter, 1) root.addWidget(self._build_final_panel(), 0) def _build_project_panel(self): panel = QWidget() panel.setObjectName("imageStudioProjectPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) project_form = QFormLayout() project_form.setLabelAlignment(Qt.AlignRight) self.account_combo = QComboBox() self.account_combo.setObjectName("imageStudioAccountCombo") self.item_id_edit = QLineEdit() self.item_id_edit.setObjectName("imageStudioItemIdEdit") self.item_id_edit.setPlaceholderText("商品ID") project_form.addRow("账号", self.account_combo) project_form.addRow("商品ID", self.item_id_edit) layout.addLayout(project_form) toolbar = QHBoxLayout() self.open_project_button = QPushButton("打开项目") self.open_project_button.setObjectName("imageStudioOpenProjectButton") self.pull_images_button = QPushButton("拉取主图") self.pull_images_button.setObjectName("imageStudioPullImagesButton") self.open_folder_button = QPushButton("打开项目文件夹") self.open_folder_button.setObjectName("imageStudioOpenFolderButton") toolbar.addWidget(self.open_project_button) toolbar.addWidget(self.pull_images_button) toolbar.addWidget(self.open_folder_button) layout.addLayout(toolbar) self.project_table = QTableWidget(0, len(self.PROJECT_COLUMNS)) self.project_table.setObjectName("imageStudioProjectList") self.project_table.setHorizontalHeaderLabels(self.PROJECT_COLUMNS) self.project_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.project_table.setSelectionMode(QAbstractItemView.SingleSelection) self.project_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.project_table.horizontalHeader().setStretchLastSection(True) self.project_table.verticalHeader().setVisible(False) layout.addWidget(self.project_table, 1) return panel def _build_pool_panel(self): panel = QWidget() panel.setObjectName("imageStudioPoolPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) original_header = QHBoxLayout() original_header.addWidget(QLabel("蝦皮原主图")) original_header.addStretch(1) self.original_hint_label = QLabel("单击下载并加入照片池,双击查看大图") self.original_hint_label.setObjectName("imageStudioOriginalHintLabel") original_header.addWidget(self.original_hint_label) layout.addLayout(original_header) self.original_table = QTableWidget(0, len(self.ORIGINAL_COLUMNS)) self.original_table.setObjectName("imageStudioOriginalTable") self.original_table.setHorizontalHeaderLabels(self.ORIGINAL_COLUMNS) self.original_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.original_table.setSelectionMode(QAbstractItemView.SingleSelection) self.original_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.original_table.horizontalHeader().setStretchLastSection(True) self.original_table.verticalHeader().setVisible(False) layout.addWidget(self.original_table, 1) pool_header = QHBoxLayout() pool_header.addWidget(QLabel("照片池")) pool_header.addStretch(1) self.source_label = QLabel("源图:未选择") self.source_label.setObjectName("imageStudioSourceLabel") pool_header.addWidget(self.source_label) layout.addLayout(pool_header) self.pool_table = QTableWidget(0, len(self.POOL_COLUMNS)) self.pool_table.setObjectName("imageStudioPoolTable") self.pool_table.setHorizontalHeaderLabels(self.POOL_COLUMNS) self.pool_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.pool_table.setSelectionMode(QAbstractItemView.SingleSelection) self.pool_table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.pool_table.setContextMenuPolicy(Qt.CustomContextMenu) self.pool_table.horizontalHeader().setStretchLastSection(True) self.pool_table.verticalHeader().setVisible(False) layout.addWidget(self.pool_table, 2) return panel def _build_generation_panel(self): panel = QWidget() panel.setObjectName("imageStudioGenerationPanel") layout = QVBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(8) template_layout = QGridLayout() self.template_combo = QComboBox() self.template_combo.setObjectName("imageStudioTemplateCombo") self.template_new_button = QPushButton("新建") self.template_new_button.setObjectName("imageStudioTemplateNewButton") self.template_rename_button = QPushButton("重命名") self.template_rename_button.setObjectName("imageStudioTemplateRenameButton") self.template_save_button = QPushButton("保存") self.template_save_button.setObjectName("imageStudioTemplateSaveButton") self.template_delete_button = QPushButton("删除") self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton") template_layout.addWidget(self.template_combo, 0, 0, 1, 2) template_layout.addWidget(self.template_new_button, 0, 2) template_layout.addWidget(self.template_rename_button, 1, 0) template_layout.addWidget(self.template_save_button, 1, 1) template_layout.addWidget(self.template_delete_button, 1, 2) layout.addLayout(template_layout) self.prompt_edit = QPlainTextEdit() self.prompt_edit.setObjectName("imageStudioPromptEdit") self.prompt_edit.setPlaceholderText("输入完整图片生成提示词") self.prompt_edit.setMinimumHeight(160) layout.addWidget(self.prompt_edit, 2) form = QFormLayout() self.job_type_combo = QComboBox() self.job_type_combo.setObjectName("imageStudioJobTypeCombo") self.job_type_combo.addItem("主图", "main") self.job_type_combo.addItem("详情图", "detail") self.count_spin = QSpinBox() self.count_spin.setObjectName("imageStudioCountSpin") self.count_spin.setRange(1, 12) self.count_spin.setValue(4) self.aspect_combo = QComboBox() self.aspect_combo.setObjectName("imageStudioAspectCombo") for value in ("1:1", "3:4", "4:3", "9:16", "16:9"): self.aspect_combo.addItem(value, value) form.addRow("类型", self.job_type_combo) form.addRow("数量", self.count_spin) form.addRow("比例", self.aspect_combo) layout.addLayout(form) self.billing_label = QLabel("cmhub 托管模型:扣点以返回结果为准") self.billing_label.setObjectName("imageStudioBillingLabel") self.billing_label.setWordWrap(True) layout.addWidget(self.billing_label) action_layout = QHBoxLayout() self.start_button = QPushButton("开始生成") self.start_button.setObjectName("imageStudioStartButton") self.stop_button = QPushButton("停止") self.stop_button.setObjectName("imageStudioStopButton") self.stop_button.setEnabled(False) action_layout.addWidget(self.start_button) action_layout.addWidget(self.stop_button) layout.addLayout(action_layout) self.progress_bar = QProgressBar() self.progress_bar.setObjectName("imageStudioProgressBar") self.progress_bar.setRange(0, 1) self.progress_bar.setValue(0) layout.addWidget(self.progress_bar) self.log_view = QPlainTextEdit() self.log_view.setObjectName("imageStudioLogView") self.log_view.setReadOnly(True) self.log_view.setPlaceholderText("运行日志会在开始后显示") layout.addWidget(self.log_view, 1) return panel def _build_final_panel(self): panel = QWidget() panel.setObjectName("imageStudioFinalPanel") layout = QHBoxLayout(panel) layout.setContentsMargins(0, 0, 0, 0) self.main_selection_label = QLabel("主图终选 0/9(拖放排序将在 T-592 接入)") self.main_selection_label.setObjectName("imageStudioMainSelectionLabel") self.detail_selection_label = QLabel("详情图终选 0/12(拖放排序将在 T-592 接入)") self.detail_selection_label.setObjectName("imageStudioDetailSelectionLabel") layout.addWidget(self.main_selection_label) layout.addWidget(self.detail_selection_label) layout.addStretch(1) return panel def _connect_signals(self): self.open_project_button.clicked.connect(self.open_project) self.pull_images_button.clicked.connect(self.pull_main_images) self.open_folder_button.clicked.connect(self.open_project_folder) self.project_table.itemSelectionChanged.connect(self._on_project_selection_changed) self.original_table.cellClicked.connect(self._on_original_clicked) self.original_table.cellDoubleClicked.connect(self._on_original_double_clicked) self.pool_table.cellClicked.connect(self._on_pool_clicked) self.pool_table.cellDoubleClicked.connect(self._on_pool_double_clicked) self.pool_table.customContextMenuRequested.connect(self._show_pool_context_menu) self.template_combo.currentIndexChanged.connect(self.load_selected_template) self.template_new_button.clicked.connect(self.create_template) self.template_rename_button.clicked.connect(self.rename_template) self.template_save_button.clicked.connect(self.save_template) self.template_delete_button.clicked.connect(self.delete_template) 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) def refresh_accounts(self): self.account_combo.clear() try: self.accounts = accounts.list_accounts(path=self.db_path, config=self.config) except Exception as exc: self.accounts = [] self._status(f"账号读取失败:{exc}", "danger") for account in self.accounts: self.account_combo.addItem( f"{account.account_name}({account.alias})", account.alias, ) if not self.accounts: self.account_combo.addItem("暂无账号,请先到④账号管理添加", "") def refresh_projects(self): try: db.init_db(self.db_path) self.projects = image_studio.list_projects(path=self.db_path) except Exception as exc: self.projects = [] self._status(f"AI工场项目读取失败:{exc}", "danger") self._fill_project_table() if self.current_project is None and self.projects: self._select_project(self.projects[0].id) elif self.current_project is not None: self._select_project(self.current_project.id, quiet=True) def _fill_project_table(self): self.project_table.setRowCount(len(self.projects)) for row, project in enumerate(self.projects): values = [ f"{project.account_alias} / {project.item_id}", project.account_name or project.account_alias, project.item_id, project.updated_at, ] for column, value in enumerate(values): item = QTableWidgetItem(str(value or "")) item.setData(Qt.UserRole, int(project.id)) self.project_table.setItem(row, column, item) self.project_table.resizeColumnsToContents() def open_project(self, checked=False): alias = str(self.account_combo.currentData() or "").strip() item_id = self.item_id_edit.text().strip() if not alias: self._message("账号未选择", "请先在④账号管理添加并选择账号。") return if not item_id: self._message("商品ID不能为空", "请输入要打开的蝦皮商品ID。") return account = accounts.get_account(alias, path=self.db_path, config=self.config) try: project = image_studio.create_or_get_project( account, item_id=item_id, path=self.db_path, ) except Exception as exc: self._message("打开项目失败", str(exc)) self._status(f"打开AI工场项目失败:{exc}", "danger") return self.current_project = project self.item_id_edit.setText(project.item_id) self._set_account_combo(project.account_alias) self.refresh_projects() self._select_project(project.id) self._status("AI工场项目已打开", "success") def pull_main_images(self, checked=False): alias = str(self.account_combo.currentData() or "").strip() item_id = self.item_id_edit.text().strip() if self.current_project is not None: alias = self.current_project.account_alias item_id = self.current_project.item_id if not alias or not item_id: self._message("项目未打开", "请先选择账号和商品ID并打开项目。") return worker = ImageStudioPullImagesWorker( alias, item_id, db_path=self.db_path, config=self.config, ) worker.log.connect(self._append_log) worker.finished.connect(self._on_pull_finished) worker.failed.connect(self._on_worker_failed) self._start_worker(worker, "AI工场拉取主图") self._append_log("[AI工场] 拉取主图开始") def _on_pull_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 project = summary.get("project") if project is not None: self.current_project = project self._set_account_combo(project.account_alias) self.item_id_edit.setText(project.item_id) self._finish_worker() self.refresh_projects() if project is not None: self._select_project(project.id) self._status(f"已拉取 {summary.get('count', 0)} 张蝦皮原主图", "success") def open_project_folder(self, checked=False): if self.current_project is None: self._message("项目未打开", "请先打开一个AI工场项目。") return dirs = image_studio.default_project_image_dirs(self.current_project, self.config) try: os.makedirs(dirs["root"], exist_ok=True) opened = file_manager.open_in_file_manager(dirs["root"]) except Exception as exc: self._message("打开项目文件夹失败", str(exc)) self._status(f"打开项目文件夹失败:{exc}", "warning") return self._status(f"已打开项目文件夹:{opened}", "success") def _on_project_selection_changed(self): items = self.project_table.selectedItems() if not items: return project_id = items[0].data(Qt.UserRole) if project_id is not None: self._select_project(project_id) def _select_project(self, project_id, quiet=False): try: project = image_studio.get_project(project_id, path=self.db_path) except Exception as exc: self._status(f"读取AI工场项目失败:{exc}", "danger") return if project is None: return self.current_project = project self._set_account_combo(project.account_alias) self.item_id_edit.setText(project.item_id) self.prompt_edit.blockSignals(True) try: self.prompt_edit.setPlainText(project.draft_prompt or "") finally: self.prompt_edit.blockSignals(False) self.selected_source_asset_id = None self.refresh_project_assets() self._sync_project_selection(project.id) if not quiet: self._status(f"当前AI工场项目:{project.account_alias} / {project.item_id}", "muted") def _sync_project_selection(self, project_id): for row in range(self.project_table.rowCount()): item = self.project_table.item(row, 0) if item is not None and item.data(Qt.UserRole) == int(project_id): if not self.project_table.item(row, 0).isSelected(): self.project_table.selectRow(row) break def refresh_project_assets(self): if self.current_project is None: self.assets = [] self.jobs = [] self.selections = [] else: self.assets = image_studio.list_assets(self.current_project.id, path=self.db_path) self.jobs = self._list_project_jobs(self.current_project.id) self.selections = image_studio.list_selections(self.current_project.id, path=self.db_path) self._fill_original_table() self._fill_pool_table() self._refresh_selection_labels() self._refresh_source_label() def _fill_original_table(self): originals = [asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL] self.original_table.setRowCount(len(originals)) for row, asset in enumerate(originals): values = [ str(asset.source_order or row + 1), _asset_status_text(asset), asset.remote_url or "", ] for column, value in enumerate(values): item = QTableWidgetItem(str(value or "")) item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)}) self.original_table.setItem(row, column, item) self.original_table.resizeColumnsToContents() def _fill_pool_table(self): rows = [] for asset in self.assets: if asset.status == image_studio.ASSET_STATUS_MISSING: continue if asset.kind not in {"original", "generated_main", "generated_detail"}: continue rows.append(("asset", asset)) for job in self.jobs: if job.status in {"pending", "submitted", "running", "failed", "expired", "cancelled"}: rows.append(("job", job)) self.pool_table.setRowCount(len(rows)) for row, (row_type, obj) in enumerate(rows): if row_type == "asset": values = [ _asset_badge(obj.kind), obj.aspect_ratio or "未知", _asset_status_text(obj), _source_text(obj, self.assets), obj.local_path or "", ] data = {"type": "asset", "asset_id": int(obj.id)} else: values = [ "任务", "-", self.JOB_STATUS_LABELS.get(obj.status, obj.status), f"源图 #{obj.source_asset_id or '-'}", obj.error or "", ] data = {"type": "job", "job_id": int(obj.id)} for column, value in enumerate(values): item = QTableWidgetItem(str(value or "")) item.setData(Qt.UserRole, data) self.pool_table.setItem(row, column, item) self.pool_table.resizeColumnsToContents() def _on_original_clicked(self, row, column): asset = self._asset_from_table_row(self.original_table, row) if asset is not None: self._ensure_original_in_pool(asset, open_after=False) def _on_original_double_clicked(self, row, column): asset = self._asset_from_table_row(self.original_table, row) if asset is not None: self._ensure_original_in_pool(asset, open_after=True) def _on_pool_clicked(self, row, column): data = self._row_data(self.pool_table, row) if not data or data.get("type") != "asset": return asset = self._asset_by_id(data.get("asset_id")) if asset is not None: self._select_source_asset(asset) def _on_pool_double_clicked(self, row, column): data = self._row_data(self.pool_table, row) if not data or data.get("type") != "asset": return asset = self._asset_by_id(data.get("asset_id")) if asset is not None: self._open_preview(asset) def _ensure_original_in_pool(self, asset, open_after=False): if str(asset.local_path or "").strip() and os.path.isfile(asset.local_path): self._select_source_asset(asset) if open_after: self._open_preview(asset) return worker = ImageStudioDownloadOriginalWorker( asset.id, db_path=self.db_path, config=self.config, open_after=open_after, ) worker.log.connect(self._append_log) worker.finished.connect(self._on_download_finished) worker.failed.connect(self._on_worker_failed) self._start_worker(worker, "AI工场下载原图") def _on_download_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 asset = summary.get("asset") self._finish_worker() self.refresh_project_assets() if asset is not None: refreshed = self._asset_by_id(asset.id) or asset self._select_source_asset(refreshed) if summary.get("open_after"): self._open_preview(refreshed) self._status("原图已加入照片池", "success") def _select_source_asset(self, asset): self.selected_source_asset_id = int(asset.id) self._refresh_source_label() self._status(f"已选择源图:{_asset_badge(asset.kind)} #{asset.id}", "success") def _refresh_source_label(self): asset = self._asset_by_id(self.selected_source_asset_id) if asset is None: self.source_label.setText("源图:未选择") return self.source_label.setText(f"源图:{_asset_badge(asset.kind)} #{asset.id}") def _open_preview(self, asset): dialog = ImageStudioPreviewDialog(asset, self) dialog.exec() def _show_pool_context_menu(self, position): row = self.pool_table.rowAt(position.y()) data = self._row_data(self.pool_table, row) if not data or data.get("type") != "asset": return asset_id = data.get("asset_id") menu = QMenu(self) try: counts = image_studio.asset_reference_counts(asset_id, path=self.db_path) referenced = bool(counts.get("total")) except Exception: referenced = True remove_action = menu.addAction( "移除照片" if not referenced else "移除照片(已被任务或终选引用)" ) remove_action.setEnabled(not referenced) action = menu.exec(self.pool_table.viewport().mapToGlobal(position)) if action is remove_action and not referenced: self.remove_asset(asset_id) def remove_asset(self, asset_id): if not self._confirm("移除照片", "只从AI工场照片池移除记录,不删除本地图片文件。"): return try: image_studio.remove_asset_if_unused(asset_id, path=self.db_path) except Exception as exc: self._message("不能移除照片", str(exc)) return if self.selected_source_asset_id == int(asset_id): self.selected_source_asset_id = None self.refresh_project_assets() self._status("照片已从池中移除", "success") def refresh_templates(self, selected=None): current = selected or self.template_combo.currentData() self.template_combo.blockSignals(True) try: self.template_combo.clear() self.template_combo.addItem("选择模板", "") for name in prompts.list_image_studio_templates(self.prompts_dir): self.template_combo.addItem(name, name) if current: index = self.template_combo.findData(current) if index >= 0: self.template_combo.setCurrentIndex(index) finally: self.template_combo.blockSignals(False) def load_selected_template(self, index=None): name = self.template_combo.currentData() if not name: return try: self.prompt_edit.setPlainText(prompts.load_image_studio_template(name, self.prompts_dir)) except Exception as exc: self._message("加载模板失败", str(exc)) def create_template(self, checked=False): name, ok = QInputDialog.getText(self, "新建模板", "模板名称") if not ok: return try: prompts.save_image_studio_template(name, self.prompt_edit.toPlainText(), self.prompts_dir) except Exception as exc: self._message("新建模板失败", str(exc)) return self.refresh_templates(selected=name) self._status("AI工场模板已新建", "success") def rename_template(self, checked=False): old = self.template_combo.currentData() if not old: self._message("未选择模板", "请先选择要重命名的模板。") return new, ok = QInputDialog.getText(self, "重命名模板", "新模板名称", text=old) if not ok: return try: prompts.rename_image_studio_template(old, new, self.prompts_dir) except Exception as exc: self._message("重命名模板失败", str(exc)) return self.refresh_templates(selected=new) self._status("AI工场模板已重命名", "success") def save_template(self, checked=False): name = self.template_combo.currentData() if not name: name, ok = QInputDialog.getText(self, "保存模板", "模板名称") if not ok: return try: prompts.save_image_studio_template(name, self.prompt_edit.toPlainText(), self.prompts_dir) except Exception as exc: self._message("保存模板失败", str(exc)) return self.refresh_templates(selected=name) self._status("AI工场模板已保存", "success") def delete_template(self, checked=False): name = self.template_combo.currentData() if not name: self._message("未选择模板", "请先选择要删除的模板。") return if not self._confirm("删除模板", f"确定删除模板「{name}」吗?"): return try: prompts.delete_image_studio_template(name, self.prompts_dir) except Exception as exc: self._message("删除模板失败", str(exc)) return self.refresh_templates() self._status("AI工场模板已删除", "success") def _save_project_prompt(self): if self.current_project is None: return try: self.current_project = image_studio.update_project_prompt( self.current_project.id, self.prompt_edit.toPlainText(), path=self.db_path, ) except Exception as exc: self._status(f"保存AI工场草稿提示词失败:{exc}", "warning") def start_generation(self, checked=False): if self.current_project is None: self._message("项目未打开", "请先打开一个AI工场项目。") return source = self._asset_by_id(self.selected_source_asset_id) if source is None: self._message("未选择源图", "请先在照片池单击选择一张源图。") return if not str(source.local_path or "").strip() or not os.path.isfile(source.local_path): self._message("源图不可用", "请先单击蝦皮原主图下载到本地后再生成。") return prompt_text = self.prompt_edit.toPlainText().strip() if not prompt_text: self._message("提示词不能为空", "请输入完整图片生成提示词。") return count = self.count_spin.value() self.progress_bar.setRange(0, count) self.progress_bar.setValue(0) self.log_view.clear() self._append_log(f"[AI工场] 本轮生图开始:{count} 张,来源 cmhub 托管模型") worker = ImageStudioGenerateJobsWorker( self.current_project.id, source.id, prompt_text, count, job_type=self.job_type_combo.currentData(), aspect_ratio=self.aspect_combo.currentData(), db_path=self.db_path, config=self.config, cmhub_config_path=self.cmhub_config_path, ) worker.progress.connect(self._on_generate_progress) worker.log.connect(self._append_log) worker.finished.connect(self._on_generation_finished) worker.failed.connect(self._on_worker_failed) self._start_worker(worker, "AI工场生成图片") def stop_generation(self, checked=False): if self._running_worker is not None and hasattr(self._running_worker, "cancel"): self._running_worker.cancel() self._append_log("[AI工场] 已请求停止,正在等待安全边界") self._status("AI工场生成已请求停止", "warning") 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)) self.progress_bar.setRange(0, total) self.progress_bar.setValue(done) if payload.get("points_balance") is not None: text = f"cmhub 托管模型:余额 {payload.get('points_balance')}" if payload.get("points_cost") is not None: text += f",本张扣点 {payload.get('points_cost')}" self.billing_label.setText(text) def _on_generation_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 "AI工场生成失败") return self._finish_worker() self.refresh_project_assets() total = int(summary.get("total") or 0) success = int(summary.get("success") or 0) failed = int(summary.get("failed") or 0) cancelled = int(summary.get("cancelled") or 0) self._append_log(f"[AI工场] 本轮完成:总数{total},成功{success},失败{failed},停止{cancelled}") level = "warning" if failed or cancelled else "success" self._status(f"AI工场生成完成:成功{success},失败{failed},停止{cancelled}", level) def _on_worker_failed(self, row, error): self._finish_worker() message = diagnostics.redact_log_text(str(error or "未知错误")) self._append_log(f"[AI工场] 失败:{message}") self._status(f"AI工场任务失败:{message}", "danger") self._message("AI工场任务失败", message) self.refresh_project_assets() def _start_worker(self, worker, thread_name): self._set_running(True) self._running_worker = worker self._running_thread = run_worker(worker, thread_name) def _finish_worker(self): self._running_worker = None self._running_thread = None self._set_running(False) def _set_running(self, running): self.open_project_button.setEnabled(not running) self.pull_images_button.setEnabled(not running) self.open_folder_button.setEnabled(not running) self.project_table.setEnabled(not running) self.original_table.setEnabled(not running) self.pool_table.setEnabled(not running) self.template_combo.setEnabled(not running) self.template_new_button.setEnabled(not running) self.template_rename_button.setEnabled(not running) self.template_save_button.setEnabled(not running) self.template_delete_button.setEnabled(not running) self.prompt_edit.setEnabled(not running) self.job_type_combo.setEnabled(not running) self.count_spin.setEnabled(not running) self.aspect_combo.setEnabled(not running) self.start_button.setEnabled(not running) self.stop_button.setEnabled(running) def _set_account_combo(self, alias): index = self.account_combo.findData(alias) if index >= 0: self.account_combo.setCurrentIndex(index) def _list_project_jobs(self, project_id): conn = db.connect(self.db_path) try: rows = conn.execute( """ SELECT * FROM image_studio_jobs WHERE project_id = ? ORDER BY updated_at DESC, id DESC """, (int(project_id),), ).fetchall() return [image_studio.ImageStudioJob(**dict(row)) for row in rows] finally: conn.close() def _refresh_selection_labels(self): main_count = sum(1 for item in self.selections if item.selection_type == "main") detail_count = sum(1 for item in self.selections if item.selection_type == "detail") main_target = getattr(self.current_project, "target_main_count", 9) if self.current_project else 9 detail_target = getattr(self.current_project, "target_detail_count", 12) if self.current_project else 12 self.main_selection_label.setText(f"主图终选 {main_count}/{main_target}(拖放排序将在 T-592 接入)") self.detail_selection_label.setText(f"详情图终选 {detail_count}/{detail_target}(拖放排序将在 T-592 接入)") def _asset_from_table_row(self, table, row): data = self._row_data(table, row) if not data or data.get("type") != "asset": return None return self._asset_by_id(data.get("asset_id")) def _row_data(self, table, row): if row < 0 or row >= table.rowCount(): return None item = table.item(row, 0) if item is None: return None return item.data(Qt.UserRole) def _asset_by_id(self, asset_id): if asset_id is None: return None for asset in self.assets: if int(asset.id) == int(asset_id): return asset try: return image_studio.get_asset(asset_id, path=self.db_path) except Exception: return None def _append_log(self, message): text = diagnostics.redact_log_text(str(message or "")) self.log_view.appendPlainText(text) scrollbar = self.log_view.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) def _message(self, title, text): box = QMessageBox(self) box.setWindowTitle(str(title or "提示")) box.setText(str(text or "")) ok_button = box.addButton("确定", QMessageBox.AcceptRole) box.setDefaultButton(ok_button) box.exec() def _confirm(self, title, text): box = QMessageBox(self) box.setWindowTitle(str(title or "确认")) box.setText(str(text or "")) yes_button = box.addButton("确定", QMessageBox.AcceptRole) box.addButton("取消", QMessageBox.RejectRole) box.setDefaultButton(yes_button) box.exec() return box.clickedButton() is yes_button def _status(self, message, level="muted"): _emit_status(self.status_callback, message, level=level) def _asset_badge(kind): return { "original": "原图", "generated_main": "主图", "generated_detail": "详情图", }.get(str(kind or ""), str(kind or "图片")) def _asset_status_text(asset): status = str(getattr(asset, "status", "") or "") local_path = str(getattr(asset, "local_path", "") or "") if status == image_studio.ASSET_STATUS_MISSING: return "文件缺失" if local_path and os.path.isfile(local_path): return "可用" if getattr(asset, "remote_url", None): return "远程待下载" return "待生成" def _source_text(asset, assets): parent_id = getattr(asset, "parent_asset_id", None) if not parent_id: return "原始来源" for item in assets: if int(item.id) == int(parent_id): return f"{_asset_badge(item.kind)} #{item.id}" return f"源图 #{parent_id}"