3011 lines
125 KiB
Python
3011 lines
125 KiB
Python
|
|
import copy
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import urllib.error
|
|||
|
|
import urllib.parse
|
|||
|
|
import urllib.request
|
|||
|
|
import uuid
|
|||
|
|
import zipfile
|
|||
|
|
from datetime import datetime
|
|||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from PyQt6.QtCore import QPointF, QRectF, QThread, Qt, pyqtSignal
|
|||
|
|
from PyQt6.QtGui import QAction, QColor, QGuiApplication, QKeySequence, QPainter, QPen, QPixmap
|
|||
|
|
from PyQt6.QtWidgets import (
|
|||
|
|
QApplication,
|
|||
|
|
QCheckBox,
|
|||
|
|
QColorDialog,
|
|||
|
|
QComboBox,
|
|||
|
|
QDialog,
|
|||
|
|
QDialogButtonBox,
|
|||
|
|
QFileDialog,
|
|||
|
|
QFormLayout,
|
|||
|
|
QFrame,
|
|||
|
|
QGridLayout,
|
|||
|
|
QGroupBox,
|
|||
|
|
QHBoxLayout,
|
|||
|
|
QLabel,
|
|||
|
|
QLineEdit,
|
|||
|
|
QListWidget,
|
|||
|
|
QListWidgetItem,
|
|||
|
|
QMainWindow,
|
|||
|
|
QMessageBox,
|
|||
|
|
QPlainTextEdit,
|
|||
|
|
QProgressDialog,
|
|||
|
|
QPushButton,
|
|||
|
|
QRadioButton,
|
|||
|
|
QScrollArea,
|
|||
|
|
QSizePolicy,
|
|||
|
|
QSlider,
|
|||
|
|
QSpinBox,
|
|||
|
|
QSplitter,
|
|||
|
|
QTabWidget,
|
|||
|
|
QTextEdit,
|
|||
|
|
QToolButton,
|
|||
|
|
QVBoxLayout,
|
|||
|
|
QWidget,
|
|||
|
|
QInputDialog,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
HEX_RE = re.compile(r"^#[0-9A-F]{6}$")
|
|||
|
|
MIN_BBOX_SIZE = 16
|
|||
|
|
APP_DIR = Path(__file__).resolve().parent
|
|||
|
|
LIBRARY_FILE = APP_DIR / "prompt_library.json"
|
|||
|
|
PREVIEW_DIR = APP_DIR / "prompt_previews"
|
|||
|
|
LANG_FILE = APP_DIR / "translations.json"
|
|||
|
|
SETTINGS_FILE = APP_DIR / "comfy_settings.json"
|
|||
|
|
DRAFT_FILE = APP_DIR / "draft.json"
|
|||
|
|
WORKFLOW_FILE = APP_DIR / "ideogram4NSFWComfyui_v11.json"
|
|||
|
|
DEFAULT_LANGUAGE = "en"
|
|||
|
|
LANGUAGE_NAMES = {"en": "English", "ru": "Русский"}
|
|||
|
|
MAX_UNDO = 60
|
|||
|
|
|
|||
|
|
DEFAULT_SETTINGS = {
|
|||
|
|
"comfy_host": "127.0.0.1",
|
|||
|
|
"comfy_port": 8188,
|
|||
|
|
"comfy_https": False,
|
|||
|
|
"theme": "light",
|
|||
|
|
"language": DEFAULT_LANGUAGE,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Models / samplers / custom nodes the bundled workflow needs to run in ComfyUI.
|
|||
|
|
REQUIRED_COMFY = {
|
|||
|
|
"nodes": [
|
|||
|
|
"Ideogram4PromptBuilderKJ",
|
|||
|
|
"Ideogram4Scheduler",
|
|||
|
|
"UNETLoader",
|
|||
|
|
"VAELoader",
|
|||
|
|
"CLIPLoader",
|
|||
|
|
"CLIPLoaderGGUF",
|
|||
|
|
"DualModelGuider",
|
|||
|
|
"SamplerCustomAdvanced",
|
|||
|
|
"KSamplerSelect",
|
|||
|
|
"RandomNoise",
|
|||
|
|
"VAEDecode",
|
|||
|
|
],
|
|||
|
|
"unet": [
|
|||
|
|
"ideogram4_fp8_scaled.safetensors",
|
|||
|
|
"ideogram4_unconditional_fp8_scaled.safetensors",
|
|||
|
|
],
|
|||
|
|
"vae": ["flux2-vae.safetensors"],
|
|||
|
|
"clip": ["qwen3vl_8b_fp8_scaled.safetensors"],
|
|||
|
|
"clip_gguf": ["Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-Q8_0.gguf"],
|
|||
|
|
"samplers": ["euler"],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# Quick-insert element templates (item 12).
|
|||
|
|
ELEMENT_TEMPLATES = {
|
|||
|
|
"Character": {
|
|||
|
|
"type": "obj",
|
|||
|
|
"desc": "A full-body character with realistic proportions and natural posture.",
|
|||
|
|
"bbox": [120, 320, 950, 690],
|
|||
|
|
},
|
|||
|
|
"Title text": {
|
|||
|
|
"type": "text",
|
|||
|
|
"text": "TITLE",
|
|||
|
|
"desc": "Bold display lettering across the top of the composition.",
|
|||
|
|
"bbox": [70, 120, 200, 880],
|
|||
|
|
},
|
|||
|
|
"Background object": {
|
|||
|
|
"type": "obj",
|
|||
|
|
"desc": "A secondary object that anchors the background of the scene.",
|
|||
|
|
"bbox": [400, 100, 800, 500],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
THEMES = {
|
|||
|
|
"light": {
|
|||
|
|
"bg": "#F4F6F4", "panel": "#FFFFFF", "text": "#182024", "muted": "#5D666F",
|
|||
|
|
"border": "#DDE3DD", "field_border": "#CBD5CE", "accent": "#176B87",
|
|||
|
|
"accent_dark": "#0F5269", "list_sel_bg": "#DCEFF3", "list_sel_fg": "#0F5269",
|
|||
|
|
"hover_bg": "#F0F7F8", "canvas_bg": "#F3F6F3", "canvas_grid": "#D4DCD4",
|
|||
|
|
"canvas_label": "#98A39B", "error": "#C0392B",
|
|||
|
|
},
|
|||
|
|
"dark": {
|
|||
|
|
"bg": "#1E2227", "panel": "#272C33", "text": "#E6EAED", "muted": "#9AA4AD",
|
|||
|
|
"border": "#363C44", "field_border": "#3C434C", "accent": "#3AA6C4",
|
|||
|
|
"accent_dark": "#2C8BA6", "list_sel_bg": "#234049", "list_sel_fg": "#CDEBF3",
|
|||
|
|
"hover_bg": "#2E3942", "canvas_bg": "#22272D", "canvas_grid": "#3A424A",
|
|||
|
|
"canvas_label": "#6C757D", "error": "#E06A5C",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_stylesheet(theme):
|
|||
|
|
c = THEMES.get(theme, THEMES["light"])
|
|||
|
|
return f"""
|
|||
|
|
QMainWindow, QWidget {{ background: {c['bg']}; color: {c['text']}; font-family: Segoe UI; font-size: 10.5pt; }}
|
|||
|
|
QToolBar {{ background: {c['panel']}; border: 0; border-bottom: 1px solid {c['border']}; spacing: 8px; padding: 8px; }}
|
|||
|
|
QToolButton {{ border-radius: 7px; padding: 6px 10px; color: {c['text']}; }}
|
|||
|
|
QToolBar QToolButton:hover {{ background: {c['hover_bg']}; }}
|
|||
|
|
QGroupBox {{ background: {c['panel']}; border: 1px solid {c['border']}; border-radius: 10px; margin-top: 18px; padding: 14px; font-weight: 700; }}
|
|||
|
|
QGroupBox::title {{ subcontrol-origin: margin; left: 14px; padding: 0 7px; color: {c['accent']}; }}
|
|||
|
|
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QSpinBox, QListWidget {{
|
|||
|
|
background: {c['panel']}; border: 1px solid {c['field_border']}; border-radius: 8px; padding: 7px;
|
|||
|
|
selection-background-color: {c['accent']}; color: {c['text']};
|
|||
|
|
}}
|
|||
|
|
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QComboBox:focus, QSpinBox:focus {{
|
|||
|
|
border: 1px solid {c['accent']};
|
|||
|
|
}}
|
|||
|
|
QLineEdit[invalid="true"], QPlainTextEdit[invalid="true"] {{ border: 1px solid {c['error']}; }}
|
|||
|
|
QPushButton {{
|
|||
|
|
background: {c['panel']}; border: 1px solid {c['field_border']}; border-radius: 8px; padding: 8px 12px; color: {c['text']};
|
|||
|
|
}}
|
|||
|
|
QPushButton:hover {{ border-color: {c['accent']}; background: {c['hover_bg']}; }}
|
|||
|
|
QPushButton:disabled {{ color: {c['muted']}; }}
|
|||
|
|
QPushButton#PrimaryButton {{ background: {c['accent']}; color: white; border-color: {c['accent']}; font-weight: 700; }}
|
|||
|
|
QPushButton#PrimaryButton:hover {{ background: {c['accent_dark']}; }}
|
|||
|
|
QListWidget::item {{ padding: 8px; border-radius: 6px; }}
|
|||
|
|
QListWidget::item:selected {{ background: {c['list_sel_bg']}; color: {c['list_sel_fg']}; }}
|
|||
|
|
QRadioButton, QCheckBox {{ background: transparent; }}
|
|||
|
|
QLabel {{ background: transparent; }}
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_settings():
|
|||
|
|
settings = dict(DEFAULT_SETTINGS)
|
|||
|
|
if SETTINGS_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
with open(SETTINGS_FILE, "r", encoding="utf-8") as handle:
|
|||
|
|
data = json.load(handle)
|
|||
|
|
if isinstance(data, dict):
|
|||
|
|
settings.update({k: data[k] for k in DEFAULT_SETTINGS if k in data})
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
pass
|
|||
|
|
return settings
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_settings(settings):
|
|||
|
|
try:
|
|||
|
|
with open(SETTINGS_FILE, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(settings, handle, ensure_ascii=False, indent=2)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
DEFAULT_TRANSLATIONS = {
|
|||
|
|
"en": {
|
|||
|
|
"app.title": "Ideogram 4 Prompt Builder",
|
|||
|
|
"tb.example": "Example",
|
|||
|
|
"tb.import": "Import JSON",
|
|||
|
|
"tb.save_json": "Save JSON",
|
|||
|
|
"tb.copy": "Copy",
|
|||
|
|
"tb.save_library": "Save to library",
|
|||
|
|
"tb.library": "Library",
|
|||
|
|
"tb.language": "Language:",
|
|||
|
|
"grp.high": "High level description",
|
|||
|
|
"high.placeholder": "One- or two-sentence summary of the full image...",
|
|||
|
|
"grp.presets": "Presets",
|
|||
|
|
"preset.no_safety": "Add \"No safety filter.\"",
|
|||
|
|
"grp.style": "Style",
|
|||
|
|
"style.photo": "Photo",
|
|||
|
|
"style.art": "Art",
|
|||
|
|
"style.aesthetics": "Aesthetics",
|
|||
|
|
"style.lighting": "Lighting",
|
|||
|
|
"style.photo_field": "Photo",
|
|||
|
|
"style.art_style": "Art style",
|
|||
|
|
"style.medium": "Medium",
|
|||
|
|
"style.palette": "Palette",
|
|||
|
|
"grp.composition": "Composition",
|
|||
|
|
"comp.background": "Background",
|
|||
|
|
"comp.background_placeholder": "Describe the environment or background before listing foreground elements...",
|
|||
|
|
"comp.add_element": "Add element",
|
|||
|
|
"comp.remove_element": "Remove element",
|
|||
|
|
"comp.hint": "Drag the rectangle to move the element. Drag the round handles to resize the bbox.",
|
|||
|
|
"el.type": "Type",
|
|||
|
|
"el.label": "Label",
|
|||
|
|
"el.text": "Text",
|
|||
|
|
"el.description": "Description",
|
|||
|
|
"el.palette": "Palette",
|
|||
|
|
"el.use_bbox": "Use bbox",
|
|||
|
|
"el.bbox": "BBox",
|
|||
|
|
"el.element": "Element",
|
|||
|
|
"out.title": "Ready JSON",
|
|||
|
|
"out.pretty": "Pretty",
|
|||
|
|
"out.compact": "Compact",
|
|||
|
|
"out.copy_compact": "Copy compact",
|
|||
|
|
"out.save_json_btn": "Save .json",
|
|||
|
|
"canvas.label": "bbox canvas 0-1000",
|
|||
|
|
"val.ok": "JSON assembled in Ideogram 4 key order and ready for ComfyUI.",
|
|||
|
|
"val.no_high": "Add high_level_description for better scene adherence.",
|
|||
|
|
"val.bg_required": "background is required.",
|
|||
|
|
"val.add_element": "Add at least one element.",
|
|||
|
|
"val.style_missing": "style_description is missing: {fields}.",
|
|||
|
|
"val.photo_or_art": "Exactly one key required: photo or art_style.",
|
|||
|
|
"val.hex_upper": "Color {color} must be uppercase #RRGGBB.",
|
|||
|
|
"val.text_literal": "{title}: text element requires a literal text.",
|
|||
|
|
"val.desc_required": "{title}: desc is required.",
|
|||
|
|
"val.bbox_order": "{title}: bbox must have y_max/x_max greater than y_min/x_min.",
|
|||
|
|
"val.el_hex": "{title}: color {color} must be uppercase #RRGGBB.",
|
|||
|
|
"val.element_word": "element {index}",
|
|||
|
|
"pal.placeholder": "#1E73BE, #FDFDFD",
|
|||
|
|
"pal.add": "Add color",
|
|||
|
|
"pal.configure": "Configure color",
|
|||
|
|
"pal.swatch_tip": "{color}: click to configure",
|
|||
|
|
"pal.remove": "Remove color",
|
|||
|
|
"dlg.save_json_title": "Save JSON",
|
|||
|
|
"dlg.json_filter": "JSON files (*.json);;All files (*)",
|
|||
|
|
"dlg.import_title": "Import JSON",
|
|||
|
|
"imp.error_title": "Import error",
|
|||
|
|
"trn.error_title": "Translate error",
|
|||
|
|
"trn.error_msg": "Translation failed:\n{err}",
|
|||
|
|
"trn.to_ru": "Translate to RU",
|
|||
|
|
"trn.to_en": "Translate to EN",
|
|||
|
|
"lib.name_prompt": "Prompt name:",
|
|||
|
|
"lib.untitled": "Untitled",
|
|||
|
|
"lib.preview_q_title": "Preview",
|
|||
|
|
"lib.preview_q": "Attach a preview image to this prompt?",
|
|||
|
|
"lib.save_fail": "Failed to save:\n{err}",
|
|||
|
|
"lib.saved": "Prompt \"{name}\" saved.",
|
|||
|
|
"prev.pick_title": "Choose preview image",
|
|||
|
|
"prev.filter": "Images (*.png *.jpg *.jpeg *.webp *.bmp);;All files (*)",
|
|||
|
|
"prev.save_fail": "Failed to save image:\n{err}",
|
|||
|
|
"prev.title": "Preview",
|
|||
|
|
"libd.title": "Prompt library",
|
|||
|
|
"libd.saved_prompts": "Saved prompts",
|
|||
|
|
"libd.no_preview": "No preview",
|
|||
|
|
"libd.preview_unavailable": "Preview unavailable",
|
|||
|
|
"libd.use": "Load into editor",
|
|||
|
|
"libd.rename": "Rename",
|
|||
|
|
"libd.set_preview": "Set preview",
|
|||
|
|
"libd.clear_preview": "Clear preview",
|
|||
|
|
"libd.delete": "Delete",
|
|||
|
|
"libd.close": "Close",
|
|||
|
|
"libd.rename_title": "Rename",
|
|||
|
|
"libd.rename_label": "Name:",
|
|||
|
|
"libd.delete_title": "Delete prompt",
|
|||
|
|
"libd.delete_q": "Delete \"{name}\" from the library?",
|
|||
|
|
"libd.meta": "Updated: {updated}\nElements: {count}\n\n{high}",
|
|||
|
|
"libd.no_high": "(no high_level_description)",
|
|||
|
|
"libd.search": "Search...",
|
|||
|
|
"libd.tags": "Tags (comma-separated):",
|
|||
|
|
"libd.tags_col": "Tags",
|
|||
|
|
"libd.paste_preview": "Paste preview from clipboard",
|
|||
|
|
"libd.export": "Export library...",
|
|||
|
|
"libd.import": "Import library...",
|
|||
|
|
"libd.no_clipboard_image": "No image in the clipboard.",
|
|||
|
|
"libd.export_done": "Library exported to:\n{path}",
|
|||
|
|
"libd.import_done": "Imported {count} prompt(s).",
|
|||
|
|
"libd.export_fail": "Export failed:\n{err}",
|
|||
|
|
"libd.import_fail": "Import failed:\n{err}",
|
|||
|
|
"libd.export_filter": "ZIP archive (*.zip)",
|
|||
|
|
"tb.undo": "Undo",
|
|||
|
|
"tb.redo": "Redo",
|
|||
|
|
"tb.duplicate": "Duplicate element",
|
|||
|
|
"tb.move_up": "Move up",
|
|||
|
|
"tb.move_down": "Move down",
|
|||
|
|
"tb.theme": "Theme",
|
|||
|
|
"tb.comfy_settings": "ComfyUI settings",
|
|||
|
|
"tb.generate": "Generate in ComfyUI",
|
|||
|
|
"tb.check_comfy": "Check ComfyUI",
|
|||
|
|
"tb.template": "Add from template",
|
|||
|
|
"tb.overwrite": "Update in library",
|
|||
|
|
"menu.file": "File",
|
|||
|
|
"menu.edit": "Edit",
|
|||
|
|
"menu.library": "Library",
|
|||
|
|
"menu.comfy": "ComfyUI",
|
|||
|
|
"menu.view": "View",
|
|||
|
|
"canvas.load_ref": "Reference image...",
|
|||
|
|
"canvas.paste_ref": "Paste reference",
|
|||
|
|
"canvas.clear_ref": "Clear reference",
|
|||
|
|
"canvas.zoom": "Grid scale",
|
|||
|
|
"canvas.ref_load_fail": "Could not load image.",
|
|||
|
|
"counter.colors": "{count}/{limit} colors",
|
|||
|
|
"set.title": "ComfyUI connection settings",
|
|||
|
|
"set.host": "Host:",
|
|||
|
|
"set.port": "Port:",
|
|||
|
|
"set.https": "Use HTTPS",
|
|||
|
|
"set.test": "Test connection",
|
|||
|
|
"set.test_ok": "Connection OK. ComfyUI is reachable.",
|
|||
|
|
"set.test_fail": "Connection failed:\n{err}",
|
|||
|
|
"set.saved": "Settings saved.",
|
|||
|
|
"comfy.checking": "Checking ComfyUI...",
|
|||
|
|
"comfy.check_title": "ComfyUI dependency check",
|
|||
|
|
"comfy.all_ok": "All required nodes and models are installed.",
|
|||
|
|
"comfy.unreachable": "ComfyUI is unreachable at {url}:\n{err}",
|
|||
|
|
"comfy.missing_header": "Missing on the server:",
|
|||
|
|
"comfy.missing_nodes": "Custom nodes: {items}",
|
|||
|
|
"comfy.missing_unet": "UNET models: {items}",
|
|||
|
|
"comfy.missing_vae": "VAE: {items}",
|
|||
|
|
"comfy.missing_clip": "CLIP: {items}",
|
|||
|
|
"comfy.missing_clip_gguf": "CLIP GGUF: {items}",
|
|||
|
|
"comfy.missing_samplers": "Samplers: {items}",
|
|||
|
|
"comfy.generating": "Generating in ComfyUI...",
|
|||
|
|
"comfy.gen_title": "Generation",
|
|||
|
|
"comfy.gen_fail": "Generation failed:\n{err}",
|
|||
|
|
"comfy.gen_done": "Image generated.",
|
|||
|
|
"tab.json": "JSON",
|
|||
|
|
"tab.result": "Result",
|
|||
|
|
"result.empty": "Generated image will appear here.",
|
|||
|
|
"result.save_lib": "Save to library",
|
|||
|
|
"result.save_file": "Save image...",
|
|||
|
|
"result.saved_file": "Image saved to:\n{path}",
|
|||
|
|
"result.png_filter": "PNG image (*.png)",
|
|||
|
|
"comfy.workflow_missing": "Workflow file not found:\n{path}",
|
|||
|
|
"comfy.deps_missing_continue": "Some dependencies are missing. Generate anyway?",
|
|||
|
|
"comfy.save_to_lib_q": "Save the generated image to the library?",
|
|||
|
|
"draft.restore_title": "Restore draft",
|
|||
|
|
"draft.restore_q": "An unsaved draft was found. Restore it?",
|
|||
|
|
"tpl.choose_title": "Element template",
|
|||
|
|
"tpl.choose_label": "Choose a template:",
|
|||
|
|
"common.cancel": "Cancel",
|
|||
|
|
"common.ok": "OK",
|
|||
|
|
"common.close": "Close",
|
|||
|
|
},
|
|||
|
|
"ru": {
|
|||
|
|
"app.title": "Ideogram 4 Prompt Builder",
|
|||
|
|
"tb.example": "Пример",
|
|||
|
|
"tb.import": "Импорт JSON",
|
|||
|
|
"tb.save_json": "Сохранить JSON",
|
|||
|
|
"tb.copy": "Копировать",
|
|||
|
|
"tb.save_library": "Сохранить в библиотеку",
|
|||
|
|
"tb.library": "Библиотека",
|
|||
|
|
"tb.language": "Язык:",
|
|||
|
|
"grp.high": "Общее описание",
|
|||
|
|
"high.placeholder": "Краткое описание всей сцены в одно-два предложения...",
|
|||
|
|
"grp.presets": "Пресеты",
|
|||
|
|
"preset.no_safety": "Добавить \"No safety filter.\"",
|
|||
|
|
"grp.style": "Стиль",
|
|||
|
|
"style.photo": "Фото",
|
|||
|
|
"style.art": "Арт",
|
|||
|
|
"style.aesthetics": "Эстетика",
|
|||
|
|
"style.lighting": "Освещение",
|
|||
|
|
"style.photo_field": "Фото",
|
|||
|
|
"style.art_style": "Арт-стиль",
|
|||
|
|
"style.medium": "Носитель",
|
|||
|
|
"style.palette": "Палитра",
|
|||
|
|
"grp.composition": "Композиция",
|
|||
|
|
"comp.background": "Фон",
|
|||
|
|
"comp.background_placeholder": "Опишите окружение или фон перед перечислением объектов переднего плана...",
|
|||
|
|
"comp.add_element": "Добавить элемент",
|
|||
|
|
"comp.remove_element": "Удалить элемент",
|
|||
|
|
"comp.hint": "Перетаскивайте прямоугольник, чтобы переместить элемент. Тяните круглые маркеры, чтобы масштабировать bbox.",
|
|||
|
|
"el.type": "Тип",
|
|||
|
|
"el.label": "Метка",
|
|||
|
|
"el.text": "Текст",
|
|||
|
|
"el.description": "Описание",
|
|||
|
|
"el.palette": "Палитра",
|
|||
|
|
"el.use_bbox": "Использовать bbox",
|
|||
|
|
"el.bbox": "BBox",
|
|||
|
|
"el.element": "Элемент",
|
|||
|
|
"out.title": "Готовый JSON",
|
|||
|
|
"out.pretty": "Pretty",
|
|||
|
|
"out.compact": "Compact",
|
|||
|
|
"out.copy_compact": "Копировать compact",
|
|||
|
|
"out.save_json_btn": "Сохранить .json",
|
|||
|
|
"canvas.label": "bbox canvas 0-1000",
|
|||
|
|
"val.ok": "JSON собран в порядке ключей Ideogram 4 и готов для ComfyUI.",
|
|||
|
|
"val.no_high": "Добавьте high_level_description для лучшего следования сцене.",
|
|||
|
|
"val.bg_required": "background обязателен.",
|
|||
|
|
"val.add_element": "Добавьте хотя бы один элемент.",
|
|||
|
|
"val.style_missing": "В style_description не хватает: {fields}.",
|
|||
|
|
"val.photo_or_art": "Нужен ровно один ключ: photo или art_style.",
|
|||
|
|
"val.hex_upper": "Цвет {color} должен быть uppercase #RRGGBB.",
|
|||
|
|
"val.text_literal": "{title}: для text-элемента нужен literal text.",
|
|||
|
|
"val.desc_required": "{title}: desc обязателен.",
|
|||
|
|
"val.bbox_order": "{title}: bbox должен иметь y_max/x_max больше y_min/x_min.",
|
|||
|
|
"val.el_hex": "{title}: цвет {color} должен быть uppercase #RRGGBB.",
|
|||
|
|
"val.element_word": "element {index}",
|
|||
|
|
"pal.placeholder": "#1E73BE, #FDFDFD",
|
|||
|
|
"pal.add": "Добавить цвет",
|
|||
|
|
"pal.configure": "Настроить цвет",
|
|||
|
|
"pal.swatch_tip": "{color}: нажмите, чтобы настроить",
|
|||
|
|
"pal.remove": "Удалить цвет",
|
|||
|
|
"dlg.save_json_title": "Сохранить JSON",
|
|||
|
|
"dlg.json_filter": "JSON файлы (*.json);;Все файлы (*)",
|
|||
|
|
"dlg.import_title": "Импорт JSON",
|
|||
|
|
"imp.error_title": "Ошибка импорта",
|
|||
|
|
"trn.error_title": "Ошибка перевода",
|
|||
|
|
"trn.error_msg": "Не удалось выполнить перевод:\n{err}",
|
|||
|
|
"trn.to_ru": "Перевести на RU",
|
|||
|
|
"trn.to_en": "Перевести на EN",
|
|||
|
|
"lib.name_prompt": "Название промта:",
|
|||
|
|
"lib.untitled": "Без названия",
|
|||
|
|
"lib.preview_q_title": "Превью",
|
|||
|
|
"lib.preview_q": "Добавить изображение превью к этому промту?",
|
|||
|
|
"lib.save_fail": "Не удалось сохранить:\n{err}",
|
|||
|
|
"lib.saved": "Промт «{name}» сохранён.",
|
|||
|
|
"prev.pick_title": "Выбрать изображение превью",
|
|||
|
|
"prev.filter": "Изображения (*.png *.jpg *.jpeg *.webp *.bmp);;Все файлы (*)",
|
|||
|
|
"prev.save_fail": "Не удалось сохранить изображение:\n{err}",
|
|||
|
|
"prev.title": "Превью",
|
|||
|
|
"libd.title": "Библиотека промтов",
|
|||
|
|
"libd.saved_prompts": "Сохранённые промты",
|
|||
|
|
"libd.no_preview": "Нет превью",
|
|||
|
|
"libd.preview_unavailable": "Превью недоступно",
|
|||
|
|
"libd.use": "Загрузить в редактор",
|
|||
|
|
"libd.rename": "Переименовать",
|
|||
|
|
"libd.set_preview": "Задать превью",
|
|||
|
|
"libd.clear_preview": "Убрать превью",
|
|||
|
|
"libd.delete": "Удалить",
|
|||
|
|
"libd.close": "Закрыть",
|
|||
|
|
"libd.rename_title": "Переименовать",
|
|||
|
|
"libd.rename_label": "Название:",
|
|||
|
|
"libd.delete_title": "Удалить промт",
|
|||
|
|
"libd.delete_q": "Удалить «{name}» из библиотеки?",
|
|||
|
|
"libd.meta": "Обновлено: {updated}\nЭлементов: {count}\n\n{high}",
|
|||
|
|
"libd.no_high": "(без high_level_description)",
|
|||
|
|
"libd.search": "Поиск...",
|
|||
|
|
"libd.tags": "Теги (через запятую):",
|
|||
|
|
"libd.tags_col": "Теги",
|
|||
|
|
"libd.paste_preview": "Вставить превью из буфера",
|
|||
|
|
"libd.export": "Экспорт библиотеки...",
|
|||
|
|
"libd.import": "Импорт библиотеки...",
|
|||
|
|
"libd.no_clipboard_image": "В буфере обмена нет изображения.",
|
|||
|
|
"libd.export_done": "Библиотека экспортирована в:\n{path}",
|
|||
|
|
"libd.import_done": "Импортировано промтов: {count}.",
|
|||
|
|
"libd.export_fail": "Не удалось экспортировать:\n{err}",
|
|||
|
|
"libd.import_fail": "Не удалось импортировать:\n{err}",
|
|||
|
|
"libd.export_filter": "ZIP архив (*.zip)",
|
|||
|
|
"tb.undo": "Отменить",
|
|||
|
|
"tb.redo": "Повторить",
|
|||
|
|
"tb.duplicate": "Дублировать элемент",
|
|||
|
|
"tb.move_up": "Вверх",
|
|||
|
|
"tb.move_down": "Вниз",
|
|||
|
|
"tb.theme": "Тема",
|
|||
|
|
"tb.comfy_settings": "Настройки ComfyUI",
|
|||
|
|
"tb.generate": "Сгенерировать в ComfyUI",
|
|||
|
|
"tb.check_comfy": "Проверить ComfyUI",
|
|||
|
|
"tb.template": "Добавить из шаблона",
|
|||
|
|
"tb.overwrite": "Обновить в библиотеке",
|
|||
|
|
"menu.file": "Файл",
|
|||
|
|
"menu.edit": "Правка",
|
|||
|
|
"menu.library": "Библиотека",
|
|||
|
|
"menu.comfy": "ComfyUI",
|
|||
|
|
"menu.view": "Вид",
|
|||
|
|
"canvas.load_ref": "Референс-изображение...",
|
|||
|
|
"canvas.paste_ref": "Вставить референс",
|
|||
|
|
"canvas.clear_ref": "Убрать референс",
|
|||
|
|
"canvas.zoom": "Масштаб сетки",
|
|||
|
|
"canvas.ref_load_fail": "Не удалось загрузить изображение.",
|
|||
|
|
"counter.colors": "{count}/{limit} цветов",
|
|||
|
|
"set.title": "Настройки соединения с ComfyUI",
|
|||
|
|
"set.host": "Хост:",
|
|||
|
|
"set.port": "Порт:",
|
|||
|
|
"set.https": "Использовать HTTPS",
|
|||
|
|
"set.test": "Проверить соединение",
|
|||
|
|
"set.test_ok": "Соединение установлено. ComfyUI доступен.",
|
|||
|
|
"set.test_fail": "Не удалось подключиться:\n{err}",
|
|||
|
|
"set.saved": "Настройки сохранены.",
|
|||
|
|
"comfy.checking": "Проверка ComfyUI...",
|
|||
|
|
"comfy.check_title": "Проверка зависимостей ComfyUI",
|
|||
|
|
"comfy.all_ok": "Все необходимые ноды и модели установлены.",
|
|||
|
|
"comfy.unreachable": "ComfyUI недоступен по адресу {url}:\n{err}",
|
|||
|
|
"comfy.missing_header": "Отсутствует на сервере:",
|
|||
|
|
"comfy.missing_nodes": "Кастомные ноды: {items}",
|
|||
|
|
"comfy.missing_unet": "UNET-модели: {items}",
|
|||
|
|
"comfy.missing_vae": "VAE: {items}",
|
|||
|
|
"comfy.missing_clip": "CLIP: {items}",
|
|||
|
|
"comfy.missing_clip_gguf": "CLIP GGUF: {items}",
|
|||
|
|
"comfy.missing_samplers": "Семплеры: {items}",
|
|||
|
|
"comfy.generating": "Генерация в ComfyUI...",
|
|||
|
|
"comfy.gen_title": "Генерация",
|
|||
|
|
"comfy.gen_fail": "Не удалось сгенерировать:\n{err}",
|
|||
|
|
"comfy.gen_done": "Изображение сгенерировано.",
|
|||
|
|
"tab.json": "JSON",
|
|||
|
|
"tab.result": "Результат",
|
|||
|
|
"result.empty": "Здесь появится сгенерированное изображение.",
|
|||
|
|
"result.save_lib": "Сохранить в библиотеку",
|
|||
|
|
"result.save_file": "Сохранить изображение...",
|
|||
|
|
"result.saved_file": "Изображение сохранено в:\n{path}",
|
|||
|
|
"result.png_filter": "PNG изображение (*.png)",
|
|||
|
|
"comfy.workflow_missing": "Файл workflow не найден:\n{path}",
|
|||
|
|
"comfy.deps_missing_continue": "Некоторые зависимости отсутствуют. Всё равно сгенерировать?",
|
|||
|
|
"comfy.save_to_lib_q": "Сохранить сгенерированное изображение в библиотеку?",
|
|||
|
|
"draft.restore_title": "Восстановить черновик",
|
|||
|
|
"draft.restore_q": "Найден несохранённый черновик. Восстановить его?",
|
|||
|
|
"tpl.choose_title": "Шаблон элемента",
|
|||
|
|
"tpl.choose_label": "Выберите шаблон:",
|
|||
|
|
"common.cancel": "Отмена",
|
|||
|
|
"common.ok": "ОК",
|
|||
|
|
"common.close": "Закрыть",
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ensure_translation_file():
|
|||
|
|
"""Write the bundled translations to disk if no file exists yet."""
|
|||
|
|
if not LANG_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
with open(LANG_FILE, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(DEFAULT_TRANSLATIONS, handle, ensure_ascii=False, indent=2)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_translations():
|
|||
|
|
"""Load translations from the external file, falling back to bundled defaults."""
|
|||
|
|
ensure_translation_file()
|
|||
|
|
try:
|
|||
|
|
with open(LANG_FILE, "r", encoding="utf-8") as handle:
|
|||
|
|
data = json.load(handle)
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
data = {}
|
|||
|
|
if not isinstance(data, dict) or not data:
|
|||
|
|
return {lang: dict(strings) for lang, strings in DEFAULT_TRANSLATIONS.items()}
|
|||
|
|
return data
|
|||
|
|
|
|||
|
|
|
|||
|
|
TRANSLATIONS = load_translations()
|
|||
|
|
_saved_lang = load_settings().get("language", DEFAULT_LANGUAGE)
|
|||
|
|
if _saved_lang in TRANSLATIONS:
|
|||
|
|
CURRENT_LANG = _saved_lang
|
|||
|
|
elif DEFAULT_LANGUAGE in TRANSLATIONS:
|
|||
|
|
CURRENT_LANG = DEFAULT_LANGUAGE
|
|||
|
|
else:
|
|||
|
|
CURRENT_LANG = next(iter(TRANSLATIONS))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def available_languages():
|
|||
|
|
return list(TRANSLATIONS.keys())
|
|||
|
|
|
|||
|
|
|
|||
|
|
def tr(key):
|
|||
|
|
"""Translate a key into the current language, falling back to English then the key."""
|
|||
|
|
for source in (TRANSLATIONS.get(CURRENT_LANG), TRANSLATIONS.get("en"),
|
|||
|
|
DEFAULT_TRANSLATIONS.get(CURRENT_LANG), DEFAULT_TRANSLATIONS.get("en")):
|
|||
|
|
if source and key in source:
|
|||
|
|
return source[key]
|
|||
|
|
return key
|
|||
|
|
|
|||
|
|
|
|||
|
|
EXAMPLE_CAPTION = {
|
|||
|
|
"high_level_description": (
|
|||
|
|
"A surreal streetwear mixed-media collage poster featuring a relaxed skateboarder mid-air "
|
|||
|
|
"against a vibrant blue sky, backed by giant puffy 3D letters spelling 'COMFY'."
|
|||
|
|
),
|
|||
|
|
"style_description": {
|
|||
|
|
"aesthetics": "retro magazine cutout style, mixed-media digital collage, high-contrast streetwear graphic",
|
|||
|
|
"lighting": "high-contrast flash mixed with harsh midday sunlight, flat bright graphic lighting on typography",
|
|||
|
|
"photo": "vintage grainy 35mm film, distressed halftone scan textures",
|
|||
|
|
"medium": "mixed-media digital collage",
|
|||
|
|
"color_palette": ["#1E73BE", "#FDFDFD", "#C82A2A", "#657C9C", "#EFEFEF"],
|
|||
|
|
},
|
|||
|
|
"compositional_deconstruction": {
|
|||
|
|
"background": "A vibrant, clear blue sky layered with vintage grainy film texture and subtle halftone dot patterns.",
|
|||
|
|
"elements": [
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"bbox": [128, 149, 354, 810],
|
|||
|
|
"desc": "Massive 3D puffy white typography spelling 'COMFY' across the upper half of the canvas.",
|
|||
|
|
"color_palette": ["#FDFDFD", "#E0E0E0", "#D3DBE2"],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"bbox": [287, 210, 756, 819],
|
|||
|
|
"desc": "A sharp photographic cutout of a skateboarder mid-air with a distinct white cutout border.",
|
|||
|
|
"color_palette": ["#FDFDFD", "#657C9C", "#2B2B2B", "#DCA57D"],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "text",
|
|||
|
|
"bbox": [105, 830, 905, 980],
|
|||
|
|
"text": "BEYOND THE COMFORT ZONE",
|
|||
|
|
"desc": "Bold black sans-serif text printed on a wide torn paper strip along the lower third.",
|
|||
|
|
"color_palette": ["#EFEFEF", "#1A1A1A", "#999999"],
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
PROMPT_PRESETS = {
|
|||
|
|
"Adult beach photo": {
|
|||
|
|
"mode": "photo",
|
|||
|
|
"high": (
|
|||
|
|
"A nude beach photograph of an adult woman standing on pale sand near the shoreline, "
|
|||
|
|
"looking directly at the camera with the ocean horizon and clear blue sky behind her."
|
|||
|
|
),
|
|||
|
|
"aesthetics": "natural, sunlit, candid, tasteful adult glamour photography",
|
|||
|
|
"lighting": "bright coastal daylight, clean shadows, soft reflected light from pale sand",
|
|||
|
|
"photo": "full-body beach photography, 50mm lens, natural skin texture, realistic proportions",
|
|||
|
|
"medium": "photograph",
|
|||
|
|
"palette": ["#E7B48D", "#F5D0B8", "#F2E8DA", "#62A9D5", "#F6E8C8"],
|
|||
|
|
"background": "A quiet tropical shoreline with pale sand, soft foamy waves, a distant ocean horizon, and a clear blue sky.",
|
|||
|
|
"elements": [
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"label": "Adult woman",
|
|||
|
|
"bbox": [120, 320, 950, 690],
|
|||
|
|
"desc": "An adult woman with realistic skin texture and natural posture, standing barefoot on pale sand near the shoreline.",
|
|||
|
|
"color_palette": ["#E7B48D", "#F5D0B8", "#F2E8DA"],
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
"Boudoir editorial": {
|
|||
|
|
"mode": "photo",
|
|||
|
|
"high": "A sensual boudoir editorial photograph of an adult woman reclining on rumpled white sheets in a softly lit private bedroom.",
|
|||
|
|
"aesthetics": "intimate, elegant, editorial, warm, sensual",
|
|||
|
|
"lighting": "soft window light, gentle highlights on skin, low contrast shadows",
|
|||
|
|
"photo": "85mm portrait lens, shallow depth of field, natural skin detail, tasteful composition",
|
|||
|
|
"medium": "photograph",
|
|||
|
|
"palette": ["#F7EFE7", "#D8A181", "#8B5E4A", "#FFFFFF", "#2F2522"],
|
|||
|
|
"background": "A quiet private bedroom with rumpled white sheets, warm neutral walls, and soft morning light through sheer curtains.",
|
|||
|
|
"elements": [
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"label": "Adult model",
|
|||
|
|
"bbox": [180, 170, 880, 840],
|
|||
|
|
"desc": "An adult woman reclining on white sheets in an elegant boudoir pose, styled as a tasteful editorial photograph.",
|
|||
|
|
"color_palette": ["#F7EFE7", "#D8A181", "#8B5E4A"],
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
"Fine-art nude": {
|
|||
|
|
"mode": "photo",
|
|||
|
|
"high": (
|
|||
|
|
"A fine-art nude studio photograph of an adult figure posed against a dark seamless backdrop, "
|
|||
|
|
"emphasizing silhouette, form, and sculptural lighting."
|
|||
|
|
),
|
|||
|
|
"aesthetics": "minimal, sculptural, gallery-grade, refined, dramatic",
|
|||
|
|
"lighting": "single softbox side light, strong chiaroscuro, controlled studio shadows",
|
|||
|
|
"photo": "black and white fine-art photography, medium format look, crisp tonal range",
|
|||
|
|
"medium": "photograph",
|
|||
|
|
"palette": ["#111111", "#E6E0D8", "#8F8A84", "#FFFFFF"],
|
|||
|
|
"background": "A dark seamless studio backdrop with subtle falloff and no visible props.",
|
|||
|
|
"elements": [
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"label": "Adult figure",
|
|||
|
|
"bbox": [90, 250, 960, 760],
|
|||
|
|
"desc": "An adult nude figure posed with an elegant sculptural silhouette, photographed as fine art with emphasis on form and light.",
|
|||
|
|
"color_palette": ["#111111", "#E6E0D8", "#8F8A84"],
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
"Pin-up poster": {
|
|||
|
|
"mode": "art",
|
|||
|
|
"high": "A retro adult pin-up poster illustration with a confident glamour model, bold typography, and polished mid-century advertising composition.",
|
|||
|
|
"aesthetics": "playful, glossy, retro, high-contrast, adult glamour",
|
|||
|
|
"lighting": "painted studio highlights, warm key light, crisp graphic shadows",
|
|||
|
|
"art_style": "mid-century pin-up illustration, clean outlines, poster-ready typography",
|
|||
|
|
"medium": "illustration",
|
|||
|
|
"palette": ["#F2B99B", "#E4433B", "#1D3557", "#FFF1C7", "#FFFFFF"],
|
|||
|
|
"background": "A clean vintage poster background with a radial burst, decorative stars, and generous negative space for title text.",
|
|||
|
|
"elements": [
|
|||
|
|
{
|
|||
|
|
"type": "obj",
|
|||
|
|
"label": "Pin-up model",
|
|||
|
|
"bbox": [160, 260, 900, 720],
|
|||
|
|
"desc": "A confident adult pin-up model in a stylized glamour pose, rendered with polished retro illustration details.",
|
|||
|
|
"color_palette": ["#F2B99B", "#E4433B", "#1D3557"],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"type": "text",
|
|||
|
|
"label": "Title",
|
|||
|
|
"text": "MIDNIGHT GLAMOUR",
|
|||
|
|
"bbox": [70, 120, 170, 880],
|
|||
|
|
"desc": "Large cream-colored retro display lettering arched across the top of the poster.",
|
|||
|
|
"color_palette": ["#FFF1C7", "#1D3557"],
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def normalize_hex(value):
|
|||
|
|
value = value.strip().upper()
|
|||
|
|
if value and not value.startswith("#"):
|
|||
|
|
value = f"#{value}"
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_palette(value, limit):
|
|||
|
|
colors = []
|
|||
|
|
for raw in value.split(","):
|
|||
|
|
color = normalize_hex(raw)
|
|||
|
|
if color:
|
|||
|
|
colors.append(color)
|
|||
|
|
return colors[:limit]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def palette_text(colors):
|
|||
|
|
return ", ".join(colors or [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def clamp(value, lower=0, upper=1000):
|
|||
|
|
return max(lower, min(upper, int(round(value))))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def google_translate_text(text, target_language):
|
|||
|
|
query = urllib.parse.urlencode(
|
|||
|
|
{
|
|||
|
|
"client": "gtx",
|
|||
|
|
"sl": "auto",
|
|||
|
|
"tl": target_language,
|
|||
|
|
"dt": "t",
|
|||
|
|
"q": text,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
request = urllib.request.Request(
|
|||
|
|
f"https://translate.googleapis.com/translate_a/single?{query}",
|
|||
|
|
headers={"User-Agent": "Mozilla/5.0"},
|
|||
|
|
)
|
|||
|
|
with urllib.request.urlopen(request, timeout=12) as response:
|
|||
|
|
payload = json.loads(response.read().decode("utf-8"))
|
|||
|
|
return "".join(part[0] for part in payload[0] if part and part[0]).strip()
|
|||
|
|
|
|||
|
|
|
|||
|
|
_TRANSLATION_CACHE = {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def cached_translate(text, target_language):
|
|||
|
|
"""Translate with an in-memory cache (item 13) to avoid repeat network calls."""
|
|||
|
|
key = (target_language, text)
|
|||
|
|
if key in _TRANSLATION_CACHE:
|
|||
|
|
return _TRANSLATION_CACHE[key]
|
|||
|
|
result = google_translate_text(text, target_language)
|
|||
|
|
if result:
|
|||
|
|
_TRANSLATION_CACHE[key] = result
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
|
|||
|
|
# --- ComfyUI integration --------------------------------------------------
|
|||
|
|
|
|||
|
|
class ComfyError(Exception):
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_base_url(settings):
|
|||
|
|
scheme = "https" if settings.get("comfy_https") else "http"
|
|||
|
|
return f"{scheme}://{settings.get('comfy_host', '127.0.0.1')}:{settings.get('comfy_port', 8188)}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_get(settings, path, timeout=10):
|
|||
|
|
url = f"{comfy_base_url(settings)}{path}"
|
|||
|
|
request = urllib.request.Request(url, headers={"User-Agent": "IdeogramPromptBuilder"})
|
|||
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|||
|
|
return json.loads(response.read().decode("utf-8"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_post(settings, path, payload, timeout=15):
|
|||
|
|
url = f"{comfy_base_url(settings)}{path}"
|
|||
|
|
data = json.dumps(payload).encode("utf-8")
|
|||
|
|
request = urllib.request.Request(
|
|||
|
|
url, data=data, headers={"Content-Type": "application/json", "User-Agent": "IdeogramPromptBuilder"}
|
|||
|
|
)
|
|||
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|||
|
|
return json.loads(response.read().decode("utf-8"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_test_connection(settings):
|
|||
|
|
"""Raise ComfyError if the server is not reachable, else return system stats."""
|
|||
|
|
try:
|
|||
|
|
return comfy_get(settings, "/system_stats", timeout=6)
|
|||
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
|||
|
|
raise ComfyError(str(error))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_object_info(settings):
|
|||
|
|
try:
|
|||
|
|
return comfy_get(settings, "/object_info", timeout=20)
|
|||
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
|||
|
|
raise ComfyError(str(error))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _combo_values(object_info, node, input_name):
|
|||
|
|
"""Return the list of allowed values for a combo input of a node, or []."""
|
|||
|
|
try:
|
|||
|
|
spec = object_info[node]["input"]
|
|||
|
|
for section in ("required", "optional"):
|
|||
|
|
if input_name in spec.get(section, {}):
|
|||
|
|
values = spec[section][input_name][0]
|
|||
|
|
return values if isinstance(values, list) else []
|
|||
|
|
except (KeyError, TypeError, IndexError):
|
|||
|
|
pass
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def check_comfy_dependencies(settings):
|
|||
|
|
"""Compare REQUIRED_COMFY against a live server. Returns dict of missing items."""
|
|||
|
|
info = comfy_object_info(settings)
|
|||
|
|
available_nodes = set(info.keys())
|
|||
|
|
missing = {
|
|||
|
|
"nodes": [n for n in REQUIRED_COMFY["nodes"] if n not in available_nodes],
|
|||
|
|
"unet": [], "vae": [], "clip": [], "clip_gguf": [], "samplers": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
unet_values = set(_combo_values(info, "UNETLoader", "unet_name"))
|
|||
|
|
for model in REQUIRED_COMFY["unet"]:
|
|||
|
|
if unet_values and model not in unet_values:
|
|||
|
|
missing["unet"].append(model)
|
|||
|
|
|
|||
|
|
vae_values = set(_combo_values(info, "VAELoader", "vae_name"))
|
|||
|
|
for model in REQUIRED_COMFY["vae"]:
|
|||
|
|
if vae_values and model not in vae_values:
|
|||
|
|
missing["vae"].append(model)
|
|||
|
|
|
|||
|
|
clip_values = set(_combo_values(info, "CLIPLoader", "clip_name"))
|
|||
|
|
for model in REQUIRED_COMFY["clip"]:
|
|||
|
|
if clip_values and model not in clip_values:
|
|||
|
|
missing["clip"].append(model)
|
|||
|
|
|
|||
|
|
gguf_values = set(_combo_values(info, "CLIPLoaderGGUF", "clip_name"))
|
|||
|
|
for model in REQUIRED_COMFY["clip_gguf"]:
|
|||
|
|
if "CLIPLoaderGGUF" not in available_nodes:
|
|||
|
|
missing["clip_gguf"].append(model)
|
|||
|
|
elif gguf_values and model not in gguf_values:
|
|||
|
|
missing["clip_gguf"].append(model)
|
|||
|
|
|
|||
|
|
sampler_values = set(_combo_values(info, "KSamplerSelect", "sampler_name"))
|
|||
|
|
for sampler in REQUIRED_COMFY["samplers"]:
|
|||
|
|
if sampler_values and sampler not in sampler_values:
|
|||
|
|
missing["samplers"].append(sampler)
|
|||
|
|
|
|||
|
|
return missing
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _input_name_by_slot(node, slot):
|
|||
|
|
inputs = node.get("inputs", [])
|
|||
|
|
if 0 <= slot < len(inputs):
|
|||
|
|
return inputs[slot].get("name")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
WIDGET_SCALAR_TYPES = {"INT", "FLOAT", "STRING", "BOOLEAN", "COMBO"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _is_widget_type(type_spec):
|
|||
|
|
# Combos arrive either as a raw list of options or as the literal "COMBO" string,
|
|||
|
|
# depending on the ComfyUI version; both render a widget.
|
|||
|
|
return isinstance(type_spec, list) or type_spec in WIDGET_SCALAR_TYPES
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _make_api_entry(node, object_info):
|
|||
|
|
"""Convert a UI node into an API entry, mapping widget values to input names.
|
|||
|
|
|
|||
|
|
When ``object_info`` describes the node class, widget values are mapped to the
|
|||
|
|
authoritative widget input order (combos and INT/FLOAT/STRING/BOOLEAN), skipping the
|
|||
|
|
extra value that ``control_after_generate`` inputs (e.g. seeds) store. Links override
|
|||
|
|
these defaults later. Falls back to the UI ``inputs`` widget flags when the class is
|
|||
|
|
unknown.
|
|||
|
|
"""
|
|||
|
|
entry = {"class_type": node["type"], "inputs": {}}
|
|||
|
|
widgets = node.get("widgets_values", []) or []
|
|||
|
|
if not isinstance(widgets, list):
|
|||
|
|
return entry
|
|||
|
|
spec = (object_info.get(node["type"], {}) or {}).get("input", {}) if object_info else {}
|
|||
|
|
if spec:
|
|||
|
|
ordered = list(spec.get("required", {}).items()) + list(spec.get("optional", {}).items())
|
|||
|
|
idx = 0
|
|||
|
|
for name, definition in ordered:
|
|||
|
|
type_spec = definition[0] if definition else None
|
|||
|
|
if not _is_widget_type(type_spec):
|
|||
|
|
continue
|
|||
|
|
if idx >= len(widgets):
|
|||
|
|
break
|
|||
|
|
entry["inputs"][name] = widgets[idx]
|
|||
|
|
idx += 1
|
|||
|
|
options = definition[1] if len(definition) > 1 and isinstance(definition[1], dict) else {}
|
|||
|
|
if options.get("control_after_generate"):
|
|||
|
|
idx += 1 # widgets_values stores the control value right after the widget
|
|||
|
|
return entry
|
|||
|
|
wi = 0
|
|||
|
|
for inp in node.get("inputs", []):
|
|||
|
|
if inp.get("widget"):
|
|||
|
|
if wi < len(widgets):
|
|||
|
|
entry["inputs"][inp["name"]] = widgets[wi]
|
|||
|
|
wi += 1
|
|||
|
|
return entry
|
|||
|
|
|
|||
|
|
|
|||
|
|
def workflow_to_api_prompt(workflow, compact_caption, seed, object_info=None):
|
|||
|
|
"""Convert the bundled UI workflow (with its subgraph) into a ComfyUI API prompt.
|
|||
|
|
|
|||
|
|
Subgraph instances are flattened: internal nodes are namespaced, internal links are
|
|||
|
|
wired by id, and the subgraph boundary (instance inputs/outputs) is resolved so that
|
|||
|
|
top-level wiring crosses into the subgraph correctly. The builder's caption is injected
|
|||
|
|
into CLIPTextEncode (which prunes the original prompt-builder branch) and a fresh seed
|
|||
|
|
into RandomNoise.
|
|||
|
|
"""
|
|||
|
|
subgraph_defs = {sg["id"]: sg for sg in workflow.get("definitions", {}).get("subgraphs", [])}
|
|||
|
|
prompt = {}
|
|||
|
|
|
|||
|
|
def key(scope, nid):
|
|||
|
|
return str(nid) if scope is None else f"{scope}_{nid}"
|
|||
|
|
|
|||
|
|
instances = [] # (instance_node, subgraph_def, scope)
|
|||
|
|
for node in workflow.get("nodes", []):
|
|||
|
|
node_type = node.get("type")
|
|||
|
|
if node_type == "MarkdownNote":
|
|||
|
|
continue
|
|||
|
|
if node_type in subgraph_defs:
|
|||
|
|
sg = subgraph_defs[node_type]
|
|||
|
|
scope = str(node["id"])
|
|||
|
|
instances.append((node, sg, scope))
|
|||
|
|
for child in sg.get("nodes", []):
|
|||
|
|
if child.get("type") == "MarkdownNote":
|
|||
|
|
continue
|
|||
|
|
prompt[key(scope, child["id"])] = _make_api_entry(child, object_info)
|
|||
|
|
else:
|
|||
|
|
prompt[str(node["id"])] = _make_api_entry(node, object_info)
|
|||
|
|
|
|||
|
|
# Wire internal subgraph links and build boundary maps per instance.
|
|||
|
|
boundary_in = {} # scope -> {input_name: [(internal_node_id, internal_slot), ...]}
|
|||
|
|
boundary_out = {} # scope -> {output_name: (internal_node_id, internal_slot)}
|
|||
|
|
for node, sg, scope in instances:
|
|||
|
|
internal_ids = {n["id"] for n in sg.get("nodes", [])}
|
|||
|
|
node_by_id = {n["id"]: n for n in sg.get("nodes", [])}
|
|||
|
|
link_by_id = {l["id"]: l for l in sg.get("links", [])}
|
|||
|
|
|
|||
|
|
for link in sg.get("links", []):
|
|||
|
|
origin, target = link.get("origin_id"), link.get("target_id")
|
|||
|
|
if origin in internal_ids and target in internal_ids:
|
|||
|
|
tnode = node_by_id.get(target)
|
|||
|
|
name = _input_name_by_slot(tnode, link.get("target_slot", 0))
|
|||
|
|
if name:
|
|||
|
|
prompt[key(scope, target)]["inputs"][name] = [key(scope, origin), link.get("origin_slot", 0)]
|
|||
|
|
|
|||
|
|
in_map = {}
|
|||
|
|
for sg_input in sg.get("inputs", []):
|
|||
|
|
targets = []
|
|||
|
|
for lid in sg_input.get("linkIds", []):
|
|||
|
|
link = link_by_id.get(lid)
|
|||
|
|
if link and link.get("target_id") in internal_ids:
|
|||
|
|
targets.append((link["target_id"], link.get("target_slot", 0)))
|
|||
|
|
in_map[sg_input["name"]] = targets
|
|||
|
|
boundary_in[scope] = in_map
|
|||
|
|
|
|||
|
|
out_map = {}
|
|||
|
|
for sg_output in sg.get("outputs", []):
|
|||
|
|
for lid in sg_output.get("linkIds", []):
|
|||
|
|
link = link_by_id.get(lid)
|
|||
|
|
if link and link.get("origin_id") in internal_ids:
|
|||
|
|
out_map[sg_output["name"]] = (link["origin_id"], link.get("origin_slot", 0))
|
|||
|
|
break
|
|||
|
|
boundary_out[scope] = out_map
|
|||
|
|
|
|||
|
|
instance_by_id = {str(node["id"]): (node, sg, scope) for node, sg, scope in instances}
|
|||
|
|
|
|||
|
|
def resolve_source(origin_id, origin_slot):
|
|||
|
|
"""Return [node_key, slot] for a link origin, crossing subgraph output boundaries."""
|
|||
|
|
sid = str(origin_id)
|
|||
|
|
if sid in instance_by_id:
|
|||
|
|
node, sg, scope = instance_by_id[sid]
|
|||
|
|
out_name = _input_name_by_slot({"inputs": node.get("outputs", [])}, origin_slot)
|
|||
|
|
internal = boundary_out.get(scope, {}).get(out_name)
|
|||
|
|
if internal:
|
|||
|
|
return [key(scope, internal[0]), internal[1]]
|
|||
|
|
return None
|
|||
|
|
return [sid, origin_slot]
|
|||
|
|
|
|||
|
|
# Wire top-level links, crossing into subgraph instances where needed.
|
|||
|
|
for link in workflow.get("links", []):
|
|||
|
|
if not isinstance(link, list) or len(link) < 6:
|
|||
|
|
continue
|
|||
|
|
_lid, oid, oslot, tid, tslot, _type = link[:6]
|
|||
|
|
source = resolve_source(oid, oslot)
|
|||
|
|
if source is None:
|
|||
|
|
continue
|
|||
|
|
tkey = str(tid)
|
|||
|
|
if tkey in instance_by_id:
|
|||
|
|
node, sg, scope = instance_by_id[tkey]
|
|||
|
|
inst_input = node.get("inputs", [])
|
|||
|
|
in_name = inst_input[tslot].get("name") if 0 <= tslot < len(inst_input) else None
|
|||
|
|
for (tnode, tnslot) in boundary_in.get(scope, {}).get(in_name, []):
|
|||
|
|
name = _input_name_by_slot({"inputs": sg_node_inputs(sg, tnode)}, tnslot)
|
|||
|
|
if name:
|
|||
|
|
prompt[key(scope, tnode)]["inputs"][name] = source
|
|||
|
|
elif tkey in prompt:
|
|||
|
|
tnode = next((n for n in workflow.get("nodes", []) if str(n.get("id")) == tkey), None)
|
|||
|
|
name = _input_name_by_slot(tnode, tslot) if tnode else None
|
|||
|
|
if name:
|
|||
|
|
prompt[tkey]["inputs"][name] = source
|
|||
|
|
|
|||
|
|
# Inject builder data; overriding CLIPTextEncode.text prunes the prompt-builder branch.
|
|||
|
|
for entry in prompt.values():
|
|||
|
|
if entry["class_type"] == "CLIPTextEncode":
|
|||
|
|
entry["inputs"]["text"] = compact_caption
|
|||
|
|
elif entry["class_type"] == "RandomNoise":
|
|||
|
|
entry["inputs"]["noise_seed"] = seed
|
|||
|
|
return prompt
|
|||
|
|
|
|||
|
|
|
|||
|
|
def sg_node_inputs(sg, node_id):
|
|||
|
|
for node in sg.get("nodes", []):
|
|||
|
|
if node.get("id") == node_id:
|
|||
|
|
return node.get("inputs", [])
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def find_save_image_node(prompt):
|
|||
|
|
for node_id, entry in prompt.items():
|
|||
|
|
if entry.get("class_type") in ("SaveImage", "PreviewImage"):
|
|||
|
|
return node_id
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def comfy_generate(settings, workflow, compact_caption, seed, should_cancel=None):
|
|||
|
|
"""Submit the workflow to ComfyUI and return raw PNG bytes of the first output image."""
|
|||
|
|
try:
|
|||
|
|
object_info = comfy_object_info(settings)
|
|||
|
|
except ComfyError:
|
|||
|
|
object_info = None
|
|||
|
|
prompt = workflow_to_api_prompt(workflow, compact_caption, seed, object_info)
|
|||
|
|
try:
|
|||
|
|
result = comfy_post(settings, "/prompt", {"prompt": prompt})
|
|||
|
|
except urllib.error.HTTPError as error:
|
|||
|
|
detail = ""
|
|||
|
|
try:
|
|||
|
|
detail = error.read().decode("utf-8", "replace")
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
raise ComfyError(f"HTTP {error.code}: {detail[:400]}")
|
|||
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
|||
|
|
raise ComfyError(str(error))
|
|||
|
|
prompt_id = result.get("prompt_id")
|
|||
|
|
if not prompt_id:
|
|||
|
|
raise ComfyError(json.dumps(result)[:300])
|
|||
|
|
|
|||
|
|
for _ in range(600): # up to ~5 minutes
|
|||
|
|
if should_cancel and should_cancel():
|
|||
|
|
raise ComfyError("cancelled")
|
|||
|
|
try:
|
|||
|
|
history = comfy_get(settings, f"/history/{prompt_id}", timeout=10)
|
|||
|
|
except (urllib.error.URLError, TimeoutError, OSError, json.JSONDecodeError) as error:
|
|||
|
|
raise ComfyError(str(error))
|
|||
|
|
if prompt_id in history:
|
|||
|
|
outputs = history[prompt_id].get("outputs", {})
|
|||
|
|
for node_output in outputs.values():
|
|||
|
|
for image in node_output.get("images", []):
|
|||
|
|
query = urllib.parse.urlencode(
|
|||
|
|
{"filename": image["filename"], "subfolder": image.get("subfolder", ""),
|
|||
|
|
"type": image.get("type", "output")}
|
|||
|
|
)
|
|||
|
|
url = f"{comfy_base_url(settings)}/view?{query}"
|
|||
|
|
request = urllib.request.Request(url, headers={"User-Agent": "IdeogramPromptBuilder"})
|
|||
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|||
|
|
return response.read()
|
|||
|
|
raise ComfyError("No image in workflow output.")
|
|||
|
|
time.sleep(0.5)
|
|||
|
|
raise ComfyError("Timed out waiting for generation.")
|
|||
|
|
|
|||
|
|
|
|||
|
|
class GenerationThread(QThread):
|
|||
|
|
finished_ok = pyqtSignal(bytes)
|
|||
|
|
failed = pyqtSignal(str)
|
|||
|
|
|
|||
|
|
def __init__(self, settings, workflow, caption, seed, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.settings = settings
|
|||
|
|
self.workflow = workflow
|
|||
|
|
self.caption = caption
|
|||
|
|
self.seed = seed
|
|||
|
|
self._cancel = False
|
|||
|
|
|
|||
|
|
def cancel(self):
|
|||
|
|
self._cancel = True
|
|||
|
|
|
|||
|
|
def run(self):
|
|||
|
|
try:
|
|||
|
|
data = comfy_generate(
|
|||
|
|
self.settings, self.workflow, self.caption, self.seed, lambda: self._cancel,
|
|||
|
|
)
|
|||
|
|
self.finished_ok.emit(data)
|
|||
|
|
except ComfyError as error:
|
|||
|
|
self.failed.emit(str(error))
|
|||
|
|
except Exception as error: # noqa: BLE001 - surface anything to the UI
|
|||
|
|
self.failed.emit(str(error))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_library():
|
|||
|
|
"""Read the prompt library from disk, returning a list of entries."""
|
|||
|
|
if LIBRARY_FILE.exists():
|
|||
|
|
try:
|
|||
|
|
with open(LIBRARY_FILE, "r", encoding="utf-8") as handle:
|
|||
|
|
data = json.load(handle)
|
|||
|
|
if isinstance(data, list):
|
|||
|
|
return data
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return []
|
|||
|
|
return []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def save_library(entries):
|
|||
|
|
"""Persist the prompt library to disk."""
|
|||
|
|
with open(LIBRARY_FILE, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(entries, handle, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def preview_file(entry):
|
|||
|
|
"""Resolve an entry's preview image to an existing path, or None."""
|
|||
|
|
name = entry.get("preview")
|
|||
|
|
if not name:
|
|||
|
|
return None
|
|||
|
|
path = PREVIEW_DIR / name
|
|||
|
|
return path if path.exists() else None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def remove_preview_file(entry):
|
|||
|
|
"""Delete the preview image associated with an entry, if any."""
|
|||
|
|
path = preview_file(entry)
|
|||
|
|
if path:
|
|||
|
|
try:
|
|||
|
|
path.unlink()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
entry["preview"] = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def attach_preview(entry, parent):
|
|||
|
|
"""Pick an image file and copy it into PREVIEW_DIR as this entry's preview."""
|
|||
|
|
path, _filter = QFileDialog.getOpenFileName(
|
|||
|
|
parent,
|
|||
|
|
tr("prev.pick_title"),
|
|||
|
|
"",
|
|||
|
|
tr("prev.filter"),
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
remove_preview_file(entry)
|
|||
|
|
target_name = f"{entry['id']}{Path(path).suffix.lower() or '.png'}"
|
|||
|
|
shutil.copyfile(path, PREVIEW_DIR / target_name)
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.warning(parent, tr("prev.title"), tr("prev.save_fail").format(err=error))
|
|||
|
|
return False
|
|||
|
|
entry["preview"] = target_name
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
return True
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PaletteEditor(QWidget):
|
|||
|
|
changed = pyqtSignal()
|
|||
|
|
|
|||
|
|
def __init__(self, limit, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.limit = limit
|
|||
|
|
self._colors = []
|
|||
|
|
self._syncing = False
|
|||
|
|
|
|||
|
|
layout = QVBoxLayout(self)
|
|||
|
|
layout.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
layout.setSpacing(8)
|
|||
|
|
|
|||
|
|
top = QHBoxLayout()
|
|||
|
|
self.line_edit = QLineEdit()
|
|||
|
|
self.line_edit.setPlaceholderText(tr("pal.placeholder"))
|
|||
|
|
self.add_button = QPushButton(tr("pal.add"))
|
|||
|
|
self.add_button.clicked.connect(self.add_color)
|
|||
|
|
self.line_edit.textChanged.connect(self._line_changed)
|
|||
|
|
top.addWidget(self.line_edit, 1)
|
|||
|
|
top.addWidget(self.add_button)
|
|||
|
|
layout.addLayout(top)
|
|||
|
|
|
|||
|
|
bottom = QHBoxLayout()
|
|||
|
|
self.swatch_row = QHBoxLayout()
|
|||
|
|
self.swatch_row.setSpacing(6)
|
|||
|
|
self.swatch_row.addStretch()
|
|||
|
|
bottom.addLayout(self.swatch_row, 1)
|
|||
|
|
self.counter_label = QLabel("")
|
|||
|
|
self.counter_label.setStyleSheet("color:#7A847C;background:transparent;")
|
|||
|
|
bottom.addWidget(self.counter_label)
|
|||
|
|
layout.addLayout(bottom)
|
|||
|
|
self._update_counter()
|
|||
|
|
|
|||
|
|
def text(self):
|
|||
|
|
return palette_text(self._colors)
|
|||
|
|
|
|||
|
|
def colors(self):
|
|||
|
|
return list(self._colors)
|
|||
|
|
|
|||
|
|
def set_text(self, text):
|
|||
|
|
self.set_colors(parse_palette(text, self.limit))
|
|||
|
|
|
|||
|
|
def set_colors(self, colors):
|
|||
|
|
self._colors = [normalize_hex(color) for color in colors if normalize_hex(color)][: self.limit]
|
|||
|
|
self._sync_line()
|
|||
|
|
self._render_swatches()
|
|||
|
|
self.changed.emit()
|
|||
|
|
|
|||
|
|
def add_color(self):
|
|||
|
|
initial = QColor(self._colors[-1] if self._colors else "#FFFFFF")
|
|||
|
|
color = QColorDialog.getColor(initial, self, tr("pal.configure"))
|
|||
|
|
if not color.isValid():
|
|||
|
|
return
|
|||
|
|
value = color.name().upper()
|
|||
|
|
if value not in self._colors and len(self._colors) < self.limit:
|
|||
|
|
self._colors.append(value)
|
|||
|
|
self._sync_line()
|
|||
|
|
self._render_swatches()
|
|||
|
|
self.changed.emit()
|
|||
|
|
|
|||
|
|
def edit_color(self, index):
|
|||
|
|
color = QColorDialog.getColor(QColor(self._colors[index]), self, tr("pal.configure"))
|
|||
|
|
if not color.isValid():
|
|||
|
|
return
|
|||
|
|
self._colors[index] = color.name().upper()
|
|||
|
|
self._sync_line()
|
|||
|
|
self._render_swatches()
|
|||
|
|
self.changed.emit()
|
|||
|
|
|
|||
|
|
def remove_color(self, index):
|
|||
|
|
del self._colors[index]
|
|||
|
|
self._sync_line()
|
|||
|
|
self._render_swatches()
|
|||
|
|
self.changed.emit()
|
|||
|
|
|
|||
|
|
def _line_changed(self):
|
|||
|
|
if self._syncing:
|
|||
|
|
return
|
|||
|
|
self._colors = parse_palette(self.line_edit.text(), self.limit)
|
|||
|
|
self._render_swatches()
|
|||
|
|
self.changed.emit()
|
|||
|
|
|
|||
|
|
def _sync_line(self):
|
|||
|
|
self._syncing = True
|
|||
|
|
self.line_edit.setText(palette_text(self._colors))
|
|||
|
|
self._syncing = False
|
|||
|
|
|
|||
|
|
def _update_counter(self):
|
|||
|
|
count = len(self._colors)
|
|||
|
|
self.counter_label.setText(tr("counter.colors").format(count=count, limit=self.limit))
|
|||
|
|
# Highlight when any color is not a valid uppercase #RRGGBB or the limit is exceeded (item 10).
|
|||
|
|
invalid = count > self.limit or any(not HEX_RE.match(c) for c in self._colors)
|
|||
|
|
self.line_edit.setProperty("invalid", "true" if invalid else "false")
|
|||
|
|
self.line_edit.style().unpolish(self.line_edit)
|
|||
|
|
self.line_edit.style().polish(self.line_edit)
|
|||
|
|
|
|||
|
|
def _render_swatches(self):
|
|||
|
|
self._update_counter()
|
|||
|
|
while self.swatch_row.count() > 1:
|
|||
|
|
item = self.swatch_row.takeAt(0)
|
|||
|
|
if item.widget():
|
|||
|
|
item.widget().deleteLater()
|
|||
|
|
|
|||
|
|
for index, color in enumerate(self._colors):
|
|||
|
|
holder = QWidget()
|
|||
|
|
holder.setObjectName("SwatchHolder")
|
|||
|
|
row = QHBoxLayout(holder)
|
|||
|
|
row.setContentsMargins(0, 0, 0, 0)
|
|||
|
|
row.setSpacing(2)
|
|||
|
|
|
|||
|
|
swatch = QToolButton()
|
|||
|
|
swatch.setToolTip(tr("pal.swatch_tip").format(color=color))
|
|||
|
|
swatch.setFixedSize(34, 28)
|
|||
|
|
swatch.setStyleSheet(
|
|||
|
|
f"QToolButton {{ background: {color}; border: 1px solid #AEB8B1; border-radius: 6px; }}"
|
|||
|
|
)
|
|||
|
|
swatch.clicked.connect(lambda _checked=False, i=index: self.edit_color(i))
|
|||
|
|
remove = QToolButton()
|
|||
|
|
remove.setText("×")
|
|||
|
|
remove.setToolTip(tr("pal.remove"))
|
|||
|
|
remove.setFixedSize(22, 28)
|
|||
|
|
remove.clicked.connect(lambda _checked=False, i=index: self.remove_color(i))
|
|||
|
|
row.addWidget(swatch)
|
|||
|
|
row.addWidget(remove)
|
|||
|
|
self.swatch_row.insertWidget(index, holder)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class BBoxCanvas(QFrame):
|
|||
|
|
selected = pyqtSignal(int)
|
|||
|
|
bbox_changed = pyqtSignal(int, list)
|
|||
|
|
|
|||
|
|
BASE_SIZE = 340
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.elements = []
|
|||
|
|
self.selected_index = None
|
|||
|
|
self.drag_mode = None
|
|||
|
|
self.drag_index = None
|
|||
|
|
self.drag_start = QPointF()
|
|||
|
|
self.start_bbox = None
|
|||
|
|
self.zoom = 1.0
|
|||
|
|
self.ref_pixmap = None
|
|||
|
|
self.theme = THEMES["light"]
|
|||
|
|
self.setMinimumHeight(360)
|
|||
|
|
self.setMouseTracking(True)
|
|||
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|||
|
|
|
|||
|
|
def set_data(self, elements, selected_index):
|
|||
|
|
self.elements = elements
|
|||
|
|
self.selected_index = selected_index
|
|||
|
|
self.update()
|
|||
|
|
|
|||
|
|
def set_theme(self, theme):
|
|||
|
|
self.theme = THEMES.get(theme, THEMES["light"])
|
|||
|
|
self.update()
|
|||
|
|
|
|||
|
|
def set_reference(self, pixmap):
|
|||
|
|
"""Set (or clear with None) the background reference image; scales with the grid."""
|
|||
|
|
self.ref_pixmap = pixmap if pixmap and not pixmap.isNull() else None
|
|||
|
|
self.update()
|
|||
|
|
|
|||
|
|
def set_zoom(self, percent):
|
|||
|
|
self.zoom = max(0.5, min(3.0, percent / 100.0))
|
|||
|
|
size = int(self.BASE_SIZE * self.zoom)
|
|||
|
|
margin = 16
|
|||
|
|
# Grow minimums so the surrounding scroll area exposes scrollbars when zoomed in.
|
|||
|
|
self.setMinimumHeight(max(360, size + margin * 2))
|
|||
|
|
self.setMinimumWidth(size + margin * 2 if self.zoom > 1.0 else 0)
|
|||
|
|
self.update()
|
|||
|
|
|
|||
|
|
def canvas_rect(self):
|
|||
|
|
margin = 16
|
|||
|
|
size = self.BASE_SIZE * self.zoom
|
|||
|
|
left = max(margin, (self.width() - size) / 2)
|
|||
|
|
top = margin
|
|||
|
|
return QRectF(left, top, size, size)
|
|||
|
|
|
|||
|
|
def bbox_to_rect(self, bbox):
|
|||
|
|
canvas = self.canvas_rect()
|
|||
|
|
y1, x1, y2, x2 = bbox
|
|||
|
|
return QRectF(
|
|||
|
|
canvas.left() + canvas.width() * x1 / 1000,
|
|||
|
|
canvas.top() + canvas.height() * y1 / 1000,
|
|||
|
|
canvas.width() * (x2 - x1) / 1000,
|
|||
|
|
canvas.height() * (y2 - y1) / 1000,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def point_to_bbox_delta(self, delta):
|
|||
|
|
canvas = self.canvas_rect()
|
|||
|
|
return delta.y() * 1000 / canvas.height(), delta.x() * 1000 / canvas.width()
|
|||
|
|
|
|||
|
|
def hit_handle(self, point, rect):
|
|||
|
|
handles = {
|
|||
|
|
"nw": rect.topLeft(),
|
|||
|
|
"n": QPointF(rect.center().x(), rect.top()),
|
|||
|
|
"ne": rect.topRight(),
|
|||
|
|
"e": QPointF(rect.right(), rect.center().y()),
|
|||
|
|
"se": rect.bottomRight(),
|
|||
|
|
"s": QPointF(rect.center().x(), rect.bottom()),
|
|||
|
|
"sw": rect.bottomLeft(),
|
|||
|
|
"w": QPointF(rect.left(), rect.center().y()),
|
|||
|
|
}
|
|||
|
|
for name, handle in handles.items():
|
|||
|
|
if QRectF(handle.x() - 7, handle.y() - 7, 14, 14).contains(point):
|
|||
|
|
return name
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def hit_test(self, point):
|
|||
|
|
for index in range(len(self.elements) - 1, -1, -1):
|
|||
|
|
element = self.elements[index]
|
|||
|
|
if not element.get("use_bbox"):
|
|||
|
|
continue
|
|||
|
|
rect = self.bbox_to_rect(element["bbox"])
|
|||
|
|
handle = self.hit_handle(point, rect)
|
|||
|
|
if handle:
|
|||
|
|
return index, handle
|
|||
|
|
if rect.contains(point):
|
|||
|
|
return index, "move"
|
|||
|
|
return None, None
|
|||
|
|
|
|||
|
|
def mousePressEvent(self, event):
|
|||
|
|
if event.button() != Qt.MouseButton.LeftButton:
|
|||
|
|
return
|
|||
|
|
index, mode = self.hit_test(event.position())
|
|||
|
|
if index is None:
|
|||
|
|
return
|
|||
|
|
self.drag_index = index
|
|||
|
|
self.drag_mode = mode
|
|||
|
|
self.drag_start = event.position()
|
|||
|
|
self.start_bbox = list(self.elements[index]["bbox"])
|
|||
|
|
self.selected.emit(index)
|
|||
|
|
|
|||
|
|
def mouseMoveEvent(self, event):
|
|||
|
|
if self.drag_index is None:
|
|||
|
|
index, mode = self.hit_test(event.position())
|
|||
|
|
self.setCursor(self.cursor_for_mode(mode))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
dy, dx = self.point_to_bbox_delta(event.position() - self.drag_start)
|
|||
|
|
y1, x1, y2, x2 = self.start_bbox
|
|||
|
|
|
|||
|
|
if self.drag_mode == "move":
|
|||
|
|
height = y2 - y1
|
|||
|
|
width = x2 - x1
|
|||
|
|
y1 = clamp(y1 + dy, 0, 1000 - height)
|
|||
|
|
x1 = clamp(x1 + dx, 0, 1000 - width)
|
|||
|
|
y2 = y1 + height
|
|||
|
|
x2 = x1 + width
|
|||
|
|
else:
|
|||
|
|
if "n" in self.drag_mode:
|
|||
|
|
y1 = clamp(y1 + dy, 0, y2 - MIN_BBOX_SIZE)
|
|||
|
|
if "s" in self.drag_mode:
|
|||
|
|
y2 = clamp(y2 + dy, y1 + MIN_BBOX_SIZE, 1000)
|
|||
|
|
if "w" in self.drag_mode:
|
|||
|
|
x1 = clamp(x1 + dx, 0, x2 - MIN_BBOX_SIZE)
|
|||
|
|
if "e" in self.drag_mode:
|
|||
|
|
x2 = clamp(x2 + dx, x1 + MIN_BBOX_SIZE, 1000)
|
|||
|
|
|
|||
|
|
self.bbox_changed.emit(self.drag_index, [y1, x1, y2, x2])
|
|||
|
|
|
|||
|
|
def mouseReleaseEvent(self, event):
|
|||
|
|
self.drag_index = None
|
|||
|
|
self.drag_mode = None
|
|||
|
|
self.start_bbox = None
|
|||
|
|
self.setCursor(Qt.CursorShape.CrossCursor)
|
|||
|
|
|
|||
|
|
def cursor_for_mode(self, mode):
|
|||
|
|
mapping = {
|
|||
|
|
"move": Qt.CursorShape.SizeAllCursor,
|
|||
|
|
"n": Qt.CursorShape.SizeVerCursor,
|
|||
|
|
"s": Qt.CursorShape.SizeVerCursor,
|
|||
|
|
"e": Qt.CursorShape.SizeHorCursor,
|
|||
|
|
"w": Qt.CursorShape.SizeHorCursor,
|
|||
|
|
"nw": Qt.CursorShape.SizeFDiagCursor,
|
|||
|
|
"se": Qt.CursorShape.SizeFDiagCursor,
|
|||
|
|
"ne": Qt.CursorShape.SizeBDiagCursor,
|
|||
|
|
"sw": Qt.CursorShape.SizeBDiagCursor,
|
|||
|
|
}
|
|||
|
|
return mapping.get(mode, Qt.CursorShape.CrossCursor)
|
|||
|
|
|
|||
|
|
def paintEvent(self, event):
|
|||
|
|
super().paintEvent(event)
|
|||
|
|
painter = QPainter(self)
|
|||
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|||
|
|
canvas = self.canvas_rect()
|
|||
|
|
|
|||
|
|
painter.setPen(QPen(QColor(self.theme["canvas_grid"]), 1))
|
|||
|
|
painter.setBrush(QColor(self.theme["canvas_bg"]))
|
|||
|
|
painter.drawRoundedRect(canvas, 10, 10)
|
|||
|
|
|
|||
|
|
# Reference image fills the grid square and therefore scales with the zoom.
|
|||
|
|
if self.ref_pixmap is not None:
|
|||
|
|
scaled = self.ref_pixmap.scaled(
|
|||
|
|
int(canvas.width()), int(canvas.height()),
|
|||
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|||
|
|
Qt.TransformationMode.SmoothTransformation,
|
|||
|
|
)
|
|||
|
|
img_x = canvas.left() + (canvas.width() - scaled.width()) / 2
|
|||
|
|
img_y = canvas.top() + (canvas.height() - scaled.height()) / 2
|
|||
|
|
painter.setOpacity(0.85)
|
|||
|
|
painter.drawPixmap(int(img_x), int(img_y), scaled)
|
|||
|
|
painter.setOpacity(1.0)
|
|||
|
|
|
|||
|
|
painter.setPen(QPen(QColor(self.theme["canvas_grid"]), 1))
|
|||
|
|
for step in range(1, 10):
|
|||
|
|
x = canvas.left() + canvas.width() * step / 10
|
|||
|
|
y = canvas.top() + canvas.height() * step / 10
|
|||
|
|
painter.drawLine(int(x), int(canvas.top()), int(x), int(canvas.bottom()))
|
|||
|
|
painter.drawLine(int(canvas.left()), int(y), int(canvas.right()), int(y))
|
|||
|
|
|
|||
|
|
painter.setPen(QPen(QColor(self.theme["canvas_label"]), 1))
|
|||
|
|
painter.drawText(int(canvas.left()) + 10, int(canvas.top()) + 22, tr("canvas.label"))
|
|||
|
|
|
|||
|
|
for index, element in enumerate(self.elements):
|
|||
|
|
if not element.get("use_bbox"):
|
|||
|
|
continue
|
|||
|
|
rect = self.bbox_to_rect(element["bbox"])
|
|||
|
|
base = QColor("#C470A8") if element["type"] == "text" else QColor(self.theme["accent"])
|
|||
|
|
fill = QColor(base)
|
|||
|
|
fill.setAlpha(32)
|
|||
|
|
painter.setBrush(fill)
|
|||
|
|
painter.setPen(QPen(base, 3 if index == self.selected_index else 2))
|
|||
|
|
painter.drawRoundedRect(rect, 6, 6)
|
|||
|
|
painter.setPen(base)
|
|||
|
|
painter.drawText(rect.adjusted(7, 5, -7, -5), Qt.AlignmentFlag.AlignLeft, element.get("label") or str(index + 1))
|
|||
|
|
|
|||
|
|
if index == self.selected_index:
|
|||
|
|
painter.setBrush(QColor(self.theme["panel"]))
|
|||
|
|
painter.setPen(QPen(base, 2))
|
|||
|
|
for point in [
|
|||
|
|
rect.topLeft(),
|
|||
|
|
QPointF(rect.center().x(), rect.top()),
|
|||
|
|
rect.topRight(),
|
|||
|
|
QPointF(rect.right(), rect.center().y()),
|
|||
|
|
rect.bottomRight(),
|
|||
|
|
QPointF(rect.center().x(), rect.bottom()),
|
|||
|
|
rect.bottomLeft(),
|
|||
|
|
QPointF(rect.left(), rect.center().y()),
|
|||
|
|
]:
|
|||
|
|
painter.drawEllipse(point, 5, 5)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class ComfySettingsDialog(QDialog):
|
|||
|
|
"""Edit ComfyUI connection settings, persisted to comfy_settings.json."""
|
|||
|
|
|
|||
|
|
def __init__(self, settings, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.settings = dict(settings)
|
|||
|
|
self.setWindowTitle(tr("set.title"))
|
|||
|
|
self.setMinimumWidth(420)
|
|||
|
|
|
|||
|
|
layout = QVBoxLayout(self)
|
|||
|
|
form = QFormLayout()
|
|||
|
|
self.host_edit = QLineEdit(str(self.settings.get("comfy_host", "127.0.0.1")))
|
|||
|
|
self.port_spin = QSpinBox()
|
|||
|
|
self.port_spin.setRange(1, 65535)
|
|||
|
|
self.port_spin.setValue(int(self.settings.get("comfy_port", 8188)))
|
|||
|
|
self.https_check = QCheckBox(tr("set.https"))
|
|||
|
|
self.https_check.setChecked(bool(self.settings.get("comfy_https", False)))
|
|||
|
|
form.addRow(tr("set.host"), self.host_edit)
|
|||
|
|
form.addRow(tr("set.port"), self.port_spin)
|
|||
|
|
form.addRow("", self.https_check)
|
|||
|
|
layout.addLayout(form)
|
|||
|
|
|
|||
|
|
test_row = QHBoxLayout()
|
|||
|
|
self.test_button = QPushButton(tr("set.test"))
|
|||
|
|
self.test_button.clicked.connect(self.test_connection)
|
|||
|
|
test_row.addWidget(self.test_button)
|
|||
|
|
test_row.addStretch()
|
|||
|
|
layout.addLayout(test_row)
|
|||
|
|
|
|||
|
|
buttons = QDialogButtonBox(
|
|||
|
|
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
|
|||
|
|
)
|
|||
|
|
buttons.accepted.connect(self.accept)
|
|||
|
|
buttons.rejected.connect(self.reject)
|
|||
|
|
layout.addWidget(buttons)
|
|||
|
|
|
|||
|
|
def values(self):
|
|||
|
|
return {
|
|||
|
|
"comfy_host": self.host_edit.text().strip() or "127.0.0.1",
|
|||
|
|
"comfy_port": self.port_spin.value(),
|
|||
|
|
"comfy_https": self.https_check.isChecked(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def test_connection(self):
|
|||
|
|
probe = dict(self.settings)
|
|||
|
|
probe.update(self.values())
|
|||
|
|
try:
|
|||
|
|
comfy_test_connection(probe)
|
|||
|
|
except ComfyError as error:
|
|||
|
|
QMessageBox.warning(self, tr("set.title"), tr("set.test_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
QMessageBox.information(self, tr("set.title"), tr("set.test_ok"))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class LibraryDialog(QDialog):
|
|||
|
|
"""Browse the prompt library: load, rename, attach a preview, or delete entries."""
|
|||
|
|
|
|||
|
|
def __init__(self, entries, parent=None):
|
|||
|
|
super().__init__(parent)
|
|||
|
|
self.entries = entries
|
|||
|
|
self.selected_caption = None
|
|||
|
|
self.selected_id = None
|
|||
|
|
self._filtered = [] # list of original indices currently shown
|
|||
|
|
self.setWindowTitle(tr("libd.title"))
|
|||
|
|
self.resize(900, 600)
|
|||
|
|
|
|||
|
|
layout = QHBoxLayout(self)
|
|||
|
|
layout.setSpacing(14)
|
|||
|
|
|
|||
|
|
left = QVBoxLayout()
|
|||
|
|
left.addWidget(QLabel(tr("libd.saved_prompts")))
|
|||
|
|
self.search_edit = QLineEdit()
|
|||
|
|
self.search_edit.setPlaceholderText(tr("libd.search"))
|
|||
|
|
self.search_edit.textChanged.connect(lambda _t: self._refresh_list(0))
|
|||
|
|
left.addWidget(self.search_edit)
|
|||
|
|
self.list_widget = QListWidget()
|
|||
|
|
self.list_widget.setMinimumWidth(300)
|
|||
|
|
self.list_widget.currentRowChanged.connect(self._show_details)
|
|||
|
|
self.list_widget.itemDoubleClicked.connect(lambda _item: self.use_selected())
|
|||
|
|
left.addWidget(self.list_widget, 1)
|
|||
|
|
io_row = QHBoxLayout()
|
|||
|
|
export_button = QPushButton(tr("libd.export"))
|
|||
|
|
export_button.clicked.connect(self.export_library)
|
|||
|
|
import_button = QPushButton(tr("libd.import"))
|
|||
|
|
import_button.clicked.connect(self.import_library)
|
|||
|
|
io_row.addWidget(export_button)
|
|||
|
|
io_row.addWidget(import_button)
|
|||
|
|
left.addLayout(io_row)
|
|||
|
|
layout.addLayout(left, 1)
|
|||
|
|
|
|||
|
|
right = QVBoxLayout()
|
|||
|
|
right.setSpacing(10)
|
|||
|
|
self.preview_label = QLabel(tr("libd.no_preview"))
|
|||
|
|
self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
self.preview_label.setMinimumSize(360, 260)
|
|||
|
|
self.preview_label.setStyleSheet(
|
|||
|
|
"background:palette(base);border:1px solid palette(mid);border-radius:8px;"
|
|||
|
|
)
|
|||
|
|
right.addWidget(self.preview_label, 1)
|
|||
|
|
|
|||
|
|
self.meta_label = QLabel("")
|
|||
|
|
self.meta_label.setWordWrap(True)
|
|||
|
|
right.addWidget(self.meta_label)
|
|||
|
|
|
|||
|
|
right.addWidget(QLabel(tr("libd.tags")))
|
|||
|
|
self.tags_edit = QLineEdit()
|
|||
|
|
self.tags_edit.editingFinished.connect(self._save_tags)
|
|||
|
|
right.addWidget(self.tags_edit)
|
|||
|
|
|
|||
|
|
button_row = QHBoxLayout()
|
|||
|
|
self.use_button = QPushButton(tr("libd.use"))
|
|||
|
|
self.use_button.setObjectName("PrimaryButton")
|
|||
|
|
self.use_button.clicked.connect(self.use_selected)
|
|||
|
|
self.rename_button = QPushButton(tr("libd.rename"))
|
|||
|
|
self.rename_button.clicked.connect(self.rename_selected)
|
|||
|
|
button_row.addWidget(self.use_button)
|
|||
|
|
button_row.addWidget(self.rename_button)
|
|||
|
|
right.addLayout(button_row)
|
|||
|
|
|
|||
|
|
button_row2 = QHBoxLayout()
|
|||
|
|
self.preview_button = QPushButton(tr("libd.set_preview"))
|
|||
|
|
self.preview_button.clicked.connect(self.set_preview)
|
|||
|
|
self.paste_preview_button = QPushButton(tr("libd.paste_preview"))
|
|||
|
|
self.paste_preview_button.clicked.connect(self.paste_preview)
|
|||
|
|
self.clear_preview_button = QPushButton(tr("libd.clear_preview"))
|
|||
|
|
self.clear_preview_button.clicked.connect(self.clear_preview)
|
|||
|
|
button_row2.addWidget(self.preview_button)
|
|||
|
|
button_row2.addWidget(self.paste_preview_button)
|
|||
|
|
button_row2.addWidget(self.clear_preview_button)
|
|||
|
|
right.addLayout(button_row2)
|
|||
|
|
|
|||
|
|
button_row3 = QHBoxLayout()
|
|||
|
|
self.delete_button = QPushButton(tr("libd.delete"))
|
|||
|
|
self.delete_button.clicked.connect(self.delete_selected)
|
|||
|
|
close_button = QPushButton(tr("libd.close"))
|
|||
|
|
close_button.clicked.connect(self.reject)
|
|||
|
|
button_row3.addWidget(self.delete_button)
|
|||
|
|
button_row3.addStretch()
|
|||
|
|
button_row3.addWidget(close_button)
|
|||
|
|
right.addLayout(button_row3)
|
|||
|
|
layout.addLayout(right, 1)
|
|||
|
|
|
|||
|
|
self._refresh_list(0 if self.entries else -1)
|
|||
|
|
|
|||
|
|
def _matches(self, entry, query):
|
|||
|
|
if not query:
|
|||
|
|
return True
|
|||
|
|
haystack = " ".join([
|
|||
|
|
entry.get("name", ""),
|
|||
|
|
" ".join(entry.get("tags", []) or []),
|
|||
|
|
entry.get("caption", {}).get("high_level_description", ""),
|
|||
|
|
]).lower()
|
|||
|
|
return query in haystack
|
|||
|
|
|
|||
|
|
def _refresh_list(self, select_row):
|
|||
|
|
query = self.search_edit.text().strip().lower()
|
|||
|
|
self.list_widget.blockSignals(True)
|
|||
|
|
self.list_widget.clear()
|
|||
|
|
self._filtered = []
|
|||
|
|
for index, entry in enumerate(self.entries):
|
|||
|
|
if not self._matches(entry, query):
|
|||
|
|
continue
|
|||
|
|
self._filtered.append(index)
|
|||
|
|
mark = "🖼 " if preview_file(entry) else ""
|
|||
|
|
tags = entry.get("tags", []) or []
|
|||
|
|
suffix = f" [{', '.join(tags)}]" if tags else ""
|
|||
|
|
self.list_widget.addItem(QListWidgetItem(f"{mark}{entry.get('name') or tr('lib.untitled')}{suffix}"))
|
|||
|
|
self.list_widget.blockSignals(False)
|
|||
|
|
if 0 <= select_row < len(self._filtered):
|
|||
|
|
self.list_widget.setCurrentRow(select_row)
|
|||
|
|
else:
|
|||
|
|
self._show_details(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def _current_entry(self):
|
|||
|
|
row = self.list_widget.currentRow()
|
|||
|
|
if 0 <= row < len(self._filtered):
|
|||
|
|
original = self._filtered[row]
|
|||
|
|
return original, self.entries[original]
|
|||
|
|
return None, None
|
|||
|
|
|
|||
|
|
def _show_details(self, row):
|
|||
|
|
has = 0 <= row < len(self._filtered)
|
|||
|
|
for button in (self.use_button, self.rename_button, self.preview_button,
|
|||
|
|
self.paste_preview_button, self.clear_preview_button, self.delete_button):
|
|||
|
|
button.setEnabled(has)
|
|||
|
|
self.tags_edit.setEnabled(has)
|
|||
|
|
if not has:
|
|||
|
|
self.preview_label.setText(tr("libd.no_preview"))
|
|||
|
|
self.preview_label.setPixmap(QPixmap())
|
|||
|
|
self.meta_label.setText("")
|
|||
|
|
self.tags_edit.blockSignals(True)
|
|||
|
|
self.tags_edit.clear()
|
|||
|
|
self.tags_edit.blockSignals(False)
|
|||
|
|
return
|
|||
|
|
entry = self.entries[self._filtered[row]]
|
|||
|
|
self.tags_edit.blockSignals(True)
|
|||
|
|
self.tags_edit.setText(", ".join(entry.get("tags", []) or []))
|
|||
|
|
self.tags_edit.blockSignals(False)
|
|||
|
|
path = preview_file(entry)
|
|||
|
|
if path:
|
|||
|
|
pixmap = QPixmap(str(path))
|
|||
|
|
if not pixmap.isNull():
|
|||
|
|
self.preview_label.setPixmap(
|
|||
|
|
pixmap.scaled(
|
|||
|
|
self.preview_label.size(),
|
|||
|
|
Qt.AspectRatioMode.KeepAspectRatio,
|
|||
|
|
Qt.TransformationMode.SmoothTransformation,
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
self.preview_label.setText(tr("libd.preview_unavailable"))
|
|||
|
|
else:
|
|||
|
|
self.preview_label.setPixmap(QPixmap())
|
|||
|
|
self.preview_label.setText(tr("libd.no_preview"))
|
|||
|
|
caption = entry.get("caption", {})
|
|||
|
|
high = caption.get("high_level_description", "") or tr("libd.no_high")
|
|||
|
|
count = len(caption.get("compositional_deconstruction", {}).get("elements", []))
|
|||
|
|
updated = entry.get("updated", entry.get("created", ""))
|
|||
|
|
self.meta_label.setText(tr("libd.meta").format(updated=updated, count=count, high=high))
|
|||
|
|
|
|||
|
|
def use_selected(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
self.selected_caption = entry.get("caption", {})
|
|||
|
|
self.selected_id = entry.get("id")
|
|||
|
|
self.accept()
|
|||
|
|
|
|||
|
|
def rename_selected(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
name, ok = QInputDialog.getText(self, tr("libd.rename_title"), tr("libd.rename_label"), text=entry.get("name", ""))
|
|||
|
|
if ok and name.strip():
|
|||
|
|
entry["name"] = name.strip()
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def _save_tags(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
tags = [t.strip() for t in self.tags_edit.text().split(",") if t.strip()]
|
|||
|
|
if tags != (entry.get("tags") or []):
|
|||
|
|
entry["tags"] = tags
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def set_preview(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
if attach_preview(entry, self):
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def paste_preview(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
image = QGuiApplication.clipboard().image()
|
|||
|
|
if image.isNull():
|
|||
|
|
QMessageBox.information(self, tr("libd.paste_preview"), tr("libd.no_clipboard_image"))
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
remove_preview_file(entry)
|
|||
|
|
target_name = f"{entry['id']}.png"
|
|||
|
|
image.save(str(PREVIEW_DIR / target_name), "PNG")
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.warning(self, tr("prev.title"), tr("prev.save_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
entry["preview"] = target_name
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def clear_preview(self):
|
|||
|
|
_row, entry = self._current_entry()
|
|||
|
|
if entry is None or not entry.get("preview"):
|
|||
|
|
return
|
|||
|
|
remove_preview_file(entry)
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(self.list_widget.currentRow())
|
|||
|
|
|
|||
|
|
def delete_selected(self):
|
|||
|
|
original, entry = self._current_entry()
|
|||
|
|
if entry is None:
|
|||
|
|
return
|
|||
|
|
confirm = QMessageBox.question(
|
|||
|
|
self,
|
|||
|
|
tr("libd.delete_title"),
|
|||
|
|
tr("libd.delete_q").format(name=entry.get("name", "")),
|
|||
|
|
)
|
|||
|
|
if confirm != QMessageBox.StandardButton.Yes:
|
|||
|
|
return
|
|||
|
|
row = self.list_widget.currentRow()
|
|||
|
|
remove_preview_file(entry)
|
|||
|
|
del self.entries[original]
|
|||
|
|
save_library(self.entries)
|
|||
|
|
self._refresh_list(min(row, len(self._filtered) - 1))
|
|||
|
|
|
|||
|
|
def export_library(self):
|
|||
|
|
path, _filter = QFileDialog.getSaveFileName(
|
|||
|
|
self, tr("libd.export"), "prompt_library.zip", tr("libd.export_filter")
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive:
|
|||
|
|
archive.writestr("prompt_library.json",
|
|||
|
|
json.dumps(self.entries, ensure_ascii=False, indent=2))
|
|||
|
|
for entry in self.entries:
|
|||
|
|
preview = preview_file(entry)
|
|||
|
|
if preview:
|
|||
|
|
archive.write(preview, f"prompt_previews/{preview.name}")
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.warning(self, tr("libd.export"), tr("libd.export_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
QMessageBox.information(self, tr("libd.export"), tr("libd.export_done").format(path=path))
|
|||
|
|
|
|||
|
|
def import_library(self):
|
|||
|
|
path, _filter = QFileDialog.getOpenFileName(
|
|||
|
|
self, tr("libd.import"), "", tr("libd.export_filter")
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with zipfile.ZipFile(path, "r") as archive:
|
|||
|
|
imported = json.loads(archive.read("prompt_library.json").decode("utf-8"))
|
|||
|
|
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
existing_ids = {e.get("id") for e in self.entries}
|
|||
|
|
added = 0
|
|||
|
|
for entry in imported:
|
|||
|
|
if not isinstance(entry, dict):
|
|||
|
|
continue
|
|||
|
|
if entry.get("id") in existing_ids:
|
|||
|
|
entry["id"] = uuid.uuid4().hex
|
|||
|
|
preview = entry.get("preview")
|
|||
|
|
if preview:
|
|||
|
|
member = f"prompt_previews/{preview}"
|
|||
|
|
if member in archive.namelist():
|
|||
|
|
with archive.open(member) as source:
|
|||
|
|
(PREVIEW_DIR / preview).write_bytes(source.read())
|
|||
|
|
self.entries.append(entry)
|
|||
|
|
existing_ids.add(entry.get("id"))
|
|||
|
|
added += 1
|
|||
|
|
save_library(self.entries)
|
|||
|
|
except (OSError, KeyError, json.JSONDecodeError, zipfile.BadZipFile) as error:
|
|||
|
|
QMessageBox.warning(self, tr("libd.import"), tr("libd.import_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
self._refresh_list(0)
|
|||
|
|
QMessageBox.information(self, tr("libd.import"), tr("libd.import_done").format(count=added))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class PromptBuilder(QMainWindow):
|
|||
|
|
def __init__(self):
|
|||
|
|
super().__init__()
|
|||
|
|
self.elements = []
|
|||
|
|
self.selected_index = None
|
|||
|
|
self._loading = False
|
|||
|
|
self._toolbar = None
|
|||
|
|
self.settings = load_settings()
|
|||
|
|
self.theme = self.settings.get("theme", "light")
|
|||
|
|
self._undo_stack = []
|
|||
|
|
self._redo_stack = []
|
|||
|
|
self._suspend_history = False
|
|||
|
|
self._library_entry_id = None # id of the entry loaded from the library (item 7)
|
|||
|
|
self._gen_thread = None
|
|||
|
|
self.setWindowTitle(tr("app.title"))
|
|||
|
|
self.resize(1460, 900)
|
|||
|
|
self._build_ui()
|
|||
|
|
if not self._restore_draft():
|
|||
|
|
self.load_caption(EXAMPLE_CAPTION)
|
|||
|
|
self._push_history(initial=True)
|
|||
|
|
|
|||
|
|
def set_language(self, language):
|
|||
|
|
global CURRENT_LANG
|
|||
|
|
if language == CURRENT_LANG or language not in TRANSLATIONS:
|
|||
|
|
return
|
|||
|
|
caption = self.current_caption()
|
|||
|
|
ref = self.canvas.ref_pixmap if hasattr(self, "canvas") else None
|
|||
|
|
CURRENT_LANG = language
|
|||
|
|
self.settings["language"] = language
|
|||
|
|
save_settings(self.settings)
|
|||
|
|
self.setWindowTitle(tr("app.title"))
|
|||
|
|
if self._toolbar is not None:
|
|||
|
|
self.removeToolBar(self._toolbar)
|
|||
|
|
self._toolbar.deleteLater()
|
|||
|
|
self._toolbar = None
|
|||
|
|
self._suspend_history = True
|
|||
|
|
self._build_ui()
|
|||
|
|
self.load_caption(caption)
|
|||
|
|
if ref is not None:
|
|||
|
|
self.canvas.set_reference(ref)
|
|||
|
|
self._suspend_history = False
|
|||
|
|
|
|||
|
|
def toggle_theme(self):
|
|||
|
|
self.theme = "dark" if self.theme == "light" else "light"
|
|||
|
|
self.settings["theme"] = self.theme
|
|||
|
|
save_settings(self.settings)
|
|||
|
|
self.setStyleSheet(build_stylesheet(self.theme))
|
|||
|
|
self.canvas.set_theme(self.theme)
|
|||
|
|
|
|||
|
|
# --- Undo / redo (item 1) -------------------------------------------
|
|||
|
|
def _snapshot(self):
|
|||
|
|
return copy.deepcopy(self.current_caption())
|
|||
|
|
|
|||
|
|
def _push_history(self, initial=False):
|
|||
|
|
if self._suspend_history:
|
|||
|
|
return
|
|||
|
|
snap = self._snapshot()
|
|||
|
|
if self._undo_stack and self._undo_stack[-1] == snap:
|
|||
|
|
return
|
|||
|
|
self._undo_stack.append(snap)
|
|||
|
|
if len(self._undo_stack) > MAX_UNDO:
|
|||
|
|
self._undo_stack.pop(0)
|
|||
|
|
if not initial:
|
|||
|
|
self._redo_stack.clear()
|
|||
|
|
|
|||
|
|
def undo(self):
|
|||
|
|
if len(self._undo_stack) < 2:
|
|||
|
|
return
|
|||
|
|
self._redo_stack.append(self._undo_stack.pop())
|
|||
|
|
target = copy.deepcopy(self._undo_stack[-1])
|
|||
|
|
self._suspend_history = True
|
|||
|
|
self.load_caption(target)
|
|||
|
|
self._suspend_history = False
|
|||
|
|
|
|||
|
|
def redo(self):
|
|||
|
|
if not self._redo_stack:
|
|||
|
|
return
|
|||
|
|
target = self._redo_stack.pop()
|
|||
|
|
self._undo_stack.append(copy.deepcopy(target))
|
|||
|
|
self._suspend_history = True
|
|||
|
|
self.load_caption(copy.deepcopy(target))
|
|||
|
|
self._suspend_history = False
|
|||
|
|
|
|||
|
|
def install_translate_menu(self, widget):
|
|||
|
|
widget.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|||
|
|
widget.customContextMenuRequested.connect(lambda point, target=widget: self.show_translate_menu(target, point))
|
|||
|
|
|
|||
|
|
def show_translate_menu(self, widget, point):
|
|||
|
|
menu = widget.createStandardContextMenu()
|
|||
|
|
selected = self.selected_text(widget)
|
|||
|
|
if selected:
|
|||
|
|
menu.addSeparator()
|
|||
|
|
ru_action = menu.addAction(tr("trn.to_ru"))
|
|||
|
|
en_action = menu.addAction(tr("trn.to_en"))
|
|||
|
|
ru_action.triggered.connect(lambda: self.translate_selection(widget, "ru"))
|
|||
|
|
en_action.triggered.connect(lambda: self.translate_selection(widget, "en"))
|
|||
|
|
menu.exec(widget.mapToGlobal(point))
|
|||
|
|
|
|||
|
|
def selected_text(self, widget):
|
|||
|
|
if isinstance(widget, QLineEdit):
|
|||
|
|
return widget.selectedText()
|
|||
|
|
if isinstance(widget, (QTextEdit, QPlainTextEdit)):
|
|||
|
|
return widget.textCursor().selectedText().replace("\u2029", "\n")
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
def replace_selection(self, widget, replacement):
|
|||
|
|
if isinstance(widget, QLineEdit):
|
|||
|
|
widget.insert(replacement)
|
|||
|
|
return
|
|||
|
|
cursor = widget.textCursor()
|
|||
|
|
cursor.insertText(replacement)
|
|||
|
|
widget.setTextCursor(cursor)
|
|||
|
|
|
|||
|
|
def translate_selection(self, widget, target_language):
|
|||
|
|
selected = self.selected_text(widget)
|
|||
|
|
if not selected.strip():
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
translated = google_translate_text(selected, target_language)
|
|||
|
|
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, IndexError, KeyError, TypeError) as error:
|
|||
|
|
QMessageBox.warning(self, tr("trn.error_title"), tr("trn.error_msg").format(err=error))
|
|||
|
|
return
|
|||
|
|
if translated:
|
|||
|
|
self.replace_selection(widget, translated)
|
|||
|
|
|
|||
|
|
def _make_action(self, title, callback, shortcut=None):
|
|||
|
|
action = QAction(title, self)
|
|||
|
|
action.triggered.connect(callback)
|
|||
|
|
if shortcut:
|
|||
|
|
action.setShortcut(QKeySequence(shortcut))
|
|||
|
|
return action
|
|||
|
|
|
|||
|
|
def _build_menubar(self):
|
|||
|
|
bar = self.menuBar()
|
|||
|
|
bar.clear()
|
|||
|
|
file_menu = bar.addMenu(tr("menu.file"))
|
|||
|
|
file_menu.addAction(self._make_action(tr("tb.example"),
|
|||
|
|
lambda: self.load_caption(EXAMPLE_CAPTION, mark_history=True)))
|
|||
|
|
file_menu.addAction(self._make_action(tr("tb.import"), self.import_json))
|
|||
|
|
file_menu.addAction(self._make_action(tr("tb.save_json"), self.save_json, "Ctrl+S"))
|
|||
|
|
file_menu.addAction(self._make_action(tr("tb.copy"), self.copy_current_json))
|
|||
|
|
|
|||
|
|
edit_menu = bar.addMenu(tr("menu.edit"))
|
|||
|
|
edit_menu.addAction(self._make_action(tr("tb.undo"), self.undo, "Ctrl+Z"))
|
|||
|
|
edit_menu.addAction(self._make_action(tr("tb.redo"), self.redo, "Ctrl+Y"))
|
|||
|
|
|
|||
|
|
lib_menu = bar.addMenu(tr("menu.library"))
|
|||
|
|
lib_menu.addAction(self._make_action(tr("tb.save_library"), self.save_to_library))
|
|||
|
|
lib_menu.addAction(self._make_action(tr("tb.overwrite"), self.overwrite_in_library))
|
|||
|
|
lib_menu.addAction(self._make_action(tr("tb.library"), self.open_library))
|
|||
|
|
|
|||
|
|
comfy_menu = bar.addMenu(tr("menu.comfy"))
|
|||
|
|
comfy_menu.addAction(self._make_action(tr("tb.comfy_settings"), self.open_comfy_settings))
|
|||
|
|
comfy_menu.addAction(self._make_action(tr("tb.check_comfy"), self.check_comfy))
|
|||
|
|
comfy_menu.addAction(self._make_action(tr("tb.generate"), self.generate_in_comfy))
|
|||
|
|
|
|||
|
|
view_menu = bar.addMenu(tr("menu.view"))
|
|||
|
|
view_menu.addAction(self._make_action(tr("tb.theme"), self.toggle_theme))
|
|||
|
|
|
|||
|
|
def _build_ui(self):
|
|||
|
|
self.setStyleSheet(build_stylesheet(self.theme))
|
|||
|
|
self._build_menubar()
|
|||
|
|
toolbar = self.addToolBar("Main")
|
|||
|
|
toolbar.setMovable(False)
|
|||
|
|
self._toolbar = toolbar
|
|||
|
|
# Slim toolbar: the most frequent actions only; everything lives in the menus too.
|
|||
|
|
generate_action = self._make_action(tr("tb.generate"), self.generate_in_comfy)
|
|||
|
|
toolbar.addAction(generate_action)
|
|||
|
|
toolbar.addSeparator()
|
|||
|
|
toolbar.addAction(self._make_action(tr("tb.undo"), self.undo))
|
|||
|
|
toolbar.addAction(self._make_action(tr("tb.redo"), self.redo))
|
|||
|
|
toolbar.addSeparator()
|
|||
|
|
toolbar.addAction(self._make_action(tr("tb.save_library"), self.save_to_library))
|
|||
|
|
toolbar.addAction(self._make_action(tr("tb.library"), self.open_library))
|
|||
|
|
toolbar.addSeparator()
|
|||
|
|
toolbar.addAction(self._make_action(tr("tb.copy"), self.copy_current_json))
|
|||
|
|
|
|||
|
|
spacer = QWidget()
|
|||
|
|
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
|||
|
|
toolbar.addWidget(spacer)
|
|||
|
|
toolbar.addWidget(QLabel(tr("tb.language") + " "))
|
|||
|
|
self.language_combo = QComboBox()
|
|||
|
|
for code in available_languages():
|
|||
|
|
self.language_combo.addItem(LANGUAGE_NAMES.get(code, code), code)
|
|||
|
|
current_index = self.language_combo.findData(CURRENT_LANG)
|
|||
|
|
if current_index >= 0:
|
|||
|
|
self.language_combo.setCurrentIndex(current_index)
|
|||
|
|
self.language_combo.currentIndexChanged.connect(
|
|||
|
|
lambda _i: self.set_language(self.language_combo.currentData())
|
|||
|
|
)
|
|||
|
|
toolbar.addWidget(self.language_combo)
|
|||
|
|
theme_action = QAction(tr("tb.theme"), self)
|
|||
|
|
theme_action.triggered.connect(self.toggle_theme)
|
|||
|
|
toolbar.addAction(theme_action)
|
|||
|
|
|
|||
|
|
splitter = QSplitter(Qt.Orientation.Horizontal)
|
|||
|
|
self.setCentralWidget(splitter)
|
|||
|
|
|
|||
|
|
editor_scroll = QScrollArea()
|
|||
|
|
editor_scroll.setWidgetResizable(True)
|
|||
|
|
editor_widget = QWidget()
|
|||
|
|
self.editor_layout = QVBoxLayout(editor_widget)
|
|||
|
|
self.editor_layout.setContentsMargins(16, 16, 16, 16)
|
|||
|
|
self.editor_layout.setSpacing(12)
|
|||
|
|
editor_scroll.setWidget(editor_widget)
|
|||
|
|
splitter.addWidget(editor_scroll)
|
|||
|
|
|
|||
|
|
output_widget = QWidget()
|
|||
|
|
output_layout = QVBoxLayout(output_widget)
|
|||
|
|
output_layout.setContentsMargins(16, 16, 16, 16)
|
|||
|
|
output_layout.setSpacing(10)
|
|||
|
|
splitter.addWidget(output_widget)
|
|||
|
|
splitter.setSizes([900, 560])
|
|||
|
|
|
|||
|
|
self._build_summary()
|
|||
|
|
self._build_presets()
|
|||
|
|
self._build_style()
|
|||
|
|
self._build_composition()
|
|||
|
|
self.editor_layout.addStretch()
|
|||
|
|
self._build_output(output_layout)
|
|||
|
|
|
|||
|
|
def _build_summary(self):
|
|||
|
|
box = QGroupBox(tr("grp.high"))
|
|||
|
|
layout = QVBoxLayout(box)
|
|||
|
|
self.high_text = QTextEdit()
|
|||
|
|
self.high_text.setMinimumHeight(110)
|
|||
|
|
self.high_text.setPlaceholderText(tr("high.placeholder"))
|
|||
|
|
self.high_text.textChanged.connect(self.update_output)
|
|||
|
|
self.install_translate_menu(self.high_text)
|
|||
|
|
layout.addWidget(self.high_text)
|
|||
|
|
self.editor_layout.addWidget(box)
|
|||
|
|
|
|||
|
|
def _build_presets(self):
|
|||
|
|
box = QGroupBox(tr("grp.presets"))
|
|||
|
|
layout = QGridLayout(box)
|
|||
|
|
layout.setSpacing(8)
|
|||
|
|
for index, name in enumerate(PROMPT_PRESETS):
|
|||
|
|
button = QPushButton(name)
|
|||
|
|
button.clicked.connect(lambda _checked=False, value=name: self.apply_preset(value))
|
|||
|
|
layout.addWidget(button, index // 2, index % 2)
|
|||
|
|
no_safety = QPushButton(tr("preset.no_safety"))
|
|||
|
|
no_safety.clicked.connect(self.append_no_safety_filter)
|
|||
|
|
layout.addWidget(no_safety, 2, 0, 1, 2)
|
|||
|
|
self.editor_layout.addWidget(box)
|
|||
|
|
|
|||
|
|
def _build_style(self):
|
|||
|
|
box = QGroupBox(tr("grp.style"))
|
|||
|
|
layout = QVBoxLayout(box)
|
|||
|
|
mode_row = QHBoxLayout()
|
|||
|
|
self.photo_radio = QRadioButton(tr("style.photo"))
|
|||
|
|
self.art_radio = QRadioButton(tr("style.art"))
|
|||
|
|
self.photo_radio.setChecked(True)
|
|||
|
|
self.photo_radio.toggled.connect(self._style_mode_changed)
|
|||
|
|
mode_row.addWidget(self.photo_radio)
|
|||
|
|
mode_row.addWidget(self.art_radio)
|
|||
|
|
mode_row.addStretch()
|
|||
|
|
layout.addLayout(mode_row)
|
|||
|
|
|
|||
|
|
form = QFormLayout()
|
|||
|
|
self.aesthetics_edit = QLineEdit()
|
|||
|
|
self.lighting_edit = QLineEdit()
|
|||
|
|
self.photo_edit = QLineEdit()
|
|||
|
|
self.art_style_edit = QLineEdit()
|
|||
|
|
self.medium_combo = QComboBox()
|
|||
|
|
self.medium_combo.addItems(
|
|||
|
|
["photograph", "illustration", "3d_render", "painting", "graphic_design", "mixed-media digital collage"]
|
|||
|
|
)
|
|||
|
|
self.palette_editor = PaletteEditor(limit=16)
|
|||
|
|
self.install_translate_menu(self.aesthetics_edit)
|
|||
|
|
self.install_translate_menu(self.lighting_edit)
|
|||
|
|
self.install_translate_menu(self.photo_edit)
|
|||
|
|
self.install_translate_menu(self.art_style_edit)
|
|||
|
|
self.install_translate_menu(self.palette_editor.line_edit)
|
|||
|
|
|
|||
|
|
form.addRow(tr("style.aesthetics"), self.aesthetics_edit)
|
|||
|
|
form.addRow(tr("style.lighting"), self.lighting_edit)
|
|||
|
|
self.photo_row_label = QLabel(tr("style.photo_field"))
|
|||
|
|
self.art_row_label = QLabel(tr("style.art_style"))
|
|||
|
|
form.addRow(self.photo_row_label, self.photo_edit)
|
|||
|
|
form.addRow(self.art_row_label, self.art_style_edit)
|
|||
|
|
form.addRow(tr("style.medium"), self.medium_combo)
|
|||
|
|
form.addRow(tr("style.palette"), self.palette_editor)
|
|||
|
|
layout.addLayout(form)
|
|||
|
|
|
|||
|
|
for widget in [self.aesthetics_edit, self.lighting_edit, self.photo_edit, self.art_style_edit]:
|
|||
|
|
widget.textChanged.connect(self.update_output)
|
|||
|
|
self.medium_combo.currentTextChanged.connect(self.update_output)
|
|||
|
|
self.palette_editor.changed.connect(self.update_output)
|
|||
|
|
self.editor_layout.addWidget(box)
|
|||
|
|
|
|||
|
|
def _build_composition(self):
|
|||
|
|
box = QGroupBox(tr("grp.composition"))
|
|||
|
|
layout = QVBoxLayout(box)
|
|||
|
|
self.background_text = QTextEdit()
|
|||
|
|
self.background_text.setMinimumHeight(95)
|
|||
|
|
self.background_text.setPlaceholderText(tr("comp.background_placeholder"))
|
|||
|
|
self.background_text.textChanged.connect(self.update_output)
|
|||
|
|
self.install_translate_menu(self.background_text)
|
|||
|
|
layout.addWidget(QLabel(tr("comp.background")))
|
|||
|
|
layout.addWidget(self.background_text)
|
|||
|
|
|
|||
|
|
body = QHBoxLayout()
|
|||
|
|
body.setSpacing(14)
|
|||
|
|
self.element_list = QListWidget()
|
|||
|
|
self.element_list.currentRowChanged.connect(self.select_element)
|
|||
|
|
self.element_list.setMinimumWidth(280)
|
|||
|
|
left = QVBoxLayout()
|
|||
|
|
add_row = QHBoxLayout()
|
|||
|
|
add_button = QPushButton(tr("comp.add_element"))
|
|||
|
|
add_button.setObjectName("PrimaryButton")
|
|||
|
|
add_button.clicked.connect(lambda: self.add_element())
|
|||
|
|
template_button = QPushButton(tr("tb.template"))
|
|||
|
|
template_button.clicked.connect(self.add_from_template)
|
|||
|
|
add_row.addWidget(add_button, 1)
|
|||
|
|
add_row.addWidget(template_button)
|
|||
|
|
left.addLayout(add_row)
|
|||
|
|
left.addWidget(self.element_list, 1)
|
|||
|
|
ops_row = QHBoxLayout()
|
|||
|
|
for label, callback in [
|
|||
|
|
(tr("tb.duplicate"), self.duplicate_element),
|
|||
|
|
(tr("tb.move_up"), lambda: self.move_element(-1)),
|
|||
|
|
(tr("tb.move_down"), lambda: self.move_element(1)),
|
|||
|
|
]:
|
|||
|
|
button = QPushButton(label)
|
|||
|
|
button.clicked.connect(callback)
|
|||
|
|
ops_row.addWidget(button)
|
|||
|
|
left.addLayout(ops_row)
|
|||
|
|
remove_button = QPushButton(tr("comp.remove_element"))
|
|||
|
|
remove_button.clicked.connect(self.delete_element)
|
|||
|
|
left.addWidget(remove_button)
|
|||
|
|
body.addLayout(left, 1)
|
|||
|
|
|
|||
|
|
right = QVBoxLayout()
|
|||
|
|
self._build_element_form(right)
|
|||
|
|
|
|||
|
|
ref_row = QHBoxLayout()
|
|||
|
|
for label, callback in [
|
|||
|
|
(tr("canvas.load_ref"), self.load_reference_image),
|
|||
|
|
(tr("canvas.paste_ref"), self.paste_reference_image),
|
|||
|
|
(tr("canvas.clear_ref"), self.clear_reference_image),
|
|||
|
|
]:
|
|||
|
|
button = QPushButton(label)
|
|||
|
|
button.clicked.connect(callback)
|
|||
|
|
ref_row.addWidget(button)
|
|||
|
|
right.addLayout(ref_row)
|
|||
|
|
|
|||
|
|
zoom_row = QHBoxLayout()
|
|||
|
|
zoom_row.addWidget(QLabel(tr("canvas.zoom")))
|
|||
|
|
self.zoom_slider = QSlider(Qt.Orientation.Horizontal)
|
|||
|
|
self.zoom_slider.setRange(50, 300)
|
|||
|
|
self.zoom_slider.setValue(100)
|
|||
|
|
self.zoom_label = QLabel("100%")
|
|||
|
|
self.zoom_slider.valueChanged.connect(self._on_zoom_changed)
|
|||
|
|
zoom_row.addWidget(self.zoom_slider, 1)
|
|||
|
|
zoom_row.addWidget(self.zoom_label)
|
|||
|
|
right.addLayout(zoom_row)
|
|||
|
|
|
|||
|
|
self.canvas = BBoxCanvas()
|
|||
|
|
self.canvas.set_theme(self.theme)
|
|||
|
|
self.canvas.selected.connect(self.select_element)
|
|||
|
|
self.canvas.bbox_changed.connect(self.update_bbox_from_canvas)
|
|||
|
|
canvas_scroll = QScrollArea()
|
|||
|
|
canvas_scroll.setWidgetResizable(True)
|
|||
|
|
canvas_scroll.setWidget(self.canvas)
|
|||
|
|
canvas_scroll.setMinimumHeight(380)
|
|||
|
|
right.addWidget(canvas_scroll)
|
|||
|
|
hint = QLabel(tr("comp.hint"))
|
|||
|
|
hint.setWordWrap(True)
|
|||
|
|
hint.setStyleSheet(f"color:{THEMES[self.theme]['muted']};background:transparent;")
|
|||
|
|
right.addWidget(hint)
|
|||
|
|
body.addLayout(right, 2)
|
|||
|
|
layout.addLayout(body)
|
|||
|
|
self.editor_layout.addWidget(box)
|
|||
|
|
|
|||
|
|
def _build_element_form(self, parent_layout):
|
|||
|
|
form_box = QFrame()
|
|||
|
|
form_layout = QFormLayout(form_box)
|
|||
|
|
self.element_type = QComboBox()
|
|||
|
|
self.element_type.addItems(["obj", "text"])
|
|||
|
|
self.element_label = QLineEdit()
|
|||
|
|
self.element_text = QLineEdit()
|
|||
|
|
self.element_desc = QTextEdit()
|
|||
|
|
self.element_desc.setMinimumHeight(90)
|
|||
|
|
self.element_palette = PaletteEditor(limit=5)
|
|||
|
|
self.install_translate_menu(self.element_label)
|
|||
|
|
self.install_translate_menu(self.element_text)
|
|||
|
|
self.install_translate_menu(self.element_desc)
|
|||
|
|
self.install_translate_menu(self.element_palette.line_edit)
|
|||
|
|
self.use_bbox = QCheckBox(tr("el.use_bbox"))
|
|||
|
|
self.use_bbox.setChecked(True)
|
|||
|
|
self.bbox_spins = []
|
|||
|
|
bbox_layout = QHBoxLayout()
|
|||
|
|
for name in ["Y min", "X min", "Y max", "X max"]:
|
|||
|
|
spin = QSpinBox()
|
|||
|
|
spin.setRange(0, 1000)
|
|||
|
|
spin.setValue(200 if "min" in name else 800)
|
|||
|
|
spin.setPrefix(f"{name}: ")
|
|||
|
|
self.bbox_spins.append(spin)
|
|||
|
|
bbox_layout.addWidget(spin)
|
|||
|
|
|
|||
|
|
form_layout.addRow(tr("el.type"), self.element_type)
|
|||
|
|
form_layout.addRow(tr("el.label"), self.element_label)
|
|||
|
|
form_layout.addRow(tr("el.text"), self.element_text)
|
|||
|
|
form_layout.addRow(tr("el.description"), self.element_desc)
|
|||
|
|
form_layout.addRow(tr("el.palette"), self.element_palette)
|
|||
|
|
form_layout.addRow("", self.use_bbox)
|
|||
|
|
form_layout.addRow(tr("el.bbox"), bbox_layout)
|
|||
|
|
parent_layout.addWidget(form_box)
|
|||
|
|
|
|||
|
|
for widget in [self.element_type, self.element_label, self.element_text, self.use_bbox, *self.bbox_spins]:
|
|||
|
|
signal = (
|
|||
|
|
widget.currentTextChanged
|
|||
|
|
if isinstance(widget, QComboBox)
|
|||
|
|
else widget.textChanged
|
|||
|
|
if isinstance(widget, QLineEdit)
|
|||
|
|
else widget.stateChanged
|
|||
|
|
if isinstance(widget, QCheckBox)
|
|||
|
|
else widget.valueChanged
|
|||
|
|
)
|
|||
|
|
signal.connect(self.save_element_form)
|
|||
|
|
self.element_desc.textChanged.connect(self.save_element_form)
|
|||
|
|
self.element_palette.changed.connect(self.save_element_form)
|
|||
|
|
|
|||
|
|
def _build_output(self, layout):
|
|||
|
|
self.output_tabs = QTabWidget()
|
|||
|
|
layout.addWidget(self.output_tabs, 1)
|
|||
|
|
|
|||
|
|
# --- JSON tab ---
|
|||
|
|
json_tab = QWidget()
|
|||
|
|
json_layout = QVBoxLayout(json_tab)
|
|||
|
|
json_layout.setContentsMargins(0, 8, 0, 0)
|
|||
|
|
top = QHBoxLayout()
|
|||
|
|
title = QLabel(tr("out.title"))
|
|||
|
|
title.setStyleSheet("font-size:16px;font-weight:700;background:transparent;")
|
|||
|
|
self.pretty_radio = QRadioButton(tr("out.pretty"))
|
|||
|
|
self.compact_radio = QRadioButton(tr("out.compact"))
|
|||
|
|
self.pretty_radio.setChecked(True)
|
|||
|
|
self.pretty_radio.toggled.connect(self.update_output)
|
|||
|
|
top.addWidget(title)
|
|||
|
|
top.addStretch()
|
|||
|
|
top.addWidget(self.pretty_radio)
|
|||
|
|
top.addWidget(self.compact_radio)
|
|||
|
|
json_layout.addLayout(top)
|
|||
|
|
|
|||
|
|
self.output_text = QPlainTextEdit()
|
|||
|
|
self.output_text.setReadOnly(True)
|
|||
|
|
self.output_text.setLineWrapMode(QPlainTextEdit.LineWrapMode.NoWrap)
|
|||
|
|
self.output_text.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|||
|
|
json_layout.addWidget(self.output_text, 1)
|
|||
|
|
|
|||
|
|
actions = QHBoxLayout()
|
|||
|
|
copy_compact = QPushButton(tr("out.copy_compact"))
|
|||
|
|
copy_compact.clicked.connect(self.copy_compact_json)
|
|||
|
|
save = QPushButton(tr("out.save_json_btn"))
|
|||
|
|
save.clicked.connect(self.save_json)
|
|||
|
|
actions.addWidget(copy_compact)
|
|||
|
|
actions.addWidget(save)
|
|||
|
|
actions.addStretch()
|
|||
|
|
json_layout.addLayout(actions)
|
|||
|
|
|
|||
|
|
self.validation_list = QListWidget()
|
|||
|
|
self.validation_list.setMaximumHeight(160)
|
|||
|
|
self.validation_list.itemClicked.connect(self._on_validation_clicked)
|
|||
|
|
json_layout.addWidget(self.validation_list)
|
|||
|
|
self.output_tabs.addTab(json_tab, tr("tab.json"))
|
|||
|
|
|
|||
|
|
# --- Result tab (generated image from ComfyUI, item 14) ---
|
|||
|
|
result_tab = QWidget()
|
|||
|
|
result_layout = QVBoxLayout(result_tab)
|
|||
|
|
result_layout.setContentsMargins(0, 8, 0, 0)
|
|||
|
|
self.result_label = QLabel(tr("result.empty"))
|
|||
|
|
self.result_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|||
|
|
self.result_label.setStyleSheet(
|
|||
|
|
"background:palette(base);border:1px solid palette(mid);border-radius:8px;"
|
|||
|
|
)
|
|||
|
|
self.result_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
|||
|
|
result_layout.addWidget(self.result_label, 1)
|
|||
|
|
result_actions = QHBoxLayout()
|
|||
|
|
self.result_save_lib = QPushButton(tr("result.save_lib"))
|
|||
|
|
self.result_save_lib.clicked.connect(lambda: self._save_generated_to_library(self._last_generated))
|
|||
|
|
self.result_save_file = QPushButton(tr("result.save_file"))
|
|||
|
|
self.result_save_file.clicked.connect(self._save_generated_to_file)
|
|||
|
|
self.result_save_lib.setEnabled(False)
|
|||
|
|
self.result_save_file.setEnabled(False)
|
|||
|
|
result_actions.addWidget(self.result_save_lib)
|
|||
|
|
result_actions.addWidget(self.result_save_file)
|
|||
|
|
result_actions.addStretch()
|
|||
|
|
result_layout.addLayout(result_actions)
|
|||
|
|
self.output_tabs.addTab(result_tab, tr("tab.result"))
|
|||
|
|
self._last_generated = None
|
|||
|
|
|
|||
|
|
def _style_mode_changed(self):
|
|||
|
|
photo_mode = self.photo_radio.isChecked()
|
|||
|
|
self.photo_edit.setVisible(photo_mode)
|
|||
|
|
self.photo_row_label.setVisible(photo_mode)
|
|||
|
|
self.art_style_edit.setVisible(not photo_mode)
|
|||
|
|
self.art_row_label.setVisible(not photo_mode)
|
|||
|
|
if self._loading:
|
|||
|
|
return
|
|||
|
|
if photo_mode:
|
|||
|
|
self.medium_combo.setCurrentText("photograph")
|
|||
|
|
elif self.medium_combo.currentText() == "photograph":
|
|||
|
|
self.medium_combo.setCurrentText("illustration")
|
|||
|
|
self.update_output()
|
|||
|
|
|
|||
|
|
def style_mode(self):
|
|||
|
|
return "photo" if self.photo_radio.isChecked() else "art"
|
|||
|
|
|
|||
|
|
def current_caption(self):
|
|||
|
|
caption = {}
|
|||
|
|
high = self.high_text.toPlainText().strip()
|
|||
|
|
if high:
|
|||
|
|
caption["high_level_description"] = high
|
|||
|
|
|
|||
|
|
style = {}
|
|||
|
|
if self.aesthetics_edit.text().strip():
|
|||
|
|
style["aesthetics"] = self.aesthetics_edit.text().strip()
|
|||
|
|
if self.lighting_edit.text().strip():
|
|||
|
|
style["lighting"] = self.lighting_edit.text().strip()
|
|||
|
|
if self.style_mode() == "photo":
|
|||
|
|
if self.photo_edit.text().strip():
|
|||
|
|
style["photo"] = self.photo_edit.text().strip()
|
|||
|
|
if self.medium_combo.currentText().strip():
|
|||
|
|
style["medium"] = self.medium_combo.currentText().strip()
|
|||
|
|
else:
|
|||
|
|
if self.medium_combo.currentText().strip():
|
|||
|
|
style["medium"] = self.medium_combo.currentText().strip()
|
|||
|
|
if self.art_style_edit.text().strip():
|
|||
|
|
style["art_style"] = self.art_style_edit.text().strip()
|
|||
|
|
if self.palette_editor.colors():
|
|||
|
|
style["color_palette"] = self.palette_editor.colors()
|
|||
|
|
if style:
|
|||
|
|
caption["style_description"] = style
|
|||
|
|
|
|||
|
|
caption["compositional_deconstruction"] = {
|
|||
|
|
"background": self.background_text.toPlainText().strip(),
|
|||
|
|
"elements": [self.ordered_element(element) for element in self.elements],
|
|||
|
|
}
|
|||
|
|
return caption
|
|||
|
|
|
|||
|
|
def ordered_element(self, element):
|
|||
|
|
item = {"type": element["type"]}
|
|||
|
|
if element.get("use_bbox"):
|
|||
|
|
item["bbox"] = [int(value) for value in element["bbox"]]
|
|||
|
|
if element["type"] == "text":
|
|||
|
|
item["text"] = element.get("text", "").strip()
|
|||
|
|
item["desc"] = element.get("desc", "").strip()
|
|||
|
|
colors = parse_palette(element.get("palette", ""), 5)
|
|||
|
|
if colors:
|
|||
|
|
item["color_palette"] = colors
|
|||
|
|
return item
|
|||
|
|
|
|||
|
|
def update_output(self):
|
|||
|
|
if self._loading:
|
|||
|
|
return
|
|||
|
|
caption = self.current_caption()
|
|||
|
|
if self.compact_radio.isChecked():
|
|||
|
|
text = json.dumps(caption, ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
else:
|
|||
|
|
text = json.dumps(caption, ensure_ascii=False, indent=2)
|
|||
|
|
self.output_text.setPlainText(text)
|
|||
|
|
self._populate_validation(self.validate_caption(caption))
|
|||
|
|
self.canvas.set_data(self.elements, self.selected_index)
|
|||
|
|
self._push_history()
|
|||
|
|
self._save_draft(caption)
|
|||
|
|
|
|||
|
|
def _populate_validation(self, messages):
|
|||
|
|
colors = {"ok": "#2E8B57", "warn": "#B8860B", "bad": THEMES[self.theme]["error"]}
|
|||
|
|
self.validation_list.clear()
|
|||
|
|
for kind, message, element_index in messages:
|
|||
|
|
item = QListWidgetItem(f"[{kind.upper()}] {message}")
|
|||
|
|
item.setForeground(QColor(colors.get(kind, THEMES[self.theme]["text"])))
|
|||
|
|
item.setData(Qt.ItemDataRole.UserRole, element_index)
|
|||
|
|
self.validation_list.addItem(item)
|
|||
|
|
|
|||
|
|
def _on_validation_clicked(self, item):
|
|||
|
|
index = item.data(Qt.ItemDataRole.UserRole)
|
|||
|
|
if index is not None and 0 <= index < len(self.elements):
|
|||
|
|
self.select_element(index)
|
|||
|
|
|
|||
|
|
def validate_caption(self, caption):
|
|||
|
|
"""Return a list of (kind, message, element_index_or_None) tuples."""
|
|||
|
|
messages = []
|
|||
|
|
style = caption.get("style_description", {})
|
|||
|
|
comp = caption["compositional_deconstruction"]
|
|||
|
|
if not caption.get("high_level_description"):
|
|||
|
|
messages.append(("warn", tr("val.no_high"), None))
|
|||
|
|
if not comp.get("background"):
|
|||
|
|
messages.append(("bad", tr("val.bg_required"), None))
|
|||
|
|
if not comp.get("elements"):
|
|||
|
|
messages.append(("bad", tr("val.add_element"), None))
|
|||
|
|
if style:
|
|||
|
|
missing = [key for key in ["aesthetics", "lighting", "medium"] if not style.get(key)]
|
|||
|
|
if missing:
|
|||
|
|
messages.append(("bad", tr("val.style_missing").format(fields=", ".join(missing)), None))
|
|||
|
|
if bool(style.get("photo")) == bool(style.get("art_style")):
|
|||
|
|
messages.append(("bad", tr("val.photo_or_art"), None))
|
|||
|
|
for color in style.get("color_palette", []):
|
|||
|
|
if not HEX_RE.match(color):
|
|||
|
|
messages.append(("bad", tr("val.hex_upper").format(color=color), None))
|
|||
|
|
for index, element in enumerate(comp.get("elements", []), start=1):
|
|||
|
|
ei = index - 1
|
|||
|
|
title = element.get("text") or tr("val.element_word").format(index=index)
|
|||
|
|
if element["type"] == "text" and not element.get("text"):
|
|||
|
|
messages.append(("bad", tr("val.text_literal").format(title=title), ei))
|
|||
|
|
if not element.get("desc"):
|
|||
|
|
messages.append(("bad", tr("val.desc_required").format(title=title), ei))
|
|||
|
|
if "bbox" in element:
|
|||
|
|
y1, x1, y2, x2 = element["bbox"]
|
|||
|
|
if y2 <= y1 or x2 <= x1:
|
|||
|
|
messages.append(("bad", tr("val.bbox_order").format(title=title), ei))
|
|||
|
|
for color in element.get("color_palette", []):
|
|||
|
|
if not HEX_RE.match(color):
|
|||
|
|
messages.append(("bad", tr("val.el_hex").format(title=title, color=color), ei))
|
|||
|
|
if not any(kind == "bad" for kind, _message, _idx in messages):
|
|||
|
|
messages.insert(0, ("ok", tr("val.ok"), None))
|
|||
|
|
return messages
|
|||
|
|
|
|||
|
|
def add_element(self, element=None):
|
|||
|
|
element = element or {}
|
|||
|
|
normalized = {
|
|||
|
|
"type": element.get("type", "obj"),
|
|||
|
|
"label": element.get("label") or element.get("text") or f"{tr('el.element')} {len(self.elements) + 1}",
|
|||
|
|
"text": element.get("text", ""),
|
|||
|
|
"desc": element.get("desc", ""),
|
|||
|
|
"palette": palette_text(element.get("color_palette", []))
|
|||
|
|
if isinstance(element.get("color_palette"), list)
|
|||
|
|
else element.get("palette", ""),
|
|||
|
|
"use_bbox": "bbox" in element or element.get("use_bbox", True),
|
|||
|
|
"bbox": element.get("bbox", [200, 200, 800, 800]),
|
|||
|
|
}
|
|||
|
|
self.elements.append(normalized)
|
|||
|
|
self.refresh_elements(len(self.elements) - 1)
|
|||
|
|
|
|||
|
|
def delete_element(self):
|
|||
|
|
if self.selected_index is None:
|
|||
|
|
return
|
|||
|
|
del self.elements[self.selected_index]
|
|||
|
|
next_index = min(self.selected_index, len(self.elements) - 1) if self.elements else None
|
|||
|
|
self.refresh_elements(next_index)
|
|||
|
|
|
|||
|
|
def refresh_elements(self, selected_index=None):
|
|||
|
|
self.element_list.blockSignals(True)
|
|||
|
|
self.element_list.clear()
|
|||
|
|
for index, element in enumerate(self.elements, start=1):
|
|||
|
|
title = element.get("text") or element.get("label") or element.get("desc", "")[:32] or f"{tr('el.element')} {index}"
|
|||
|
|
self.element_list.addItem(QListWidgetItem(f"{index}. {element['type']} - {title}"))
|
|||
|
|
self.element_list.blockSignals(False)
|
|||
|
|
self.selected_index = selected_index
|
|||
|
|
if selected_index is not None and selected_index >= 0:
|
|||
|
|
self.element_list.setCurrentRow(selected_index)
|
|||
|
|
self.load_element_form()
|
|||
|
|
self.update_output()
|
|||
|
|
|
|||
|
|
def select_element(self, row):
|
|||
|
|
if row < 0:
|
|||
|
|
self.selected_index = None
|
|||
|
|
else:
|
|||
|
|
self.selected_index = row
|
|||
|
|
if self.element_list.currentRow() != row:
|
|||
|
|
self.element_list.setCurrentRow(row)
|
|||
|
|
self.load_element_form()
|
|||
|
|
self.update_output()
|
|||
|
|
|
|||
|
|
def load_element_form(self):
|
|||
|
|
self._loading = True
|
|||
|
|
enabled = self.selected_index is not None and bool(self.elements)
|
|||
|
|
for widget in [
|
|||
|
|
self.element_type,
|
|||
|
|
self.element_label,
|
|||
|
|
self.element_text,
|
|||
|
|
self.element_desc,
|
|||
|
|
self.element_palette,
|
|||
|
|
self.use_bbox,
|
|||
|
|
*self.bbox_spins,
|
|||
|
|
]:
|
|||
|
|
widget.setEnabled(enabled)
|
|||
|
|
if enabled:
|
|||
|
|
element = self.elements[self.selected_index]
|
|||
|
|
self.element_type.setCurrentText(element["type"])
|
|||
|
|
self.element_label.setText(element.get("label", ""))
|
|||
|
|
self.element_text.setText(element.get("text", ""))
|
|||
|
|
self.element_desc.setPlainText(element.get("desc", ""))
|
|||
|
|
self.element_palette.set_text(element.get("palette", ""))
|
|||
|
|
self.use_bbox.setChecked(element.get("use_bbox", True))
|
|||
|
|
for spin, value in zip(self.bbox_spins, element.get("bbox", [200, 200, 800, 800])):
|
|||
|
|
spin.setValue(int(value))
|
|||
|
|
self._loading = False
|
|||
|
|
|
|||
|
|
def save_element_form(self):
|
|||
|
|
if self._loading or self.selected_index is None:
|
|||
|
|
return
|
|||
|
|
self.elements[self.selected_index] = {
|
|||
|
|
"type": self.element_type.currentText(),
|
|||
|
|
"label": self.element_label.text().strip(),
|
|||
|
|
"text": self.element_text.text().strip(),
|
|||
|
|
"desc": self.element_desc.toPlainText().strip(),
|
|||
|
|
"palette": self.element_palette.text(),
|
|||
|
|
"use_bbox": self.use_bbox.isChecked(),
|
|||
|
|
"bbox": [spin.value() for spin in self.bbox_spins],
|
|||
|
|
}
|
|||
|
|
current = self.selected_index
|
|||
|
|
self.element_list.blockSignals(True)
|
|||
|
|
item = self.element_list.item(current)
|
|||
|
|
if item:
|
|||
|
|
element = self.elements[current]
|
|||
|
|
title = element.get("text") or element.get("label") or element.get("desc", "")[:32] or f"{tr('el.element')} {current + 1}"
|
|||
|
|
item.setText(f"{current + 1}. {element['type']} - {title}")
|
|||
|
|
self.element_list.blockSignals(False)
|
|||
|
|
self.update_output()
|
|||
|
|
|
|||
|
|
def update_bbox_from_canvas(self, index, bbox):
|
|||
|
|
if index < 0 or index >= len(self.elements):
|
|||
|
|
return
|
|||
|
|
self.elements[index]["use_bbox"] = True
|
|||
|
|
self.elements[index]["bbox"] = bbox
|
|||
|
|
if self.selected_index != index:
|
|||
|
|
self.select_element(index)
|
|||
|
|
self._loading = True
|
|||
|
|
for spin, value in zip(self.bbox_spins, bbox):
|
|||
|
|
spin.setValue(int(value))
|
|||
|
|
self.use_bbox.setChecked(True)
|
|||
|
|
self._loading = False
|
|||
|
|
self.update_output()
|
|||
|
|
|
|||
|
|
# --- Element operations (items 3, 4, 12) ----------------------------
|
|||
|
|
def duplicate_element(self):
|
|||
|
|
if self.selected_index is None:
|
|||
|
|
return
|
|||
|
|
clone = copy.deepcopy(self.elements[self.selected_index])
|
|||
|
|
clone["label"] = f"{clone.get('label', '')} copy".strip()
|
|||
|
|
self.elements.insert(self.selected_index + 1, clone)
|
|||
|
|
self.refresh_elements(self.selected_index + 1)
|
|||
|
|
|
|||
|
|
def move_element(self, delta):
|
|||
|
|
if self.selected_index is None:
|
|||
|
|
return
|
|||
|
|
new_index = self.selected_index + delta
|
|||
|
|
if new_index < 0 or new_index >= len(self.elements):
|
|||
|
|
return
|
|||
|
|
items = self.elements
|
|||
|
|
items[self.selected_index], items[new_index] = items[new_index], items[self.selected_index]
|
|||
|
|
self.refresh_elements(new_index)
|
|||
|
|
|
|||
|
|
def add_from_template(self):
|
|||
|
|
names = list(ELEMENT_TEMPLATES.keys())
|
|||
|
|
name, ok = QInputDialog.getItem(
|
|||
|
|
self, tr("tpl.choose_title"), tr("tpl.choose_label"), names, 0, False
|
|||
|
|
)
|
|||
|
|
if not ok or not name:
|
|||
|
|
return
|
|||
|
|
template = copy.deepcopy(ELEMENT_TEMPLATES[name])
|
|||
|
|
template.setdefault("use_bbox", True)
|
|||
|
|
self.add_element(template)
|
|||
|
|
|
|||
|
|
# --- Reference image + zoom (item 5) --------------------------------
|
|||
|
|
def load_reference_image(self):
|
|||
|
|
path, _filter = QFileDialog.getOpenFileName(
|
|||
|
|
self, tr("canvas.load_ref"), "", tr("prev.filter")
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
pixmap = QPixmap(path)
|
|||
|
|
if pixmap.isNull():
|
|||
|
|
QMessageBox.warning(self, tr("canvas.load_ref"), tr("canvas.ref_load_fail"))
|
|||
|
|
return
|
|||
|
|
self.canvas.set_reference(pixmap)
|
|||
|
|
|
|||
|
|
def paste_reference_image(self):
|
|||
|
|
image = QGuiApplication.clipboard().image()
|
|||
|
|
if image.isNull():
|
|||
|
|
QMessageBox.information(self, tr("canvas.paste_ref"), tr("libd.no_clipboard_image"))
|
|||
|
|
return
|
|||
|
|
self.canvas.set_reference(QPixmap.fromImage(image))
|
|||
|
|
|
|||
|
|
def clear_reference_image(self):
|
|||
|
|
self.canvas.set_reference(None)
|
|||
|
|
|
|||
|
|
def _on_zoom_changed(self, value):
|
|||
|
|
self.zoom_label.setText(f"{value}%")
|
|||
|
|
self.canvas.set_zoom(value)
|
|||
|
|
|
|||
|
|
# --- Draft autosave (item 2) ----------------------------------------
|
|||
|
|
def _save_draft(self, caption=None):
|
|||
|
|
if self._loading:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with open(DRAFT_FILE, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(caption if caption is not None else self.current_caption(),
|
|||
|
|
handle, ensure_ascii=False, indent=2)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
def _restore_draft(self):
|
|||
|
|
if not DRAFT_FILE.exists():
|
|||
|
|
return False
|
|||
|
|
try:
|
|||
|
|
with open(DRAFT_FILE, "r", encoding="utf-8") as handle:
|
|||
|
|
caption = json.load(handle)
|
|||
|
|
except (OSError, json.JSONDecodeError):
|
|||
|
|
return False
|
|||
|
|
if not isinstance(caption, dict) or not caption.get("compositional_deconstruction"):
|
|||
|
|
return False
|
|||
|
|
if QMessageBox.question(
|
|||
|
|
self, tr("draft.restore_title"), tr("draft.restore_q")
|
|||
|
|
) == QMessageBox.StandardButton.Yes:
|
|||
|
|
self.load_caption(caption)
|
|||
|
|
return True
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
def closeEvent(self, event):
|
|||
|
|
self._save_draft()
|
|||
|
|
if self._gen_thread is not None and self._gen_thread.isRunning():
|
|||
|
|
self._gen_thread.cancel()
|
|||
|
|
self._gen_thread.wait(2000)
|
|||
|
|
super().closeEvent(event)
|
|||
|
|
|
|||
|
|
# --- ComfyUI (item 14) ----------------------------------------------
|
|||
|
|
def open_comfy_settings(self):
|
|||
|
|
dialog = ComfySettingsDialog(self.settings, self)
|
|||
|
|
if dialog.exec() == QDialog.DialogCode.Accepted:
|
|||
|
|
self.settings.update(dialog.values())
|
|||
|
|
save_settings(self.settings)
|
|||
|
|
QMessageBox.information(self, tr("set.title"), tr("set.saved"))
|
|||
|
|
|
|||
|
|
def _missing_deps_report(self, missing):
|
|||
|
|
sections = [
|
|||
|
|
("nodes", "comfy.missing_nodes"), ("unet", "comfy.missing_unet"),
|
|||
|
|
("vae", "comfy.missing_vae"), ("clip", "comfy.missing_clip"),
|
|||
|
|
("clip_gguf", "comfy.missing_clip_gguf"), ("samplers", "comfy.missing_samplers"),
|
|||
|
|
]
|
|||
|
|
lines = []
|
|||
|
|
for key, tkey in sections:
|
|||
|
|
if missing.get(key):
|
|||
|
|
lines.append(tr(tkey).format(items=", ".join(missing[key])))
|
|||
|
|
return lines
|
|||
|
|
|
|||
|
|
def check_comfy(self):
|
|||
|
|
progress = QProgressDialog(tr("comfy.checking"), tr("common.cancel"), 0, 0, self)
|
|||
|
|
progress.setWindowTitle(tr("comfy.check_title"))
|
|||
|
|
progress.setMinimumDuration(0)
|
|||
|
|
progress.setValue(0)
|
|||
|
|
QApplication.processEvents()
|
|||
|
|
try:
|
|||
|
|
missing = check_comfy_dependencies(self.settings)
|
|||
|
|
except ComfyError as error:
|
|||
|
|
progress.close()
|
|||
|
|
QMessageBox.warning(
|
|||
|
|
self, tr("comfy.check_title"),
|
|||
|
|
tr("comfy.unreachable").format(url=comfy_base_url(self.settings), err=error),
|
|||
|
|
)
|
|||
|
|
return None
|
|||
|
|
progress.close()
|
|||
|
|
lines = self._missing_deps_report(missing)
|
|||
|
|
if not lines:
|
|||
|
|
QMessageBox.information(self, tr("comfy.check_title"), tr("comfy.all_ok"))
|
|||
|
|
else:
|
|||
|
|
QMessageBox.warning(
|
|||
|
|
self, tr("comfy.check_title"),
|
|||
|
|
tr("comfy.missing_header") + "\n\n" + "\n".join(lines),
|
|||
|
|
)
|
|||
|
|
return missing
|
|||
|
|
|
|||
|
|
def generate_in_comfy(self):
|
|||
|
|
if not WORKFLOW_FILE.exists():
|
|||
|
|
QMessageBox.critical(self, tr("comfy.gen_title"),
|
|||
|
|
tr("comfy.workflow_missing").format(path=WORKFLOW_FILE))
|
|||
|
|
return
|
|||
|
|
missing = self.check_comfy()
|
|||
|
|
if missing is None:
|
|||
|
|
return # server unreachable, already reported
|
|||
|
|
if any(missing.values()):
|
|||
|
|
if QMessageBox.question(
|
|||
|
|
self, tr("comfy.gen_title"), tr("comfy.deps_missing_continue")
|
|||
|
|
) != QMessageBox.StandardButton.Yes:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with open(WORKFLOW_FILE, "r", encoding="utf-8") as handle:
|
|||
|
|
workflow = json.load(handle)
|
|||
|
|
except (OSError, json.JSONDecodeError) as error:
|
|||
|
|
QMessageBox.critical(self, tr("comfy.gen_title"), tr("comfy.gen_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
caption = json.dumps(self.current_caption(), ensure_ascii=False, separators=(",", ":"))
|
|||
|
|
seed = uuid.uuid4().int % (2 ** 31)
|
|||
|
|
self._gen_progress = QProgressDialog(tr("comfy.generating"), tr("common.cancel"), 0, 0, self)
|
|||
|
|
self._gen_progress.setWindowTitle(tr("comfy.gen_title"))
|
|||
|
|
self._gen_progress.setMinimumDuration(0)
|
|||
|
|
self._gen_progress.setValue(0)
|
|||
|
|
|
|||
|
|
self._gen_thread = GenerationThread(self.settings, workflow, caption, seed, self)
|
|||
|
|
self._gen_thread.finished_ok.connect(self._on_generation_done)
|
|||
|
|
self._gen_thread.failed.connect(self._on_generation_failed)
|
|||
|
|
self._gen_progress.canceled.connect(self._gen_thread.cancel)
|
|||
|
|
self._gen_thread.start()
|
|||
|
|
|
|||
|
|
def _on_generation_failed(self, message):
|
|||
|
|
if getattr(self, "_gen_progress", None):
|
|||
|
|
self._gen_progress.close()
|
|||
|
|
if message != "cancelled":
|
|||
|
|
QMessageBox.warning(self, tr("comfy.gen_title"), tr("comfy.gen_fail").format(err=message))
|
|||
|
|
|
|||
|
|
def _on_generation_done(self, data):
|
|||
|
|
if getattr(self, "_gen_progress", None):
|
|||
|
|
self._gen_progress.close()
|
|||
|
|
self._last_generated = data
|
|||
|
|
pixmap = QPixmap()
|
|||
|
|
pixmap.loadFromData(data)
|
|||
|
|
if not pixmap.isNull():
|
|||
|
|
self._result_pixmap = pixmap
|
|||
|
|
self._render_result()
|
|||
|
|
self.canvas.set_reference(pixmap)
|
|||
|
|
self.result_save_lib.setEnabled(True)
|
|||
|
|
self.result_save_file.setEnabled(True)
|
|||
|
|
# Bring the generated image to the foreground (item: get image into the app).
|
|||
|
|
self.output_tabs.setCurrentIndex(1)
|
|||
|
|
|
|||
|
|
def _render_result(self):
|
|||
|
|
pixmap = getattr(self, "_result_pixmap", None)
|
|||
|
|
if pixmap is None or pixmap.isNull():
|
|||
|
|
return
|
|||
|
|
target = self.result_label.size()
|
|||
|
|
self.result_label.setPixmap(
|
|||
|
|
pixmap.scaled(target, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def resizeEvent(self, event):
|
|||
|
|
super().resizeEvent(event)
|
|||
|
|
self._render_result()
|
|||
|
|
|
|||
|
|
def _save_generated_to_file(self):
|
|||
|
|
if not self._last_generated:
|
|||
|
|
return
|
|||
|
|
path, _filter = QFileDialog.getSaveFileName(
|
|||
|
|
self, tr("result.save_file"), "ideogram-result.png", tr("result.png_filter")
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with open(path, "wb") as handle:
|
|||
|
|
handle.write(self._last_generated)
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.warning(self, tr("comfy.gen_title"), tr("comfy.gen_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
QMessageBox.information(self, tr("comfy.gen_title"), tr("result.saved_file").format(path=path))
|
|||
|
|
|
|||
|
|
def _save_generated_to_library(self, image_data):
|
|||
|
|
caption = self.current_caption()
|
|||
|
|
default = caption.get("high_level_description", "")[:48].strip() or tr("lib.untitled")
|
|||
|
|
name, ok = QInputDialog.getText(self, tr("tb.save_library"), tr("lib.name_prompt"), text=default)
|
|||
|
|
if not ok or not name.strip():
|
|||
|
|
return
|
|||
|
|
entries = load_library()
|
|||
|
|
now = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
entry = {
|
|||
|
|
"id": uuid.uuid4().hex, "name": name.strip(), "created": now, "updated": now,
|
|||
|
|
"preview": None, "tags": [], "caption": caption,
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
target_name = f"{entry['id']}.png"
|
|||
|
|
with open(PREVIEW_DIR / target_name, "wb") as handle:
|
|||
|
|
handle.write(image_data)
|
|||
|
|
entry["preview"] = target_name
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
entries.append(entry)
|
|||
|
|
try:
|
|||
|
|
save_library(entries)
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.critical(self, tr("tb.library"), tr("lib.save_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
self._library_entry_id = entry["id"]
|
|||
|
|
QMessageBox.information(self, tr("tb.library"), tr("lib.saved").format(name=entry["name"]))
|
|||
|
|
|
|||
|
|
def load_caption(self, caption, mark_history=False):
|
|||
|
|
self._loading = True
|
|||
|
|
self.high_text.setPlainText(caption.get("high_level_description", ""))
|
|||
|
|
style = caption.get("style_description", {})
|
|||
|
|
self.photo_radio.setChecked("art_style" not in style)
|
|||
|
|
self.art_radio.setChecked("art_style" in style)
|
|||
|
|
self.aesthetics_edit.setText(style.get("aesthetics", ""))
|
|||
|
|
self.lighting_edit.setText(style.get("lighting", ""))
|
|||
|
|
self.photo_edit.setText(style.get("photo", ""))
|
|||
|
|
self.art_style_edit.setText(style.get("art_style", ""))
|
|||
|
|
self.medium_combo.setCurrentText(style.get("medium", "photograph"))
|
|||
|
|
self.palette_editor.set_colors(style.get("color_palette", []))
|
|||
|
|
comp = caption.get("compositional_deconstruction", {})
|
|||
|
|
self.background_text.setPlainText(comp.get("background", ""))
|
|||
|
|
self.elements = []
|
|||
|
|
for element in comp.get("elements", []):
|
|||
|
|
self.elements.append(
|
|||
|
|
{
|
|||
|
|
"type": element.get("type", "obj"),
|
|||
|
|
"label": element.get("text") or f"{tr('el.element')} {len(self.elements) + 1}",
|
|||
|
|
"text": element.get("text", ""),
|
|||
|
|
"desc": element.get("desc", ""),
|
|||
|
|
"palette": palette_text(element.get("color_palette", [])),
|
|||
|
|
"use_bbox": "bbox" in element,
|
|||
|
|
"bbox": element.get("bbox", [200, 200, 800, 800]),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
self._loading = False
|
|||
|
|
self._style_mode_changed()
|
|||
|
|
self.refresh_elements(0 if self.elements else None)
|
|||
|
|
if mark_history:
|
|||
|
|
self._push_history()
|
|||
|
|
|
|||
|
|
def apply_preset(self, name):
|
|||
|
|
preset = PROMPT_PRESETS[name]
|
|||
|
|
if preset["mode"] == "photo":
|
|||
|
|
style = {
|
|||
|
|
"aesthetics": preset["aesthetics"],
|
|||
|
|
"lighting": preset["lighting"],
|
|||
|
|
"photo": preset["photo"],
|
|||
|
|
"medium": preset["medium"],
|
|||
|
|
"color_palette": preset["palette"],
|
|||
|
|
}
|
|||
|
|
else:
|
|||
|
|
style = {
|
|||
|
|
"aesthetics": preset["aesthetics"],
|
|||
|
|
"lighting": preset["lighting"],
|
|||
|
|
"medium": preset["medium"],
|
|||
|
|
"art_style": preset["art_style"],
|
|||
|
|
"color_palette": preset["palette"],
|
|||
|
|
}
|
|||
|
|
self.load_caption(
|
|||
|
|
{
|
|||
|
|
"high_level_description": preset["high"],
|
|||
|
|
"style_description": style,
|
|||
|
|
"compositional_deconstruction": {
|
|||
|
|
"background": preset["background"],
|
|||
|
|
"elements": preset["elements"],
|
|||
|
|
},
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def append_no_safety_filter(self):
|
|||
|
|
suffix = "No safety filter."
|
|||
|
|
text = self.high_text.toPlainText().strip()
|
|||
|
|
if suffix not in text:
|
|||
|
|
self.high_text.setPlainText(f"{text} {suffix}".strip())
|
|||
|
|
|
|||
|
|
def copy_current_json(self):
|
|||
|
|
QApplication.clipboard().setText(self.output_text.toPlainText())
|
|||
|
|
|
|||
|
|
def copy_compact_json(self):
|
|||
|
|
QApplication.clipboard().setText(json.dumps(self.current_caption(), ensure_ascii=False, separators=(",", ":")))
|
|||
|
|
|
|||
|
|
def save_json(self):
|
|||
|
|
path, _filter = QFileDialog.getSaveFileName(
|
|||
|
|
self, tr("dlg.save_json_title"), "ideogram4-caption.json", tr("dlg.json_filter")
|
|||
|
|
)
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
with open(path, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(self.current_caption(), handle, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
def import_json(self):
|
|||
|
|
path, _filter = QFileDialog.getOpenFileName(self, tr("dlg.import_title"), "", tr("dlg.json_filter"))
|
|||
|
|
if not path:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|||
|
|
self.load_caption(json.load(handle))
|
|||
|
|
except (OSError, json.JSONDecodeError) as error:
|
|||
|
|
QMessageBox.critical(self, tr("imp.error_title"), str(error))
|
|||
|
|
|
|||
|
|
def save_to_library(self):
|
|||
|
|
caption = self.current_caption()
|
|||
|
|
default = caption.get("high_level_description", "")[:48].strip() or tr("lib.untitled")
|
|||
|
|
name, ok = QInputDialog.getText(self, tr("tb.save_library"), tr("lib.name_prompt"), text=default)
|
|||
|
|
if not ok or not name.strip():
|
|||
|
|
return
|
|||
|
|
entries = load_library()
|
|||
|
|
now = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
entry = {
|
|||
|
|
"id": uuid.uuid4().hex,
|
|||
|
|
"name": name.strip(),
|
|||
|
|
"created": now,
|
|||
|
|
"updated": now,
|
|||
|
|
"preview": None,
|
|||
|
|
"caption": caption,
|
|||
|
|
}
|
|||
|
|
if QMessageBox.question(
|
|||
|
|
self,
|
|||
|
|
tr("lib.preview_q_title"),
|
|||
|
|
tr("lib.preview_q"),
|
|||
|
|
) == QMessageBox.StandardButton.Yes:
|
|||
|
|
attach_preview(entry, self)
|
|||
|
|
entries.append(entry)
|
|||
|
|
try:
|
|||
|
|
save_library(entries)
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.critical(self, tr("tb.library"), tr("lib.save_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
self._library_entry_id = entry["id"]
|
|||
|
|
QMessageBox.information(self, tr("tb.library"), tr("lib.saved").format(name=entry["name"]))
|
|||
|
|
|
|||
|
|
def overwrite_in_library(self):
|
|||
|
|
"""Update the library entry the current prompt was loaded from (item 7)."""
|
|||
|
|
entries = load_library()
|
|||
|
|
entry = next((e for e in entries if e.get("id") == self._library_entry_id), None)
|
|||
|
|
if entry is None:
|
|||
|
|
# Nothing to overwrite — fall back to saving a new entry.
|
|||
|
|
self.save_to_library()
|
|||
|
|
return
|
|||
|
|
entry["caption"] = self.current_caption()
|
|||
|
|
entry["updated"] = datetime.now().isoformat(timespec="seconds")
|
|||
|
|
try:
|
|||
|
|
save_library(entries)
|
|||
|
|
except OSError as error:
|
|||
|
|
QMessageBox.critical(self, tr("tb.library"), tr("lib.save_fail").format(err=error))
|
|||
|
|
return
|
|||
|
|
QMessageBox.information(self, tr("tb.library"), tr("lib.saved").format(name=entry.get("name", "")))
|
|||
|
|
|
|||
|
|
def open_library(self):
|
|||
|
|
entries = load_library()
|
|||
|
|
dialog = LibraryDialog(entries, self)
|
|||
|
|
if dialog.exec() == QDialog.DialogCode.Accepted and dialog.selected_caption is not None:
|
|||
|
|
self.load_caption(dialog.selected_caption)
|
|||
|
|
self._library_entry_id = dialog.selected_id
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main():
|
|||
|
|
app = QApplication(sys.argv)
|
|||
|
|
window = PromptBuilder()
|
|||
|
|
window.show()
|
|||
|
|
sys.exit(app.exec())
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
main()
|