Files
dinlo 34a40ae759 Initial release: Comfy-Studio v1.0.0
Десктопная галерея и менеджер изображений 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>
2026-07-02 14:09:57 +08:00

390 lines
16 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Правая панель: превью, параметры генерации, промты, редакторы
workflow/prompt JSON, рейтинг, избранное и теги.
"""
import json
import random
from typing import Optional
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget,
QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox,
QMessageBox, QApplication, QCheckBox
)
from PyQt6.QtGui import QPixmap
from PyQt6.QtCore import Qt, pyqtSignal
from comfy_studio.core.i18n import tr
from comfy_studio.parsers.metadata_parser import MetadataParser
from comfy_studio.ui.widgets.translatable_text_edit import (
TranslatableTextEdit, TranslatableLineEdit,
)
class TagChip(QWidget):
"""Тег с кнопкой удаления."""
removed = pyqtSignal(str)
def __init__(self, tag_name: str, parent=None):
super().__init__(parent)
self.tag_name = tag_name
layout = QHBoxLayout(self)
layout.setContentsMargins(6, 2, 2, 2)
layout.setSpacing(4)
label = QLabel(tag_name)
label.setStyleSheet("background: transparent; color: #cde5ff;")
layout.addWidget(label)
btn = QPushButton("×")
btn.setFixedSize(16, 16)
btn.setStyleSheet(
"QPushButton { background: transparent; border: none; color: #90a4ae;"
" font-weight: bold; padding: 0; }"
"QPushButton:hover { color: #ef5350; }"
)
btn.clicked.connect(lambda: self.removed.emit(self.tag_name))
layout.addWidget(btn)
self.setStyleSheet(
"TagChip { background-color: #1a3a5c; border: 1px solid #2a5a8c;"
" border-radius: 9px; }"
)
class FlowTagContainer(QWidget):
"""Контейнер тегов в строку с переносом."""
tag_removed = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent)
self._layout = QHBoxLayout(self)
self._layout.setContentsMargins(0, 0, 0, 0)
self._layout.setSpacing(4)
self._layout.addStretch()
def set_tags(self, tags: list):
# Удаляем старые чипы
while self._layout.count() > 1:
item = self._layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
for t in tags:
chip = TagChip(t)
chip.removed.connect(self.tag_removed.emit)
self._layout.insertWidget(self._layout.count() - 1, chip)
class RightPanel(QWidget):
save_clicked = pyqtSignal(int, dict)
send_to_comfy_clicked = pyqtSignal(str)
favorite_toggled = pyqtSignal(int, bool)
tag_added = pyqtSignal(int, str)
tag_removed = pyqtSignal(int, str)
json_saved = pyqtSignal(int, str, str) # (file_id, field: "workflow_json"|"prompt_json", json_text)
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(tr("no_image_selected"))
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.favorite_btn = QPushButton(tr("favorite"))
self.favorite_btn.setCheckable(True)
self.favorite_btn.clicked.connect(self._on_favorite_clicked)
layout.addWidget(self.favorite_btn)
self.tabs = QTabWidget()
# ── Вкладка Generation ──
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(tr("model"), self.model_input)
self.sampler_input = QLineEdit()
self.sampler_input.setReadOnly(True)
form_layout.addRow(tr("sampler"), 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([tr("no_rating"), "", "★★", "★★★", "★★★★", "★★★★★"])
form_layout.addRow(tr("rating"), self.rating_combo)
gen_layout.addLayout(form_layout)
# Теги
gen_layout.addWidget(QLabel(tr("tags")))
self.tag_container = FlowTagContainer()
self.tag_container.tag_removed.connect(self._on_tag_removed)
gen_layout.addWidget(self.tag_container)
self.tag_input = TranslatableLineEdit()
self.tag_input.setPlaceholderText(tr("add_tag_placeholder"))
self.tag_input.returnPressed.connect(self._on_tag_entered)
gen_layout.addWidget(self.tag_input)
gen_layout.addWidget(QLabel(tr("positive_prompt")))
self.positive_text = TranslatableTextEdit()
gen_layout.addWidget(self.positive_text)
gen_layout.addWidget(QLabel(tr("negative_prompt")))
self.negative_text = TranslatableTextEdit()
gen_layout.addWidget(self.negative_text)
self.save_btn = QPushButton(tr("save_changes"))
self.save_btn.clicked.connect(self._on_save_clicked)
gen_layout.addWidget(self.save_btn)
# ── Вкладка Workflow (редактируемая) ──
self.workflow_tab = QWidget()
wf_layout = QVBoxLayout(self.workflow_tab)
wf_layout.setContentsMargins(5, 5, 5, 5)
self.workflow_text = TranslatableTextEdit()
wf_layout.addWidget(self.workflow_text)
wf_btn_layout = QHBoxLayout()
self.format_wf_btn = QPushButton(tr("format_json"))
self.format_wf_btn.clicked.connect(lambda: self._format_json(self.workflow_text))
self.save_wf_btn = QPushButton(tr("save_workflow"))
self.save_wf_btn.clicked.connect(
lambda: self._save_json_field("workflow_json", self.workflow_text))
wf_btn_layout.addWidget(self.format_wf_btn)
wf_btn_layout.addWidget(self.save_wf_btn)
wf_layout.addLayout(wf_btn_layout)
# ── Вкладка Prompt JSON (редактируемая) ──
self.prompt_tab = QWidget()
pr_layout = QVBoxLayout(self.prompt_tab)
pr_layout.setContentsMargins(5, 5, 5, 5)
self.prompt_json_text = TranslatableTextEdit()
pr_layout.addWidget(self.prompt_json_text)
pr_btn_layout = QHBoxLayout()
self.format_pr_btn = QPushButton(tr("format_json"))
self.format_pr_btn.clicked.connect(lambda: self._format_json(self.prompt_json_text))
self.save_pr_btn = QPushButton(tr("save_prompt_json"))
self.save_pr_btn.clicked.connect(
lambda: self._save_json_field("prompt_json", self.prompt_json_text))
pr_btn_layout.addWidget(self.format_pr_btn)
pr_btn_layout.addWidget(self.save_pr_btn)
pr_layout.addLayout(pr_btn_layout)
self.tabs.addTab(self.gen_tab, tr("tab_generation"))
self.tabs.addTab(self.workflow_tab, tr("tab_workflow"))
self.tabs.addTab(self.prompt_tab, tr("tab_prompt_json"))
layout.addWidget(self.tabs)
# Кнопки копирования
export_layout = QHBoxLayout()
self.copy_pos_btn = QPushButton(tr("copy_positive"))
self.copy_pos_btn.clicked.connect(
lambda: QApplication.clipboard().setText(self.positive_text.toPlainText()))
self.copy_neg_btn = QPushButton(tr("copy_negative"))
self.copy_neg_btn.clicked.connect(
lambda: QApplication.clipboard().setText(self.negative_text.toPlainText()))
self.copy_wf_btn = QPushButton(tr("copy_workflow"))
self.copy_wf_btn.clicked.connect(
lambda: QApplication.clipboard().setText(self.workflow_text.toPlainText()))
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)
send_layout = QHBoxLayout()
self.send_btn = QPushButton(tr("send_to_comfy"))
self.send_btn.clicked.connect(self._on_send_clicked)
send_layout.addWidget(self.send_btn, 1)
self.randomize_seed_check = QCheckBox(tr("randomize_seed"))
self.randomize_seed_check.setChecked(True)
send_layout.addWidget(self.randomize_seed_check)
layout.addLayout(send_layout)
# ── Отображение данных ──
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(tr("preview_error"))
self.favorite_btn.setChecked(bool(file_details.get("favorite")))
self.model_input.setText(file_details.get("model_name") or tr("not_specified"))
self.sampler_input.setText(file_details.get("sampler") or tr("not_specified"))
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.tag_container.set_tags(file_details.get("tags", []))
self.positive_text.setPlainText(file_details.get("positive_prompt") or "")
self.negative_text.setPlainText(file_details.get("negative_prompt") or "")
self._display_json(self.workflow_text, file_details.get("workflow_json"),
tr("workflow_not_found"))
self._display_json(self.prompt_json_text, file_details.get("prompt_json"),
tr("prompt_json_not_found"))
has_wf = bool(file_details.get("workflow_json"))
has_pr = bool(file_details.get("prompt_json"))
self.save_wf_btn.setEnabled(True)
self.save_pr_btn.setEnabled(True)
self.format_wf_btn.setEnabled(has_wf)
self.format_pr_btn.setEnabled(has_pr)
@staticmethod
def _display_json(widget: QTextEdit, raw: Optional[str], placeholder: str):
if raw:
try:
widget.setPlainText(json.dumps(json.loads(raw), indent=2, ensure_ascii=False))
except Exception:
widget.setPlainText(raw)
else:
widget.clear()
widget.setPlaceholderText(placeholder)
def clear_fields(self):
self.current_file_id = None
self.current_filepath = None
self.current_raw_prompt = None
self.preview_label.setText(tr("no_image_selected"))
self.preview_label.setPixmap(QPixmap())
self.favorite_btn.setChecked(False)
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.tag_container.set_tags([])
self.tag_input.clear()
self.positive_text.clear()
self.negative_text.clear()
self.workflow_text.clear()
self.prompt_json_text.clear()
self.save_wf_btn.setEnabled(False)
self.save_pr_btn.setEnabled(False)
self.format_wf_btn.setEnabled(False)
self.format_pr_btn.setEnabled(False)
# ── Редактор JSON ──
def _format_json(self, widget: QTextEdit):
"""Валидирует и красиво форматирует JSON в редакторе."""
text = widget.toPlainText().strip()
if not text:
return
try:
widget.setPlainText(json.dumps(json.loads(text), indent=2, ensure_ascii=False))
except json.JSONDecodeError as e:
QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e)))
def _save_json_field(self, field: str, widget: QTextEdit):
"""Проверяет JSON и передаёт его на сохранение в БД."""
if self.current_file_id is None:
return
text = widget.toPlainText().strip()
if text:
try:
# Сохраняем компактную форму, как пишет ComfyUI
text = json.dumps(json.loads(text), ensure_ascii=False)
except json.JSONDecodeError as e:
QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e)))
return
if field == "prompt_json":
self.current_raw_prompt = text or None
self.json_saved.emit(self.current_file_id, field, text)
# ── Обработчики ──
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, tr("error"), tr("no_prompt_graph"))
return
# Внедряем текущие (возможно отредактированные) промты в граф.
# Без нового seed ComfyUI отдаёт идентичный граф из кэша
# и не генерирует ("Prompt executed in 0.00 seconds").
seed = random.randint(0, 2**48) if self.randomize_seed_check.isChecked() else None
updated_prompt = MetadataParser.inject_parameters(
self.current_raw_prompt,
positive=self.positive_text.toPlainText(),
negative=self.negative_text.toPlainText(),
seed=seed,
)
self.send_to_comfy_clicked.emit(updated_prompt)
def _on_favorite_clicked(self, checked: bool):
if self.current_file_id is not None:
self.favorite_toggled.emit(self.current_file_id, checked)
def _on_tag_entered(self):
if self.current_file_id is None:
return
tag = self.tag_input.text().strip()
if tag:
self.tag_added.emit(self.current_file_id, tag)
self.tag_input.clear()
def _on_tag_removed(self, tag: str):
if self.current_file_id is not None:
self.tag_removed.emit(self.current_file_id, tag)