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()