390 lines
16 KiB
Python
390 lines
16 KiB
Python
|
|
"""
|
|||
|
|
Правая панель: превью, параметры генерации, промты, редакторы
|
|||
|
|
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)
|