34a40ae759
Десктопная галерея и менеджер изображений ComfyUI (PyQt6 + SQLite): - мультипапочный мониторинг (watchdog), грид миниатюр, drag-and-drop - теги, избранное, рейтинг, поиск по промтам - парсер метаданных ComfyUI (PNG/WebP/JPEG), редактор workflow/prompt JSON - перевод выделенного текста через контекстное меню (настраиваемые языки) - отправка промта в очередь ComfyUI с внедрением правок и нового seed - полноэкранный просмотр, локализация ru/en, тёмная тема Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""
|
|
Центральная панель: грид миниатюр с асинхронной загрузкой,
|
|
drag-and-drop наружу, зум, значки workflow/избранного.
|
|
"""
|
|
|
|
import os
|
|
import logging
|
|
from typing import List, Dict, Any
|
|
|
|
from PyQt6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider,
|
|
QListView, QStyledItemDelegate, QStyle
|
|
)
|
|
from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFontMetrics, QDrag
|
|
from PyQt6.QtCore import (
|
|
Qt, QAbstractListModel, QModelIndex, QSize, QRect,
|
|
QRunnable, QObject, pyqtSignal, QThreadPool, QMimeData, QUrl
|
|
)
|
|
|
|
from comfy_studio.core.config import MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE, DEFAULT_THUMBNAIL_SIZE
|
|
from comfy_studio.core.i18n import tr
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
FileIdRole = Qt.ItemDataRole.UserRole + 1
|
|
FilePathRole = Qt.ItemDataRole.UserRole + 2
|
|
HasWorkflowRole = Qt.ItemDataRole.UserRole + 3
|
|
RatingRole = Qt.ItemDataRole.UserRole + 4
|
|
PixmapRole = Qt.ItemDataRole.UserRole + 5
|
|
FavoriteRole = Qt.ItemDataRole.UserRole + 6
|
|
|
|
|
|
class ThumbnailSignals(QObject):
|
|
loaded = pyqtSignal(str, QImage)
|
|
|
|
|
|
class ThumbnailRunnable(QRunnable):
|
|
def __init__(self, filepath: str, target_size: int, signals: ThumbnailSignals):
|
|
super().__init__()
|
|
self.filepath = filepath
|
|
self.target_size = target_size
|
|
self.signals = signals
|
|
|
|
def run(self):
|
|
try:
|
|
image = QImage(self.filepath)
|
|
if not image.isNull():
|
|
scaled = image.scaled(
|
|
self.target_size, self.target_size,
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|
Qt.TransformationMode.SmoothTransformation
|
|
)
|
|
self.signals.loaded.emit(self.filepath, scaled)
|
|
except Exception as e:
|
|
logger.debug(f"Ошибка загрузки миниатюры {self.filepath}: {e}")
|
|
|
|
|
|
class ImageModel(QAbstractListModel):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.files: List[Dict[str, Any]] = []
|
|
self.pixmap_cache: Dict[str, QPixmap] = {}
|
|
self.loading_paths = set()
|
|
self.thread_pool = QThreadPool.globalInstance()
|
|
self.signals = ThumbnailSignals()
|
|
self.signals.loaded.connect(self._on_thumbnail_loaded)
|
|
self.thumbnail_size = DEFAULT_THUMBNAIL_SIZE
|
|
|
|
def set_files(self, files: List[Dict[str, Any]]):
|
|
self.beginResetModel()
|
|
self.files = files
|
|
self.pixmap_cache.clear()
|
|
self.loading_paths.clear()
|
|
self.endResetModel()
|
|
|
|
def set_thumbnail_size(self, size: int):
|
|
self.beginResetModel()
|
|
self.thumbnail_size = size
|
|
self.pixmap_cache.clear()
|
|
self.loading_paths.clear()
|
|
self.endResetModel()
|
|
|
|
def rowCount(self, parent=QModelIndex()) -> int:
|
|
return len(self.files)
|
|
|
|
def supportedDragActions(self) -> Qt.DropAction:
|
|
return Qt.DropAction.CopyAction
|
|
|
|
def mimeTypes(self) -> List[str]:
|
|
return ["text/uri-list"]
|
|
|
|
def mimeData(self, indexes: List[QModelIndex]) -> QMimeData:
|
|
"""Преобразует выделенные файлы в UriList для ОС и браузеров."""
|
|
mime_data = QMimeData()
|
|
urls = []
|
|
for index in indexes:
|
|
if index.isValid():
|
|
filepath = self.data(index, FilePathRole)
|
|
if filepath and os.path.exists(filepath):
|
|
urls.append(QUrl.fromLocalFile(filepath))
|
|
mime_data.setUrls(urls)
|
|
return mime_data
|
|
|
|
def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any:
|
|
if not index.isValid() or index.row() >= len(self.files):
|
|
return None
|
|
file_data = self.files[index.row()]
|
|
filepath = file_data["filepath"]
|
|
|
|
if role == Qt.ItemDataRole.DisplayRole:
|
|
return file_data["filename"]
|
|
elif role == FileIdRole:
|
|
return file_data["id"]
|
|
elif role == FilePathRole:
|
|
return filepath
|
|
elif role == HasWorkflowRole:
|
|
return bool(file_data.get("has_workflow"))
|
|
elif role == RatingRole:
|
|
return file_data.get("rating", 0)
|
|
elif role == FavoriteRole:
|
|
return bool(file_data.get("favorite"))
|
|
elif role == PixmapRole:
|
|
if filepath in self.pixmap_cache:
|
|
return self.pixmap_cache[filepath]
|
|
if filepath not in self.loading_paths:
|
|
self.loading_paths.add(filepath)
|
|
runnable = ThumbnailRunnable(filepath, self.thumbnail_size, self.signals)
|
|
self.thread_pool.start(runnable)
|
|
return None
|
|
return None
|
|
|
|
def _on_thumbnail_loaded(self, filepath: str, qimage: QImage):
|
|
self.loading_paths.discard(filepath)
|
|
pixmap = QPixmap.fromImage(qimage)
|
|
self.pixmap_cache[filepath] = pixmap
|
|
for row, f in enumerate(self.files):
|
|
if f["filepath"] == filepath:
|
|
idx = self.index(row)
|
|
self.dataChanged.emit(idx, idx, [PixmapRole])
|
|
break
|
|
|
|
|
|
class ImageDelegate(QStyledItemDelegate):
|
|
def __init__(self, model: ImageModel, parent=None):
|
|
super().__init__(parent)
|
|
self.model = model
|
|
|
|
def paint(self, painter: QPainter, option, index: QModelIndex):
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
rect = option.rect
|
|
selected = option.state & QStyle.StateFlag.State_Selected
|
|
hovered = option.state & QStyle.StateFlag.State_MouseOver
|
|
|
|
bg_color = QColor("#1c1c1c") if hovered else QColor("#141414")
|
|
if selected:
|
|
bg_color = QColor("#0d47a1")
|
|
painter.setBrush(QBrush(bg_color))
|
|
painter.setPen(QPen(QColor("#1976d2") if selected else QColor("#2d2d2d"), 1.5))
|
|
painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 6, 6)
|
|
|
|
cell_size = self.model.thumbnail_size
|
|
img_rect = QRect(rect.left() + 8, rect.top() + 8, cell_size - 16, cell_size - 16)
|
|
|
|
pixmap = index.data(PixmapRole)
|
|
if pixmap and not pixmap.isNull():
|
|
w, h = pixmap.width(), pixmap.height()
|
|
target_w, target_h = img_rect.width(), img_rect.height()
|
|
if w > 0 and h > 0:
|
|
ratio = min(target_w / w, target_h / h)
|
|
final_w, final_h = int(w * ratio), int(h * ratio)
|
|
x_offset = img_rect.left() + (target_w - final_w) // 2
|
|
y_offset = img_rect.top() + (target_h - final_h) // 2
|
|
painter.drawPixmap(x_offset, y_offset, final_w, final_h, pixmap)
|
|
else:
|
|
painter.setBrush(QBrush(QColor("#0a0a0a")))
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.drawRoundedRect(img_rect, 4, 4)
|
|
|
|
# Имя файла
|
|
text = index.data(Qt.ItemDataRole.DisplayRole)
|
|
if text:
|
|
font = painter.font()
|
|
font.setPointSize(8)
|
|
painter.setFont(font)
|
|
fm = QFontMetrics(font)
|
|
text_rect = QRect(rect.left() + 4, rect.bottom() - 20, rect.width() - 8, 16)
|
|
elided_text = fm.elidedText(text, Qt.TextElideMode.ElideMiddle, text_rect.width())
|
|
painter.setPen(QPen(QColor("#ffffff") if selected else QColor("#8e8e8e")))
|
|
painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text)
|
|
|
|
# Значок наличия workflow (зелёный кружок "W")
|
|
if index.data(HasWorkflowRole):
|
|
badge_rect = QRect(rect.right() - 22, rect.top() + 8, 14, 14)
|
|
painter.setBrush(QBrush(QColor("#2ea44f")))
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.drawEllipse(badge_rect)
|
|
painter.setPen(QPen(QColor("#ffffff")))
|
|
font = painter.font()
|
|
font.setBold(True)
|
|
font.setPointSize(7)
|
|
painter.setFont(font)
|
|
painter.drawText(badge_rect.adjusted(0, -1, 0, 0), Qt.AlignmentFlag.AlignCenter, "W")
|
|
|
|
# Значок избранного (жёлтая звезда)
|
|
if index.data(FavoriteRole):
|
|
star_rect = QRect(rect.left() + 8, rect.top() + 6, 16, 16)
|
|
painter.setPen(QPen(QColor("#ffc107")))
|
|
font = painter.font()
|
|
font.setPointSize(11)
|
|
painter.setFont(font)
|
|
painter.drawText(star_rect, Qt.AlignmentFlag.AlignCenter, "★")
|
|
|
|
painter.restore()
|
|
|
|
def sizeHint(self, option, index) -> QSize:
|
|
size = self.model.thumbnail_size
|
|
return QSize(size, size + 22)
|
|
|
|
|
|
class GalleryListView(QListView):
|
|
delete_pressed = pyqtSignal(list)
|
|
copy_pressed = pyqtSignal(str)
|
|
fullscreen_requested = pyqtSignal(int) # row
|
|
|
|
def keyPressEvent(self, event):
|
|
if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C:
|
|
indexes = self.selectedIndexes()
|
|
if indexes:
|
|
filepath = self.model().data(indexes[0], FilePathRole)
|
|
if filepath:
|
|
self.copy_pressed.emit(filepath)
|
|
event.accept()
|
|
return
|
|
if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace):
|
|
indexes = self.selectedIndexes()
|
|
if indexes:
|
|
paths = [self.model().data(idx, FilePathRole) for idx in indexes if idx.isValid()]
|
|
self.delete_pressed.emit(paths)
|
|
event.accept()
|
|
return
|
|
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
|
indexes = self.selectedIndexes()
|
|
if indexes:
|
|
self.fullscreen_requested.emit(indexes[0].row())
|
|
event.accept()
|
|
return
|
|
super().keyPressEvent(event)
|
|
|
|
def mouseDoubleClickEvent(self, event):
|
|
index = self.indexAt(event.pos())
|
|
if index.isValid():
|
|
self.fullscreen_requested.emit(index.row())
|
|
super().mouseDoubleClickEvent(event)
|
|
|
|
def startDrag(self, supportedActions):
|
|
"""Системный Drag-and-Drop файлов во внешние окна."""
|
|
indexes = self.selectedIndexes()
|
|
if not indexes:
|
|
return
|
|
drag = QDrag(self)
|
|
drag.setMimeData(self.model().mimeData(indexes))
|
|
pixmap = self.model().data(indexes[0], PixmapRole)
|
|
if pixmap and not pixmap.isNull():
|
|
drag.setPixmap(pixmap.scaled(70, 70, Qt.AspectRatioMode.KeepAspectRatio))
|
|
drag.exec(Qt.DropAction.CopyAction)
|
|
|
|
|
|
class CenterGrid(QWidget):
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.init_ui()
|
|
|
|
def init_ui(self):
|
|
layout = QVBoxLayout(self)
|
|
layout.setContentsMargins(5, 5, 5, 5)
|
|
|
|
control_layout = QHBoxLayout()
|
|
self.path_label = QLabel(tr("select_folder"))
|
|
control_layout.addWidget(self.path_label)
|
|
control_layout.addStretch()
|
|
|
|
control_layout.addWidget(QLabel(tr("zoom")))
|
|
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|
self.zoom_slider.setRange(MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE)
|
|
self.zoom_slider.setValue(DEFAULT_THUMBNAIL_SIZE)
|
|
self.zoom_slider.setFixedWidth(150)
|
|
control_layout.addWidget(self.zoom_slider)
|
|
layout.addLayout(control_layout)
|
|
|
|
self.list_view = GalleryListView()
|
|
self.list_view.setViewMode(QListView.ViewMode.IconMode)
|
|
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
|
|
self.list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection)
|
|
self.list_view.setDragEnabled(True)
|
|
self.list_view.setSpacing(10)
|
|
self.list_view.setUniformItemSizes(True)
|
|
self.list_view.setStyleSheet(
|
|
"QListView { border: 1px solid #2d2d2d; background-color: #121212; }")
|
|
|
|
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
|
|
self.model = ImageModel()
|
|
self.delegate = ImageDelegate(self.model)
|
|
self.list_view.setModel(self.model)
|
|
self.list_view.setItemDelegate(self.delegate)
|
|
layout.addWidget(self.list_view)
|
|
|
|
self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
|
|
|
def _on_zoom_changed(self, value: int):
|
|
self.model.set_thumbnail_size(value)
|