Files

390 lines
16 KiB
Python
Raw Permalink Normal View History

2026-07-02 14:09:57 +08:00
"""
Правая панель: превью, параметры генерации, промты, редакторы
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)