Initial commit

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
dinlo
2026-05-31 18:43:18 +08:00
commit b553c957f3
30 changed files with 1879 additions and 0 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+285
View File
@@ -0,0 +1,285 @@
# ui/widgets/center_grid.py
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
)
logger = logging.getLogger("ComfyGallery.CenterGrid")
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
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 = 130
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:
""" Указываем, что модель поддерживает копирование при Drag-and-Drop """
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["has_workflow"])
elif role == RatingRole:
return file_data["rating"]
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):
if filepath in self.loading_paths:
self.loading_paths.remove(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: "QStyleOptionViewItem", 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)
has_workflow = index.data(HasWorkflowRole)
if has_workflow:
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")
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)
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
super().keyPressEvent(event)
def startDrag(self, supportedActions):
"""
Переопределяем метод начала перетаскивания.
Принудительно инициализирует системное событие Drag-and-Drop для внешних окон.
"""
indexes = self.selectedIndexes()
if not indexes:
return
logger.debug(f"Начало перетаскивания файлов: {len(indexes)} шт.")
drag = QDrag(self)
mime_data = self.model().mimeData(indexes)
drag.setMimeData(mime_data)
# Установка миниатюры под курсор при перетаскивании
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("Выберите папку...")
control_layout.addWidget(self.path_label)
control_layout.addStretch()
control_layout.addWidget(QLabel("Масштаб:"))
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
self.zoom_slider.setRange(80, 250)
self.zoom_slider.setValue(130)
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)
+57
View File
@@ -0,0 +1,57 @@
# ui/widgets/left_panel.py
from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QTreeView, QComboBox
from PyQt6.QtGui import QFileSystemModel
from PyQt6.QtCore import QDir, pyqtSignal
class LeftPanel(QWidget):
search_changed = pyqtSignal(str)
root_folder_changed = pyqtSignal(str) # Сигнал переключения активной корневой папки
def __init__(self, parent=None):
super().__init__(parent)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Поиск по тегам или промтам...")
self.search_input.textChanged.connect(self.search_changed.emit)
layout.addWidget(self.search_input)
# Выпадающий список выбора активной корневой директории
layout.addWidget(QLabel("Активная папка:"))
self.folder_selector = QComboBox()
self.folder_selector.currentTextChanged.connect(self._on_folder_changed)
layout.addWidget(self.folder_selector)
layout.addWidget(QLabel("Дерево подпапок:"))
self.folder_model = QFileSystemModel()
self.folder_model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot)
self.tree_view = QTreeView()
self.tree_view.setModel(self.folder_model)
self.tree_view.setHeaderHidden(True)
for i in range(1, self.folder_model.columnCount()):
self.tree_view.setColumnHidden(i, True)
layout.addWidget(self.tree_view)
def set_tracked_folders(self, folders: list):
""" Обновляет выпадающий список папок """
self.folder_selector.blockSignals(True)
self.folder_selector.clear()
self.folder_selector.addItems(folders)
self.folder_selector.blockSignals(False)
def set_root_path(self, path: str):
self.folder_model.setRootPath(path)
self.tree_view.setRootIndex(self.folder_model.index(path))
def _on_folder_changed(self, path: str):
if path:
self.set_root_path(path)
self.root_folder_changed.emit(path)
+186
View File
@@ -0,0 +1,186 @@
# ui/widgets/right_panel.py
import json
from typing import Optional # <-- Добавлен пропущенный импорт
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget,
QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox, QMessageBox, QApplication
)
from PyQt6.QtGui import QPixmap
from PyQt6.QtCore import Qt, pyqtSignal
class RightPanel(QWidget):
save_clicked = pyqtSignal(int, dict)
send_to_comfy_clicked = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.current_file_id = None
self.current_filepath = None
self.current_raw_prompt = None
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(5, 5, 5, 5)
self.preview_label = QLabel("Нет выделенного изображения")
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.preview_label.setMinimumHeight(250)
self.preview_label.setStyleSheet(
"QLabel { background-color: #0f0f0f; border: 1px solid #2d2d2d; border-radius: 4px; }"
)
layout.addWidget(self.preview_label)
self.tabs = QTabWidget()
self.gen_tab = QWidget()
gen_layout = QVBoxLayout(self.gen_tab)
gen_layout.setContentsMargins(5, 5, 5, 5)
form_layout = QFormLayout()
self.model_input = QLineEdit()
self.model_input.setReadOnly(True)
form_layout.addRow("Модель:", self.model_input)
self.sampler_input = QLineEdit()
self.sampler_input.setReadOnly(True)
form_layout.addRow("Сэмплер:", self.sampler_input)
params_row = QHBoxLayout()
self.seed_input = QLineEdit()
self.seed_input.setReadOnly(True)
self.steps_input = QLineEdit()
self.steps_input.setReadOnly(True)
self.cfg_input = QLineEdit()
self.cfg_input.setReadOnly(True)
params_row.addWidget(QLabel("Seed:"))
params_row.addWidget(self.seed_input)
params_row.addWidget(QLabel("Steps:"))
params_row.addWidget(self.steps_input)
params_row.addWidget(QLabel("CFG:"))
params_row.addWidget(self.cfg_input)
form_layout.addRow(params_row)
self.rating_combo = QComboBox()
self.rating_combo.addItems(["Без рейтинга", "", "★★", "★★★", "★★★★", "★★★★★"])
form_layout.addRow("Рейтинг:", self.rating_combo)
gen_layout.addLayout(form_layout)
gen_layout.addWidget(QLabel("Позитивный промт:"))
self.positive_text = QTextEdit()
gen_layout.addWidget(self.positive_text)
gen_layout.addWidget(QLabel("Негативный промт:"))
self.negative_text = QTextEdit()
gen_layout.addWidget(self.negative_text)
self.save_btn = QPushButton("Сохранить изменения")
self.save_btn.clicked.connect(self._on_save_clicked)
gen_layout.addWidget(self.save_btn)
self.workflow_tab = QWidget()
wf_layout = QVBoxLayout(self.workflow_tab)
wf_layout.setContentsMargins(5, 5, 5, 5)
self.workflow_text = QTextEdit()
self.workflow_text.setReadOnly(True)
wf_layout.addWidget(self.workflow_text)
self.tabs.addTab(self.gen_tab, "Generation")
self.tabs.addTab(self.workflow_tab, "Workflow")
layout.addWidget(self.tabs)
export_layout = QHBoxLayout()
self.copy_pos_btn = QPushButton("Коп. Pos")
self.copy_pos_btn.clicked.connect(self._copy_positive)
self.copy_neg_btn = QPushButton("Коп. Neg")
self.copy_neg_btn.clicked.connect(self._copy_negative)
self.copy_wf_btn = QPushButton("Коп. Work")
self.copy_wf_btn.clicked.connect(self._copy_workflow)
export_layout.addWidget(self.copy_pos_btn)
export_layout.addWidget(self.copy_neg_btn)
export_layout.addWidget(self.copy_wf_btn)
layout.addLayout(export_layout)
self.send_btn = QPushButton("Отправить в ComfyUI")
self.send_btn.clicked.connect(self._on_send_clicked)
layout.addWidget(self.send_btn)
def display_metadata(self, file_details: Optional[dict]):
if not file_details:
self.clear_fields()
return
self.current_file_id = file_details["id"]
self.current_filepath = file_details["filepath"]
self.current_raw_prompt = file_details.get("prompt_json")
pixmap = QPixmap(self.current_filepath)
if not pixmap.isNull():
scaled = pixmap.scaled(
self.preview_label.width(), self.preview_label.height(),
Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation
)
self.preview_label.setPixmap(scaled)
else:
self.preview_label.setText("Ошибка рендеринга превью")
self.model_input.setText(file_details.get("model_name") or "Не указано")
self.sampler_input.setText(file_details.get("sampler") or "Не указано")
self.seed_input.setText(str(file_details.get("seed") or ""))
self.steps_input.setText(str(file_details.get("steps") or ""))
self.cfg_input.setText(str(file_details.get("cfg") or ""))
rating = file_details.get("rating", 0)
self.rating_combo.setCurrentIndex(rating if 0 <= rating <= 5 else 0)
self.positive_text.setPlainText(file_details.get("positive_prompt") or "")
self.negative_text.setPlainText(file_details.get("negative_prompt") or "")
wf_raw = file_details.get("workflow_json")
if wf_raw:
try:
self.workflow_text.setPlainText(json.dumps(json.loads(wf_raw), indent=2, ensure_ascii=False))
except Exception:
self.workflow_text.setPlainText(wf_raw)
else:
self.workflow_text.setPlainText("Workflow не найден")
def clear_fields(self):
self.current_file_id = None
self.current_filepath = None
self.current_raw_prompt = None
self.preview_label.setText("Нет выделенного изображения")
self.preview_label.setPixmap(QPixmap())
self.model_input.clear()
self.sampler_input.clear()
self.seed_input.clear()
self.steps_input.clear()
self.cfg_input.clear()
self.rating_combo.setCurrentIndex(0)
self.positive_text.clear()
self.negative_text.clear()
self.workflow_text.clear()
def _on_save_clicked(self):
if self.current_file_id is None: return
payload = {
"positive_prompt": self.positive_text.toPlainText(),
"negative_prompt": self.negative_text.toPlainText(),
"rating": self.rating_combo.currentIndex()
}
self.save_clicked.emit(self.current_file_id, payload)
def _on_send_clicked(self):
if not self.current_raw_prompt:
QMessageBox.warning(self, "Ошибка", "У изображения отсутствует prompt-граф.")
return
self.send_to_comfy_clicked.emit(self.current_raw_prompt)
def _copy_positive(self):
QApplication.clipboard().setText(self.positive_text.toPlainText())
def _copy_negative(self):
QApplication.clipboard().setText(self.negative_text.toPlainText())
def _copy_workflow(self):
QApplication.clipboard().setText(self.workflow_text.toPlainText())
+74
View File
@@ -0,0 +1,74 @@
# ui/widgets/settings_dialog.py
from PyQt6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QListWidget, QFileDialog, QMessageBox
)
class SettingsDialog(QDialog):
def __init__(self, current_settings, parent=None):
super().__init__(parent)
self.settings = current_settings
self.setWindowTitle("Настройки подключения и папок")
self.resize(500, 380)
self.init_ui()
def init_ui(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Адрес API ComfyUI:"))
self.url_input = QLineEdit()
self.url_input.setText(self.settings.get("comfyui_url", "http://127.0.0.1:8000"))
layout.addWidget(self.url_input)
layout.addWidget(QLabel("Отслеживаемые папки генерации:"))
self.paths_list = QListWidget()
for p in self.settings.get("tracked_paths", []):
self.paths_list.addItem(p)
layout.addWidget(self.paths_list)
paths_btn_layout = QHBoxLayout()
self.add_path_btn = QPushButton("Добавить дополнительную папку")
self.add_path_btn.clicked.connect(self._add_path)
self.remove_path_btn = QPushButton("Удалить выбранную")
self.remove_path_btn.clicked.connect(self._remove_path)
paths_btn_layout.addWidget(self.add_path_btn)
paths_btn_layout.addWidget(self.remove_path_btn)
layout.addLayout(paths_btn_layout)
btn_layout = QHBoxLayout()
self.save_btn = QPushButton("Сохранить")
self.save_btn.clicked.connect(self._save)
self.cancel_btn = QPushButton("Отмена")
self.cancel_btn.clicked.connect(self.reject)
btn_layout.addStretch()
btn_layout.addWidget(self.save_btn)
btn_layout.addWidget(self.cancel_btn)
layout.addLayout(btn_layout)
def _add_path(self):
dir_path = QFileDialog.getExistingDirectory(self, "Выбрать отслеживаемую папку")
if dir_path:
items = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
if dir_path not in items:
self.paths_list.addItem(dir_path)
def _remove_path(self):
selected = self.paths_list.selectedItems()
if not selected: return
for s in selected:
self.paths_list.takeItem(self.paths_list.row(s))
def _save(self):
url = self.url_input.text().strip()
if not url:
QMessageBox.warning(self, "Ошибка", "Адрес API не может быть пустым.")
return
paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())]
if not paths:
QMessageBox.warning(self, "Ошибка", "Должна быть добавлена хотя бы одна папка.")
return
self.settings["comfyui_url"] = url
self.settings["tracked_paths"] = paths
self.accept()