feat(ai-studio): add thumbnail workspace grids

This commit is contained in:
chengma
2026-07-11 16:47:18 +08:00
parent 82e7e1bf14
commit e4292f8a0e
3 changed files with 467 additions and 168 deletions
+306 -152
View File
@@ -4,10 +4,10 @@ from __future__ import annotations
import os
from PySide6.QtCore import QMimeData, QSize
from PySide6.QtWidgets import QListWidget, QListWidgetItem
from PySide6.QtCore import QObject, QMimeData, QSize, Signal
from PySide6.QtWidgets import QListView, QListWidget, QListWidgetItem
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, prompts
from ... import accounts, appconfig, cmhub_models, db, image_studio, image_studio_export, image_studio_images, prompts
from .. import file_manager
from ..widgets import *
from ..workers import (
@@ -83,24 +83,38 @@ def _mime_asset_id(mime_data):
return None
class ImageStudioPoolTable(QTableWidget):
"""Photo pool table that can drag available asset IDs into final trays."""
class _ThumbnailSignals(QObject):
"""Deliver executor callbacks to the GUI thread without exposing remote URLs."""
def __init__(self, parent=None):
loaded = Signal(object)
failed = Signal(object)
class ImageStudioThumbnailGrid(QListWidget):
"""Stable icon grid for original images, pool assets and job cards."""
def __init__(self, *, draggable_assets=False, parent=None):
super().__init__(parent)
self.setDragEnabled(True)
self.setDragDropMode(QAbstractItemView.DragOnly)
self._draggable_assets = bool(draggable_assets)
self.setViewMode(QListView.IconMode)
self.setFlow(QListView.LeftToRight)
self.setWrapping(True)
self.setResizeMode(QListView.Adjust)
self.setMovement(QListView.Static)
self.setSpacing(6)
self.setUniformItemSizes(True)
self.setSelectionMode(QAbstractItemView.SingleSelection)
self.setDragEnabled(self._draggable_assets)
self.setDragDropMode(
QAbstractItemView.DragOnly if self._draggable_assets else QAbstractItemView.NoDragDrop
)
self.setDefaultDropAction(Qt.CopyAction)
def mimeData(self, items):
mime = QMimeData()
if not items:
if not self._draggable_assets or not items:
return mime
row = items[0].row()
item = self.item(row, 0)
if item is None:
return mime
data = item.data(Qt.UserRole) or {}
data = items[0].data(Qt.UserRole) or {}
if data.get("type") != "asset" or not data.get("draggable"):
return mime
mime.setData(ASSET_MIME_TYPE, str(data.get("asset_id")).encode("utf-8"))
@@ -239,8 +253,6 @@ class ImageStudioTab(QWidget):
"""Sixth tab: project-based AI image studio."""
PROJECT_COLUMNS = ["店铺", "商品ID", "更新时间"]
ORIGINAL_COLUMNS = ["序号", "状态", "远程地址"]
POOL_COLUMNS = ["类型", "比例", "状态", "来源", "本地文件"]
JOB_STATUS_LABELS = {
"pending": "排队中",
"submitted": "已提交",
@@ -259,6 +271,7 @@ class ImageStudioTab(QWidget):
config_path=None,
status_callback=None,
prompts_dir=None,
thumbnail_loader=None,
):
super().__init__(parent)
self.setObjectName("imageStudioTab")
@@ -280,6 +293,14 @@ class ImageStudioTab(QWidget):
self._operation_running = False
self._worker_error_handled = False
self._download_open_after = {}
self._thumbnail_loader = thumbnail_loader or image_studio_images.ThumbnailLoader()
self._thumbnail_pixmaps = {}
self._thumbnail_pending = set()
self._thumbnail_errors = set()
self._thumbnail_project_id = None
self._thumbnail_signals = _ThumbnailSignals(self)
self._thumbnail_signals.loaded.connect(self._on_thumbnail_loaded)
self._thumbnail_signals.failed.connect(self._on_thumbnail_failed)
self._build_ui()
self._connect_signals()
@@ -397,17 +418,14 @@ class ImageStudioTab(QWidget):
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)
self.original_table.setIconSize(QSize(72, 72))
self.original_table.setAlternatingRowColors(False)
layout.addWidget(self.original_table, 1)
self.original_grid = ImageStudioThumbnailGrid(parent=self)
self.original_grid.setObjectName("imageStudioOriginalGrid")
self.original_grid.setIconSize(QSize(62, 62))
self.original_grid.setGridSize(QSize(78, 92))
self.original_grid.setMinimumHeight(174)
self.original_grid.setMaximumHeight(202)
self.original_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.original_grid, 0)
pool_header = QHBoxLayout()
pool_title = QLabel("照片池")
@@ -419,19 +437,12 @@ class ImageStudioTab(QWidget):
pool_header.addWidget(self.source_label)
layout.addLayout(pool_header)
self.pool_table = ImageStudioPoolTable()
self.pool_table.setColumnCount(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)
self.pool_table.setIconSize(QSize(86, 86))
self.pool_table.setAlternatingRowColors(False)
layout.addWidget(self.pool_table, 2)
self.pool_grid = ImageStudioThumbnailGrid(draggable_assets=True, parent=self)
self.pool_grid.setObjectName("imageStudioPoolGrid")
self.pool_grid.setIconSize(QSize(86, 86))
self.pool_grid.setGridSize(QSize(108, 124))
self.pool_grid.setContextMenuPolicy(Qt.CustomContextMenu)
layout.addWidget(self.pool_grid, 2)
return panel
def _build_generation_panel(self):
@@ -450,14 +461,11 @@ class ImageStudioTab(QWidget):
self.source_preview_label.setObjectName("imageStudioSourcePreview")
self.source_preview_label.setAlignment(Qt.AlignCenter)
self.source_preview_label.setMinimumSize(86, 86)
source_note = QLabel("当前源图\n单击照片池图片设为源图,双击查看大图")
source_note.setObjectName("imageStudioMutedLabel")
source_note.setWordWrap(True)
source_preview_tooltip = "当前源图。请在照片池单击选择源图,双击可查看大图。"
self.source_preview_label.setToolTip(source_preview_tooltip)
source_row.addWidget(self.source_preview_label, 0)
source_row.addWidget(source_note, 1)
layout.addLayout(source_row)
template_layout = QGridLayout()
template_layout = QVBoxLayout()
template_layout.setSpacing(6)
self.template_combo = QComboBox()
self.template_combo.setObjectName("imageStudioTemplateCombo")
@@ -471,25 +479,32 @@ class ImageStudioTab(QWidget):
self.template_delete_button.setObjectName("imageStudioTemplateDeleteButton")
template_label = QLabel("提示词模板")
template_label.setObjectName("imageStudioMutedLabel")
template_layout.addWidget(template_label, 0, 0, 1, 3)
template_layout.addWidget(self.template_combo, 1, 0, 1, 3)
template_layout.addWidget(self.template_new_button, 2, 0)
template_layout.addWidget(self.template_rename_button, 2, 1)
template_layout.addWidget(self.template_save_button, 2, 2)
template_layout.addWidget(self.template_delete_button, 3, 2)
layout.addLayout(template_layout)
template_select_row = QHBoxLayout()
template_select_row.addWidget(template_label)
template_select_row.addWidget(self.template_combo, 1)
template_actions = QHBoxLayout()
for button in (
self.template_new_button,
self.template_rename_button,
self.template_save_button,
self.template_delete_button,
):
template_actions.addWidget(button, 1)
template_layout.addLayout(template_select_row)
template_layout.addLayout(template_actions)
source_row.addLayout(template_layout, 1)
layout.addLayout(source_row)
self.prompt_edit = QPlainTextEdit()
self.prompt_edit.setObjectName("imageStudioPromptEdit")
self.prompt_edit.setPlaceholderText("输入完整图片生成提示词")
self.prompt_edit.setMinimumHeight(160)
layout.addWidget(self.prompt_edit, 2)
self.prompt_edit.setMinimumHeight(210)
layout.addWidget(self.prompt_edit, 3)
prompt_hint = QLabel("程序原样提交完整提示词,不自动拆分,也不自动追加动作词。")
prompt_hint.setObjectName("imageStudioMutedLabel")
prompt_hint.setWordWrap(True)
layout.addWidget(prompt_hint)
form = QFormLayout()
self.job_type_combo = QComboBox()
self.job_type_combo.setObjectName("imageStudioJobTypeCombo")
self.job_type_combo.addItem("主图", "main")
@@ -502,10 +517,18 @@ class ImageStudioTab(QWidget):
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)
generation_form = QHBoxLayout()
generation_form.setSpacing(8)
for label, field in (
("类型", self.job_type_combo),
("数量", self.count_spin),
("比例", self.aspect_combo),
):
group = QHBoxLayout()
group.addWidget(QLabel(label))
group.addWidget(field, 1)
generation_form.addLayout(group, 1)
layout.addLayout(generation_form)
self.billing_label = QLabel("cmhub 托管默认档:扣点以返回结果为准")
self.billing_label.setObjectName("imageStudioBillingLabel")
@@ -638,8 +661,8 @@ class ImageStudioTab(QWidget):
#imageStudioDeleteProjectButton {
color: #b42318;
}
QTableWidget#imageStudioOriginalTable,
QTableWidget#imageStudioPoolTable,
QListWidget#imageStudioOriginalGrid,
QListWidget#imageStudioPoolGrid,
QListWidget#imageStudioMainSelectionList,
QListWidget#imageStudioDetailSelectionList {
border: 1px solid #d8dee4;
@@ -657,11 +680,12 @@ class ImageStudioTab(QWidget):
self.open_folder_button.clicked.connect(self.open_project_folder)
self.delete_project_button.clicked.connect(self.delete_current_project)
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.original_grid.itemClicked.connect(self._on_original_clicked)
self.original_grid.itemDoubleClicked.connect(self._on_original_double_clicked)
self.original_grid.customContextMenuRequested.connect(self._show_original_context_menu)
self.pool_grid.itemClicked.connect(self._on_pool_clicked)
self.pool_grid.itemDoubleClicked.connect(self._on_pool_double_clicked)
self.pool_grid.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)
@@ -821,6 +845,9 @@ class ImageStudioTab(QWidget):
return
if project is None:
return
previous_project_id = getattr(self.current_project, "id", None)
if previous_project_id != project.id:
self._reset_thumbnail_view(project.id)
self.current_project = project
self._set_account_combo(project.account_alias)
self.item_id_edit.setText(project.item_id)
@@ -854,88 +881,77 @@ class ImageStudioTab(QWidget):
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._refresh_project_summary()
self._fill_original_table()
self._fill_pool_table()
self._fill_original_grid()
self._fill_pool_grid()
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))
def _fill_original_grid(self):
originals = sorted(
[asset for asset in self.assets if asset.kind == image_studio.ASSET_KIND_ORIGINAL],
key=lambda asset: (int(asset.source_order or 0), int(asset.id)),
)
self.original_grid.clear()
for row, asset in enumerate(originals):
order = asset.source_order or row + 1
values = [
f"主图 #{order}",
_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)})
if column == 0:
item.setIcon(_asset_icon(asset, "原"))
item.setToolTip("单击下载并加入照片池,双击查看大图")
self.original_table.setItem(row, column, item)
self.original_table.setRowHeight(row, 84)
if originals:
self.original_table.setColumnWidth(0, 132)
self.original_table.setColumnWidth(1, 96)
self.original_table.resizeColumnsToContents()
item = QListWidgetItem(f"主图 {order}\n{self._original_thumbnail_state(asset)}")
item.setData(Qt.UserRole, {"type": "asset", "asset_id": int(asset.id)})
item.setIcon(
_asset_icon(
asset,
"原",
size=QSize(62, 62),
cached_pixmap=self._thumbnail_pixmaps.get(int(asset.id)),
)
)
item.setSizeHint(QSize(78, 92))
tooltip = "单击下载并加入照片池,双击查看大图。缩略图仅用于预览,不保存原图。"
if int(asset.id) in self._thumbnail_errors:
tooltip += "\n缩略图加载失败,可右键重新加载,或直接单击下载原图。"
item.setToolTip(tooltip)
self.original_grid.addItem(item)
self._queue_original_thumbnails(originals)
for row, asset in enumerate(originals):
item = self.original_grid.item(row)
if item is not None:
order = asset.source_order or row + 1
item.setText(f"主图 {order}\n{self._original_thumbnail_state(asset)}")
def _fill_pool_table(self):
def _fill_pool_grid(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"}:
if asset.kind not in {"original", "generated_main", "generated_detail"} or not _asset_is_usable(asset):
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):
self.pool_grid.clear()
for row_type, obj in rows:
if row_type == "asset":
draggable = _asset_is_usable(obj)
values = [
f"{_asset_badge(obj.kind)} #{obj.id}",
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), "draggable": draggable}
text = f"{_asset_badge(obj.kind)} #{obj.id}\n{obj.aspect_ratio or '比例未知'}"
tooltip = "单击设为源图,双击查看大图,可拖入终选槽。"
else:
values = [
"任务",
"-",
_job_status_text(obj, self.JOB_STATUS_LABELS),
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)
if row_type == "asset" and column == 0:
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind)))
item.setToolTip("单击设为源图,双击查看大图,可拖入终选槽。")
if row_type == "job" and column == 0:
item.setIcon(_job_icon(obj.status))
item.setToolTip("cmhub 生图任务已保存,可继续查询。")
if row_type == "asset" and obj.id == self.selected_source_asset_id:
text = f"任务\n{_job_status_text(obj, self.JOB_STATUS_LABELS)}"
tooltip = "生图任务已保存,可继续查询。"
item = QListWidgetItem(text)
item.setData(Qt.UserRole, data)
item.setSizeHint(QSize(108, 124))
item.setToolTip(tooltip)
if row_type == "asset":
item.setIcon(_asset_icon(obj, _asset_badge(obj.kind), size=QSize(86, 86)))
if obj.id == self.selected_source_asset_id:
item.setBackground(QColor("#eaf2ff"))
if row_type == "job" and obj.status in {"failed", "expired"}:
else:
item.setIcon(_job_icon(obj.status))
if obj.status in {"failed", "expired"}:
item.setForeground(_qcolor(COLOR_DANGER))
elif row_type == "job" and obj.status in {"pending", "submitted", "running"}:
elif obj.status in {"pending", "submitted", "running"}:
item.setForeground(_qcolor(COLOR_WARNING))
self.pool_table.setItem(row, column, item)
self.pool_table.setRowHeight(row, 104)
if rows:
self.pool_table.setColumnWidth(0, 150)
self.pool_table.setColumnWidth(1, 68)
self.pool_table.setColumnWidth(2, 170)
self.pool_table.resizeColumnsToContents()
self.pool_grid.addItem(item)
def _selection_asset_ids(self, selection_type):
return [
@@ -1008,32 +1024,43 @@ class ImageStudioTab(QWidget):
return int(self.current_project.target_detail_count or 12)
return int(self.current_project.target_main_count or 9)
def _on_original_clicked(self, row, column):
asset = self._asset_from_table_row(self.original_table, row)
def _on_original_clicked(self, item):
asset = self._asset_from_grid_item(item)
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)
def _on_original_double_clicked(self, item):
asset = self._asset_from_grid_item(item)
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)
def _on_pool_clicked(self, item):
data = self._grid_data(item)
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)
def _on_pool_double_clicked(self, item):
data = self._grid_data(item)
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 _show_original_context_menu(self, position):
item = self.original_grid.itemAt(position)
asset = self._asset_from_grid_item(item)
if asset is None or int(asset.id) not in self._thumbnail_errors:
return
menu = QMenu(self)
retry_action = menu.addAction("重新加载缩略图")
action = menu.exec(self.original_grid.viewport().mapToGlobal(position))
if action is retry_action:
self._retry_original_thumbnail(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)
@@ -1097,6 +1124,7 @@ class ImageStudioTab(QWidget):
self._update_project_action_buttons()
def _clear_current_project(self):
self._reset_thumbnail_view(None)
self.current_project = None
self.assets = []
self.jobs = []
@@ -1108,8 +1136,8 @@ class ImageStudioTab(QWidget):
self.prompt_edit.clear()
finally:
self.prompt_edit.blockSignals(False)
self._fill_original_table()
self._fill_pool_table()
self._fill_original_grid()
self._fill_pool_grid()
self._refresh_selection_labels()
self._refresh_source_label()
self._refresh_project_summary()
@@ -1119,6 +1147,110 @@ class ImageStudioTab(QWidget):
self.open_folder_button.setEnabled(enabled)
self.delete_project_button.setEnabled(enabled)
def _thumbnail_key(self, project_id, asset_id):
return f"{int(project_id)}:{int(asset_id)}"
def _original_thumbnail_state(self, asset):
asset_id = int(asset.id)
if _asset_is_usable(asset):
return "已下载"
if asset_id in self._thumbnail_pixmaps:
return "可预览"
if asset_id in self._thumbnail_errors:
return "加载失败"
project_id = getattr(self.current_project, "id", None)
key = self._thumbnail_key(project_id, asset_id) if project_id is not None else ""
if key in self._thumbnail_pending:
return "加载中"
return "待加载"
def _queue_original_thumbnails(self, originals):
project = self.current_project
if project is None:
return
project_id = int(project.id)
for asset in originals:
asset_id = int(asset.id)
remote_url = str(asset.remote_url or "").strip()
key = self._thumbnail_key(project_id, asset_id)
if (
not remote_url
or _asset_is_usable(asset)
or asset_id in self._thumbnail_pixmaps
or asset_id in self._thumbnail_errors
or key in self._thumbnail_pending
):
continue
self._thumbnail_pending.add(key)
try:
self._thumbnail_loader.submit(
key,
remote_url,
lambda result, pid=project_id, aid=asset_id, thumbnail_key=key: self._thumbnail_signals.loaded.emit(
{
"project_id": pid,
"asset_id": aid,
"key": thumbnail_key,
"result": result,
}
),
lambda failed_key, exc, pid=project_id, aid=asset_id, thumbnail_key=key: self._thumbnail_signals.failed.emit(
{
"project_id": pid,
"asset_id": aid,
"key": thumbnail_key,
}
),
)
except Exception:
self._thumbnail_signals.failed.emit(
{"project_id": project_id, "asset_id": asset_id, "key": key}
)
def _on_thumbnail_loaded(self, payload):
project_id = int(payload.get("project_id") or 0)
asset_id = int(payload.get("asset_id") or 0)
key = str(payload.get("key") or "")
self._thumbnail_pending.discard(key)
if self.current_project is None or int(self.current_project.id) != project_id:
return
result = payload.get("result")
image = QImage.fromData(bytes(getattr(result, "image_bytes", b"") or b""))
if image.isNull():
self._thumbnail_errors.add(asset_id)
else:
self._thumbnail_errors.discard(asset_id)
self._thumbnail_pixmaps[asset_id] = QPixmap.fromImage(image)
self._fill_original_grid()
def _on_thumbnail_failed(self, payload):
project_id = int(payload.get("project_id") or 0)
asset_id = int(payload.get("asset_id") or 0)
self._thumbnail_pending.discard(str(payload.get("key") or ""))
if self.current_project is None or int(self.current_project.id) != project_id:
return
self._thumbnail_errors.add(asset_id)
self._fill_original_grid()
def _retry_original_thumbnail(self, asset):
if self.current_project is None:
return
asset_id = int(asset.id)
self._thumbnail_errors.discard(asset_id)
self._thumbnail_pixmaps.pop(asset_id, None)
self._fill_original_grid()
def _reset_thumbnail_view(self, project_id):
for key in list(self._thumbnail_pending):
try:
self._thumbnail_loader.cancel(key)
except Exception:
pass
self._thumbnail_pending.clear()
self._thumbnail_pixmaps.clear()
self._thumbnail_errors.clear()
self._thumbnail_project_id = int(project_id) if project_id is not None else None
def _update_generation_action_text(self, *args):
count = self.count_spin.value() if hasattr(self, "count_spin") else 0
label = self.job_type_combo.currentText() if hasattr(self, "job_type_combo") else "图片"
@@ -1129,8 +1261,8 @@ class ImageStudioTab(QWidget):
dialog.exec()
def _show_pool_context_menu(self, position):
row = self.pool_table.rowAt(position.y())
data = self._row_data(self.pool_table, row)
item = self.pool_grid.itemAt(position)
data = self._grid_data(item)
if not data or data.get("type") != "asset":
return
asset_id = data.get("asset_id")
@@ -1144,7 +1276,7 @@ class ImageStudioTab(QWidget):
"移除照片" if not referenced else "移除照片(已被任务或终选引用)"
)
remove_action.setEnabled(not referenced)
action = menu.exec(self.pool_table.viewport().mapToGlobal(position))
action = menu.exec(self.pool_grid.viewport().mapToGlobal(position))
if action is remove_action and not referenced:
self.remove_asset(asset_id)
@@ -1472,8 +1604,8 @@ class ImageStudioTab(QWidget):
self._operation_running = bool(running)
self.pull_images_button.setEnabled(not running)
self.project_table.setEnabled(not running)
self.original_table.setEnabled(not running)
self.pool_table.setEnabled(not running)
self.original_grid.setEnabled(not running)
self.pool_grid.setEnabled(not running)
self.main_selection_list.setEnabled(not running)
self.detail_selection_list.setEnabled(not running)
self.template_combo.setEnabled(not running)
@@ -1557,16 +1689,13 @@ class ImageStudioTab(QWidget):
item.setFlags(Qt.NoItemFlags)
widget.addItem(item)
def _asset_from_table_row(self, table, row):
data = self._row_data(table, row)
def _asset_from_grid_item(self, item):
data = self._grid_data(item)
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)
def _grid_data(self, item):
if item is None:
return None
return item.data(Qt.UserRole)
@@ -1616,6 +1745,13 @@ class ImageStudioTab(QWidget):
def _status(self, message, level="muted"):
_emit_status(self.status_callback, message, level=level)
def closeEvent(self, event):
try:
self._thumbnail_loader.close()
except Exception:
pass
super().closeEvent(event)
def _asset_badge(kind):
return {
@@ -1653,8 +1789,15 @@ def _asset_is_usable(asset):
return bool(local_path and os.path.isfile(local_path))
def _asset_icon(asset, fallback_label):
return QIcon(_asset_pixmap(asset, QSize(86, 86), fallback_label=fallback_label))
def _asset_icon(asset, fallback_label, size=None, cached_pixmap=None):
return QIcon(
_asset_pixmap(
asset,
size or QSize(86, 86),
fallback_label=fallback_label,
cached_pixmap=cached_pixmap,
)
)
def _job_icon(status):
@@ -1677,7 +1820,18 @@ def _job_icon(status):
return QIcon(_placeholder_pixmap(label, QSize(86, 86), color))
def _asset_pixmap(asset, size, fallback_label=None):
def _asset_pixmap(asset, size, fallback_label=None, cached_pixmap=None):
if cached_pixmap is not None and not cached_pixmap.isNull():
scaled = cached_pixmap.scaled(size, Qt.KeepAspectRatio, Qt.SmoothTransformation)
canvas = _placeholder_pixmap("", size, "#f6f8fa")
painter = QPainter(canvas)
painter.drawPixmap(
(size.width() - scaled.width()) // 2,
(size.height() - scaled.height()) // 2,
scaled,
)
painter.end()
return canvas
path = str(getattr(asset, "local_path", "") or "")
if path and os.path.isfile(path):
image = QImage(path)