From 34a40ae759aced7cc5764db950a95471ab832371 Mon Sep 17 00:00:00 2001 From: dinlo Date: Thu, 2 Jul 2026 14:09:57 +0800 Subject: [PATCH] Initial release: Comfy-Studio v1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Десктопная галерея и менеджер изображений ComfyUI (PyQt6 + SQLite): - мультипапочный мониторинг (watchdog), грид миниатюр, drag-and-drop - теги, избранное, рейтинг, поиск по промтам - парсер метаданных ComfyUI (PNG/WebP/JPEG), редактор workflow/prompt JSON - перевод выделенного текста через контекстное меню (настраиваемые языки) - отправка промта в очередь ComfyUI с внедрением правок и нового seed - полноэкранный просмотр, локализация ru/en, тёмная тема Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 15 + README.md | 67 +++ comfy_studio/__init__.py | 3 + comfy_studio/core/__init__.py | 0 comfy_studio/core/config.py | 32 ++ comfy_studio/core/i18n.py | 153 +++++++ comfy_studio/core/settings.py | 45 ++ comfy_studio/db/__init__.py | 0 comfy_studio/db/manager.py | 389 ++++++++++++++++++ comfy_studio/db/schema.py | 60 +++ comfy_studio/parsers/__init__.py | 0 comfy_studio/parsers/metadata_parser.py | 272 ++++++++++++ comfy_studio/services/__init__.py | 0 comfy_studio/services/comfy_api.py | 65 +++ comfy_studio/services/translator.py | 69 ++++ comfy_studio/services/watcher.py | 146 +++++++ comfy_studio/ui/__init__.py | 0 comfy_studio/ui/main_window.py | 377 +++++++++++++++++ comfy_studio/ui/theme.py | 136 ++++++ comfy_studio/ui/widgets/__init__.py | 0 comfy_studio/ui/widgets/center_grid.py | 312 ++++++++++++++ comfy_studio/ui/widgets/left_panel.py | 99 +++++ comfy_studio/ui/widgets/right_panel.py | 389 ++++++++++++++++++ comfy_studio/ui/widgets/settings_dialog.py | 118 ++++++ .../ui/widgets/translatable_text_edit.py | 82 ++++ comfy_studio/ui/widgets/translate_dialog.py | 80 ++++ comfy_studio/ui/widgets/viewer.py | 99 +++++ main.py | 96 +++++ requirements.txt | 5 + 29 files changed, 3109 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 comfy_studio/__init__.py create mode 100644 comfy_studio/core/__init__.py create mode 100644 comfy_studio/core/config.py create mode 100644 comfy_studio/core/i18n.py create mode 100644 comfy_studio/core/settings.py create mode 100644 comfy_studio/db/__init__.py create mode 100644 comfy_studio/db/manager.py create mode 100644 comfy_studio/db/schema.py create mode 100644 comfy_studio/parsers/__init__.py create mode 100644 comfy_studio/parsers/metadata_parser.py create mode 100644 comfy_studio/services/__init__.py create mode 100644 comfy_studio/services/comfy_api.py create mode 100644 comfy_studio/services/translator.py create mode 100644 comfy_studio/services/watcher.py create mode 100644 comfy_studio/ui/__init__.py create mode 100644 comfy_studio/ui/main_window.py create mode 100644 comfy_studio/ui/theme.py create mode 100644 comfy_studio/ui/widgets/__init__.py create mode 100644 comfy_studio/ui/widgets/center_grid.py create mode 100644 comfy_studio/ui/widgets/left_panel.py create mode 100644 comfy_studio/ui/widgets/right_panel.py create mode 100644 comfy_studio/ui/widgets/settings_dialog.py create mode 100644 comfy_studio/ui/widgets/translatable_text_edit.py create mode 100644 comfy_studio/ui/widgets/translate_dialog.py create mode 100644 comfy_studio/ui/widgets/viewer.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1e8652 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ + +# Данные приложения (локальные, создаются при запуске) +comfystudio.db +comfystudio.log +settings.json + +# IDE +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ac0bd3b --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Comfy-Studio + +Десктопная галерея и менеджер изображений, сгенерированных в **ComfyUI**. +Объединяет лучшее из проектов ComfyGallery и ComfyGallery-qwen. + +## Возможности + +- 📁 **Мультипапочный мониторинг** — отслеживание нескольких папок генерации в реальном времени (watchdog) +- 🖼️ **Грид миниатюр** с асинхронной загрузкой, зумом и drag-and-drop во внешние приложения +- 🔍 **Поиск** по имени файла и тексту промтов +- 🏷️ **Теги** — добавление/удаление тегов, фильтрация по тегу +- ⭐ **Избранное** — пометка изображений и фильтр «только избранное» +- ⭐ **Рейтинг** от 1 до 5 звёзд +- 📝 **Метаданные ComfyUI** — извлечение prompt/workflow из PNG, WebP и JPEG (EXIF), парсинг параметров: модель, сэмплер, seed, steps, CFG +- ✏️ **Редактор workflow и prompt JSON** — вкладки Workflow и Prompt JSON редактируемые: валидация, форматирование, сохранение в БД (при изменении prompt параметры генерации пересчитываются автоматически) +- 🌍 **Перевод выделенного текста** — в любом редактируемом поле выделите текст → правая кнопка мыши → «Перевести на …»; перевод показывается во всплывающем окне, его можно отредактировать и применить или отменить. Список языков настраивается в настройках +- 🖥️ **Полноэкранный просмотр** — двойной клик или Enter; листание стрелками/колесом, выход по Esc +- 🚀 **Отправка в ComfyUI** — постановка prompt-графа изображения в очередь генерации через API +- 🌐 **Два языка интерфейса** — русский и английский (переключение в настройках) +- 🌙 Тёмная тема + +## Установка + +```bash +pip install -r requirements.txt +``` + +## Запуск + +```bash +python main.py +``` + +При первом запуске создаются `settings.json`, `comfystudio.db` и `comfystudio.log` рядом с проектом. + +## Настройка + +Кнопка **«Настройки»** в главном окне: +- **Адрес API ComfyUI** — например `http://127.0.0.1:8188` +- **Отслеживаемые папки** — папки, куда ComfyUI сохраняет результаты +- **Язык интерфейса** — русский / English (применяется после перезапуска) +- **Языки перевода** — отметьте языки, которые появятся в контекстном меню «Перевести на …» (12 языков на выбор) + +## Горячие клавиши + +| Клавиша | Действие | +|---|---| +| `Enter` / двойной клик | Полноэкранный просмотр | +| `←` `→` / колесо мыши | Листание в полноэкранном режиме | +| `Esc` | Выход из полноэкранного режима | +| `Ctrl+C` | Копировать изображение в буфер | +| `Delete` | Удалить выбранные файлы | + +## Структура проекта + +``` +Comfy-Studio/ +├── main.py # точка входа +├── comfy_studio/ +│ ├── core/ # config, settings, i18n +│ ├── db/ # схема и менеджер SQLite +│ ├── parsers/ # парсер метаданных ComfyUI +│ ├── services/ # watcher, клиент API ComfyUI +│ └── ui/ # тема, главное окно, виджеты +│ └── widgets/ # панели, грид, просмотрщик, настройки +└── requirements.txt +``` diff --git a/comfy_studio/__init__.py b/comfy_studio/__init__.py new file mode 100644 index 0000000..ca226d9 --- /dev/null +++ b/comfy_studio/__init__.py @@ -0,0 +1,3 @@ +"""Comfy-Studio — desktop gallery and workflow manager for ComfyUI images.""" + +__version__ = "1.0.0" diff --git a/comfy_studio/core/__init__.py b/comfy_studio/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/core/config.py b/comfy_studio/core/config.py new file mode 100644 index 0000000..b470528 --- /dev/null +++ b/comfy_studio/core/config.py @@ -0,0 +1,32 @@ +""" +Конфигурационные константы приложения. +Application configuration constants. +""" + +from pathlib import Path + +APP_NAME = "Comfy-Studio" +APP_VERSION = "1.0.0" + +# Данные храним рядом с проектом (портативный режим) +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +DB_PATH = PROJECT_ROOT / "comfystudio.db" +SETTINGS_PATH = PROJECT_ROOT / "settings.json" +LOG_PATH = PROJECT_ROOT / "comfystudio.log" + +# Поддерживаемые расширения изображений +SUPPORTED_EXTENSIONS = (".png", ".jpg", ".jpeg", ".webp") + +# --- ComfyUI --- +DEFAULT_COMFYUI_URL = "http://127.0.0.1:8188" +COMFYUI_PROMPT_ENDPOINT = "/prompt" + +# --- UI --- +DEFAULT_WINDOW_WIDTH = 1400 +DEFAULT_WINDOW_HEIGHT = 900 +DEFAULT_THUMBNAIL_SIZE = 130 +MIN_THUMBNAIL_SIZE = 80 +MAX_THUMBNAIL_SIZE = 300 + +# --- Логирование --- +LOGGING_LEVEL = "INFO" diff --git a/comfy_studio/core/i18n.py b/comfy_studio/core/i18n.py new file mode 100644 index 0000000..069c646 --- /dev/null +++ b/comfy_studio/core/i18n.py @@ -0,0 +1,153 @@ +""" +Простая система локализации ru/en. +Использование: + from comfy_studio.core.i18n import tr, set_language + tr("search_placeholder") -> "Поиск по имени или промтам..." | "Search by name or prompts..." +""" + +from typing import Dict + +_current_language = "ru" + +STRINGS: Dict[str, Dict[str, str]] = { + # --- Общее --- + "app_ready": {"ru": "Готов к работе", "en": "Ready"}, + "error": {"ru": "Ошибка", "en": "Error"}, + "success": {"ru": "Успешно", "en": "Success"}, + "save": {"ru": "Сохранить", "en": "Save"}, + "cancel": {"ru": "Отмена", "en": "Cancel"}, + "delete": {"ru": "Удалить", "en": "Delete"}, + "rename": {"ru": "Переименовать", "en": "Rename"}, + + # --- Левая панель --- + "search_placeholder": {"ru": "Поиск по имени или промтам...", "en": "Search by name or prompts..."}, + "active_folder": {"ru": "Активная папка:", "en": "Active folder:"}, + "subfolder_tree": {"ru": "Дерево подпапок:", "en": "Subfolder tree:"}, + "filter_favorites": {"ru": "Только избранное", "en": "Favorites only"}, + "filter_tag": {"ru": "Фильтр по тегу:", "en": "Filter by tag:"}, + "all_tags": {"ru": "Все теги", "en": "All tags"}, + + # --- Центральная панель --- + "select_folder": {"ru": "Выберите папку...", "en": "Select a folder..."}, + "folder_label": {"ru": "Папка", "en": "Folder"}, + "zoom": {"ru": "Масштаб:", "en": "Zoom:"}, + "files_shown": {"ru": "Файлов отображено", "en": "Files shown"}, + + # --- Правая панель --- + "no_image_selected": {"ru": "Нет выделенного изображения", "en": "No image selected"}, + "preview_error": {"ru": "Ошибка рендеринга превью", "en": "Preview rendering error"}, + "model": {"ru": "Модель:", "en": "Model:"}, + "sampler": {"ru": "Сэмплер:", "en": "Sampler:"}, + "rating": {"ru": "Рейтинг:", "en": "Rating:"}, + "no_rating": {"ru": "Без рейтинга", "en": "No rating"}, + "not_specified": {"ru": "Не указано", "en": "Not specified"}, + "positive_prompt": {"ru": "Позитивный промт:", "en": "Positive prompt:"}, + "negative_prompt": {"ru": "Негативный промт:", "en": "Negative prompt:"}, + "save_changes": {"ru": "Сохранить изменения", "en": "Save changes"}, + "workflow_not_found": {"ru": "Workflow не найден", "en": "Workflow not found"}, + "tab_generation": {"ru": "Генерация", "en": "Generation"}, + "tab_workflow": {"ru": "Workflow", "en": "Workflow"}, + "copy_positive": {"ru": "Коп. Pos", "en": "Copy Pos"}, + "copy_negative": {"ru": "Коп. Neg", "en": "Copy Neg"}, + "copy_workflow": {"ru": "Коп. Work", "en": "Copy WF"}, + "send_to_comfy": {"ru": "Отправить в ComfyUI", "en": "Send to ComfyUI"}, + "favorite": {"ru": "★ Избранное", "en": "★ Favorite"}, + "tags": {"ru": "Теги:", "en": "Tags:"}, + "add_tag_placeholder": {"ru": "Новый тег + Enter", "en": "New tag + Enter"}, + + # --- Контекстное меню --- + "show_on_disk": {"ru": "Показать на диске", "en": "Show on disk"}, + "delete_selected": {"ru": "Удалить выбранные", "en": "Delete selected"}, + "rename_title": {"ru": "Переименование", "en": "Rename"}, + "rename_prompt": {"ru": "Новое имя файла для", "en": "New filename for"}, + "open_fullscreen": {"ru": "Открыть на весь экран", "en": "Open fullscreen"}, + "toggle_favorite": {"ru": "Добавить/убрать из избранного", "en": "Toggle favorite"}, + + # --- Диалоги --- + "settings_title": {"ru": "Настройки", "en": "Settings"}, + "settings_btn": {"ru": "Настройки", "en": "Settings"}, + "comfy_api_url": {"ru": "Адрес API ComfyUI:", "en": "ComfyUI API URL:"}, + "tracked_folders": {"ru": "Отслеживаемые папки генерации:", "en": "Tracked generation folders:"}, + "add_folder": {"ru": "Добавить папку", "en": "Add folder"}, + "remove_selected": {"ru": "Удалить выбранную", "en": "Remove selected"}, + "language": {"ru": "Язык интерфейса:", "en": "Interface language:"}, + "language_restart_note": {"ru": "Язык применится после перезапуска приложения.", + "en": "Language change takes effect after restart."}, + "url_empty_error": {"ru": "Адрес API не может быть пустым.", "en": "API URL cannot be empty."}, + "paths_empty_error": {"ru": "Должна быть добавлена хотя бы одна папка.", + "en": "At least one folder must be added."}, + "choose_tracked_folder": {"ru": "Выбрать отслеживаемую папку", "en": "Select folder to track"}, + "delete_confirm_title": {"ru": "Удаление", "en": "Delete"}, + "delete_confirm_text": {"ru": "Вы действительно хотите безвозвратно удалить {n} файлов?", + "en": "Permanently delete {n} file(s)?"}, + "deleted_from_disk": {"ru": "Удалено файлов с диска", "en": "Files deleted from disk"}, + "copied_to_clipboard": {"ru": "Изображение скопировано в буфер обмена.", + "en": "Image copied to clipboard."}, + "renamed_ok": {"ru": "Файл успешно переименован", "en": "File renamed"}, + "rename_failed": {"ru": "Не удалось переименовать файл", "en": "Failed to rename file"}, + "settings_applied": {"ru": "Настройки успешно применены.", "en": "Settings applied."}, + "watcher_restarting": {"ru": "Перезапуск службы отслеживания...", "en": "Restarting watcher..."}, + "params_updated": {"ru": "Параметры успешно обновлены.", "en": "Parameters updated."}, + "db_save_error": {"ru": "Не удалось сохранить изменения в БД.", "en": "Failed to save changes to DB."}, + + # --- Перевод --- + "translate_to": {"ru": "Перевести на {lang}", "en": "Translate to {lang}"}, + "translation_title": {"ru": "Перевод", "en": "Translation"}, + "original_text": {"ru": "Оригинал:", "en": "Original:"}, + "translated_text": {"ru": "Перевод (можно редактировать):", + "en": "Translation (editable):"}, + "translating": {"ru": "Перевод...", "en": "Translating..."}, + "apply": {"ru": "Применить", "en": "Apply"}, + "translation_failed": {"ru": "Не удалось выполнить перевод", "en": "Translation failed"}, + "translation_languages": {"ru": "Языки перевода (в контекстном меню):", + "en": "Translation languages (in context menu):"}, + + # --- Редактор workflow/prompt --- + "tab_prompt_json": {"ru": "Prompt JSON", "en": "Prompt JSON"}, + "save_workflow": {"ru": "Сохранить workflow", "en": "Save workflow"}, + "save_prompt_json": {"ru": "Сохранить prompt", "en": "Save prompt"}, + "invalid_json": {"ru": "Некорректный JSON: {err}", "en": "Invalid JSON: {err}"}, + "json_saved": {"ru": "JSON сохранён в БД.", "en": "JSON saved to DB."}, + "format_json": {"ru": "Форматировать", "en": "Format"}, + "prompt_json_not_found": {"ru": "Prompt не найден", "en": "Prompt not found"}, + + "randomize_seed": {"ru": "Новый seed при отправке", "en": "New seed on send"}, + + # --- ComfyUI --- + "sending_prompt": {"ru": "Отправка промта в ComfyUI...", "en": "Sending prompt to ComfyUI..."}, + "prompt_queued": {"ru": "Промт успешно добавлен в очередь ComfyUI!", + "en": "Prompt queued in ComfyUI!"}, + "prompt_send_failed": {"ru": "Не удалось отправить промт.", "en": "Failed to send prompt."}, + "no_prompt_graph": {"ru": "У изображения отсутствует prompt-граф.", + "en": "Image has no prompt graph."}, + "network_error_title": {"ru": "Ошибка сети / API", "en": "Network / API error"}, + "task_id": {"ru": "ID задачи", "en": "Task ID"}, + "comfy_connect_error": {"ru": "Не удалось подключиться к ComfyUI по адресу {url}.\nУбедитесь, что сервер запущен.", + "en": "Could not connect to ComfyUI at {url}.\nMake sure the server is running."}, + "comfy_server_error": {"ru": "Сервер ComfyUI вернул ошибку {code}", "en": "ComfyUI server returned error {code}"}, + "metadata_error": {"ru": "Ошибка метаданных изображения (неверный формат JSON)", + "en": "Image metadata error (invalid JSON)"}, +} + + +def set_language(lang: str) -> None: + global _current_language + _current_language = lang if lang in ("ru", "en") else "ru" + + +def get_language() -> str: + return _current_language + + +def tr(key: str, **kwargs) -> str: + """Возвращает строку для текущего языка; {placeholders} подставляются из kwargs.""" + entry = STRINGS.get(key) + if not entry: + return key + text = entry.get(_current_language) or entry.get("ru") or key + if kwargs: + try: + text = text.format(**kwargs) + except (KeyError, IndexError): + pass + return text diff --git a/comfy_studio/core/settings.py b/comfy_studio/core/settings.py new file mode 100644 index 0000000..5e3e5a5 --- /dev/null +++ b/comfy_studio/core/settings.py @@ -0,0 +1,45 @@ +""" +Загрузка и сохранение пользовательских настроек (settings.json рядом с проектом). +""" + +import json +import logging +from typing import Dict, Any + +from comfy_studio.core.config import SETTINGS_PATH, DEFAULT_COMFYUI_URL + +logger = logging.getLogger(__name__) + +DEFAULT_SETTINGS: Dict[str, Any] = { + "comfyui_url": DEFAULT_COMFYUI_URL, + "tracked_paths": [], + "language": "ru", # "ru" | "en" + "thumbnail_size": 130, + "translate_languages": ["ru", "en"], # языки в контекстном меню перевода +} + + +def load_settings() -> Dict[str, Any]: + """Загружает настройки, недостающие ключи добираются из DEFAULT_SETTINGS.""" + if not SETTINGS_PATH.exists(): + save_settings(DEFAULT_SETTINGS) + return dict(DEFAULT_SETTINGS) + try: + with open(SETTINGS_PATH, "r", encoding="utf-8") as f: + data = json.load(f) + for k, v in DEFAULT_SETTINGS.items(): + data.setdefault(k, v) + return data + except Exception as e: + logger.error(f"Ошибка чтения настроек: {e}") + return dict(DEFAULT_SETTINGS) + + +def save_settings(settings: Dict[str, Any]) -> bool: + try: + with open(SETTINGS_PATH, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"Ошибка сохранения настроек: {e}") + return False diff --git a/comfy_studio/db/__init__.py b/comfy_studio/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/db/manager.py b/comfy_studio/db/manager.py new file mode 100644 index 0000000..f297e8f --- /dev/null +++ b/comfy_studio/db/manager.py @@ -0,0 +1,389 @@ +""" +Менеджер базы данных SQLite: папки, файлы, метаданные, теги, избранное. +""" + +import sqlite3 +import logging +from pathlib import Path +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +from comfy_studio.db.schema import PRAGMA_FOREIGN_KEYS, CREATE_TABLES_SQL, CREATE_INDEXES_SQL + +logger = logging.getLogger(__name__) + + +def _normalize(path: str) -> str: + return str(Path(path).resolve().as_posix()) + + +class DBManager: + def __init__(self, db_path: str = "comfystudio.db"): + self.db_path = str(db_path) + self._initialize_db() + + @contextmanager + def _get_connection(self): + conn = None + try: + conn = sqlite3.connect(self.db_path) + conn.execute(PRAGMA_FOREIGN_KEYS) + conn.row_factory = sqlite3.Row + yield conn + except sqlite3.Error as e: + logger.error(f"Ошибка соединения с БД {self.db_path}: {e}") + if conn: + conn.rollback() + raise + finally: + if conn: + conn.close() + + def _initialize_db(self) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.executescript(CREATE_TABLES_SQL) + cursor.executescript(CREATE_INDEXES_SQL) + conn.commit() + return True + except sqlite3.Error as e: + logger.critical(f"Критическая ошибка инициализации БД: {e}") + return False + + # ──────────────────────────────── + # Папки + # ──────────────────────────────── + + def add_folder(self, folder_path: str, parent_id: Optional[int] = None) -> Optional[int]: + if not folder_path: + return None + normalized_path = _normalize(folder_path) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "INSERT INTO folders (path, parent_id) VALUES (?, ?) " + "ON CONFLICT(path) DO UPDATE SET parent_id=excluded.parent_id RETURNING id", + (normalized_path, parent_id) + ) + result = cursor.fetchone() + if not result: + cursor.execute("SELECT id FROM folders WHERE path = ?", (normalized_path,)) + result = cursor.fetchone() + conn.commit() + return result[0] if result else None + except sqlite3.Error as e: + logger.error(f"Ошибка при добавлении папки {normalized_path}: {e}") + return None + + def remove_folder_by_path(self, folder_path: str) -> bool: + normalized_path = _normalize(folder_path) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM folders WHERE path = ?", (normalized_path,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Ошибка при удалении папки {normalized_path}: {e}") + return False + + # ──────────────────────────────── + # Файлы + # ──────────────────────────────── + + def register_file(self, folder_id: int, filename: str, filepath: str, + size: int, mtime: float) -> Optional[int]: + if not filename or not filepath: + return None + normalized_filepath = _normalize(filepath) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO files (folder_id, filename, filepath, size, mtime) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(filepath) DO UPDATE SET + size = excluded.size, + mtime = excluded.mtime, + folder_id = excluded.folder_id + RETURNING id + """, + (folder_id, filename, normalized_filepath, size, mtime) + ) + result = cursor.fetchone() + conn.commit() + return result[0] if result else None + except sqlite3.Error as e: + logger.error(f"Ошибка регистрации файла {normalized_filepath}: {e}") + return None + + def remove_file_by_path(self, filepath: str) -> bool: + normalized_filepath = _normalize(filepath) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("DELETE FROM files WHERE filepath = ?", (normalized_filepath,)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Ошибка удаления файла {normalized_filepath}: {e}") + return False + + # ──────────────────────────────── + # Метаданные + # ──────────────────────────────── + + def save_metadata(self, file_id: int, meta_payload: Dict[str, Any]) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + INSERT INTO metadata ( + file_id, prompt_json, workflow_json, positive_prompt, + negative_prompt, seed, model_name, sampler, steps, cfg + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(file_id) DO UPDATE SET + prompt_json = excluded.prompt_json, + workflow_json = excluded.workflow_json, + positive_prompt = excluded.positive_prompt, + negative_prompt = excluded.negative_prompt, + seed = excluded.seed, + model_name = excluded.model_name, + sampler = excluded.sampler, + steps = excluded.steps, + cfg = excluded.cfg + """, + ( + file_id, + meta_payload.get("prompt_json"), + meta_payload.get("workflow_json"), + meta_payload.get("positive_prompt"), + meta_payload.get("negative_prompt"), + meta_payload.get("seed"), + meta_payload.get("model_name"), + meta_payload.get("sampler"), + meta_payload.get("steps"), + meta_payload.get("cfg") + ) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка сохранения метаданных файла ID {file_id}: {e}") + return False + + # ──────────────────────────────── + # Выборки + # ──────────────────────────────── + + def get_files_in_folder(self, folder_path: str, search_query: str = "", + favorites_only: bool = False, + tag: Optional[str] = None) -> List[Dict[str, Any]]: + """Файлы папки с фильтрами: поиск, избранное, тег.""" + normalized_path = _normalize(folder_path) + sql = """ + SELECT f.id, f.filename, f.filepath, f.rating, f.favorite, + CASE WHEN m.workflow_json IS NOT NULL AND m.workflow_json != '' + THEN 1 ELSE 0 END as has_workflow + FROM files f + JOIN folders fo ON f.folder_id = fo.id + LEFT JOIN metadata m ON f.id = m.file_id + WHERE fo.path = ? + """ + params: List[Any] = [normalized_path] + + if search_query: + like = f"%{search_query}%" + sql += " AND (f.filename LIKE ? OR m.positive_prompt LIKE ? OR m.negative_prompt LIKE ?)" + params.extend([like, like, like]) + + if favorites_only: + sql += " AND f.favorite = 1" + + if tag: + sql += """ AND f.id IN ( + SELECT ft.file_id FROM file_tags ft + JOIN tags t ON ft.tag_id = t.id WHERE t.name = ? + )""" + params.append(tag) + + sql += " ORDER BY f.filename ASC" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(sql, params) + return [dict(row) for row in cursor.fetchall()] + except sqlite3.Error as e: + logger.error(f"Ошибка получения файлов в папке {normalized_path}: {e}") + return [] + + def get_file_details(self, file_id: int) -> Optional[Dict[str, Any]]: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT f.id, f.filename, f.filepath, f.rating, f.favorite, + m.prompt_json, m.workflow_json, m.positive_prompt, m.negative_prompt, + m.seed, m.model_name, m.sampler, m.steps, m.cfg + FROM files f + LEFT JOIN metadata m ON f.id = m.file_id + WHERE f.id = ? + """, + (file_id,) + ) + row = cursor.fetchone() + if not row: + return None + details = dict(row) + details["tags"] = self.get_file_tags(file_id) + return details + except sqlite3.Error as e: + logger.error(f"Ошибка получения деталей файла ID {file_id}: {e}") + return None + + def update_file_details(self, file_id: int, positive: str, negative: str, rating: int) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("UPDATE files SET rating = ? WHERE id = ?", (rating, file_id)) + cursor.execute( + "UPDATE metadata SET positive_prompt = ?, negative_prompt = ? WHERE file_id = ?", + (positive, negative, file_id) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка сохранения правок для файла ID {file_id}: {e}") + return False + + def update_json_field(self, file_id: int, field: str, json_text: Optional[str]) -> bool: + """Обновляет workflow_json или prompt_json файла. + При изменении prompt_json перечитывает производные параметры генерации.""" + if field not in ("workflow_json", "prompt_json"): + logger.error(f"Недопустимое JSON-поле: {field}") + return False + try: + with self._get_connection() as conn: + cursor = conn.cursor() + # Гарантируем существование строки metadata + cursor.execute( + "INSERT OR IGNORE INTO metadata (file_id) VALUES (?)", (file_id,)) + cursor.execute( + f"UPDATE metadata SET {field} = ? WHERE file_id = ?", + (json_text or None, file_id) + ) + if field == "prompt_json": + # Пересчитываем параметры из нового prompt-графа + from comfy_studio.parsers.metadata_parser import MetadataParser + params = MetadataParser.parse_comfy_parameters(json_text) + cursor.execute( + """ + UPDATE metadata SET + positive_prompt = ?, negative_prompt = ?, seed = ?, + model_name = ?, sampler = ?, steps = ?, cfg = ? + WHERE file_id = ? + """, + ( + params["positive_prompt"], params["negative_prompt"], + params["seed"], params["model_name"], params["sampler"], + params["steps"], params["cfg"], file_id + ) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка сохранения {field} для файла ID {file_id}: {e}") + return False + + # ──────────────────────────────── + # Избранное + # ──────────────────────────────── + + def set_favorite(self, file_id: int, favorite: bool) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("UPDATE files SET favorite = ? WHERE id = ?", + (1 if favorite else 0, file_id)) + conn.commit() + return cursor.rowcount > 0 + except sqlite3.Error as e: + logger.error(f"Ошибка изменения избранного для ID {file_id}: {e}") + return False + + # ──────────────────────────────── + # Теги + # ──────────────────────────────── + + def add_tag_to_file(self, file_id: int, tag_name: str) -> bool: + tag_name = tag_name.strip().lower() + if not tag_name: + return False + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("INSERT OR IGNORE INTO tags (name) VALUES (?)", (tag_name,)) + cursor.execute("SELECT id FROM tags WHERE name = ?", (tag_name,)) + tag_id = cursor.fetchone()[0] + cursor.execute( + "INSERT OR IGNORE INTO file_tags (file_id, tag_id) VALUES (?, ?)", + (file_id, tag_id) + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка добавления тега '{tag_name}' к файлу ID {file_id}: {e}") + return False + + def remove_tag_from_file(self, file_id: int, tag_name: str) -> bool: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + DELETE FROM file_tags WHERE file_id = ? + AND tag_id = (SELECT id FROM tags WHERE name = ?) + """, + (file_id, tag_name.strip().lower()) + ) + # Подчищаем теги-сироты + cursor.execute( + "DELETE FROM tags WHERE id NOT IN (SELECT DISTINCT tag_id FROM file_tags)" + ) + conn.commit() + return True + except sqlite3.Error as e: + logger.error(f"Ошибка удаления тега '{tag_name}' у файла ID {file_id}: {e}") + return False + + def get_file_tags(self, file_id: int) -> List[str]: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT t.name FROM tags t + JOIN file_tags ft ON t.id = ft.tag_id + WHERE ft.file_id = ? ORDER BY t.name + """, + (file_id,) + ) + return [row[0] for row in cursor.fetchall()] + except sqlite3.Error as e: + logger.error(f"Ошибка получения тегов файла ID {file_id}: {e}") + return [] + + def get_all_tags(self) -> List[str]: + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute("SELECT name FROM tags ORDER BY name") + return [row[0] for row in cursor.fetchall()] + except sqlite3.Error as e: + logger.error(f"Ошибка получения списка тегов: {e}") + return [] diff --git a/comfy_studio/db/schema.py b/comfy_studio/db/schema.py new file mode 100644 index 0000000..8004226 --- /dev/null +++ b/comfy_studio/db/schema.py @@ -0,0 +1,60 @@ +"""SQL-схема базы данных Comfy-Studio.""" + +PRAGMA_FOREIGN_KEYS = "PRAGMA foreign_keys = ON;" + +CREATE_TABLES_SQL = """ +CREATE TABLE IF NOT EXISTS folders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + path TEXT UNIQUE NOT NULL, + parent_id INTEGER, + last_scanned TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + folder_id INTEGER NOT NULL, + filename TEXT NOT NULL, + filepath TEXT UNIQUE NOT NULL, + size INTEGER NOT NULL, + mtime REAL NOT NULL, + rating INTEGER DEFAULT 0 CHECK (rating >= 0 AND rating <= 5), + favorite INTEGER DEFAULT 0 CHECK (favorite IN (0, 1)), + FOREIGN KEY (folder_id) REFERENCES folders(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS metadata ( + file_id INTEGER PRIMARY KEY, + prompt_json TEXT, + workflow_json TEXT, + positive_prompt TEXT, + negative_prompt TEXT, + seed INTEGER, + model_name TEXT, + sampler TEXT, + steps INTEGER, + cfg REAL, + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL +); + +CREATE TABLE IF NOT EXISTS file_tags ( + file_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (file_id, tag_id), + FOREIGN KEY (file_id) REFERENCES files(id) ON DELETE CASCADE, + FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE +); +""" + +CREATE_INDEXES_SQL = """ +CREATE INDEX IF NOT EXISTS idx_folders_path ON folders(path); +CREATE INDEX IF NOT EXISTS idx_files_filepath ON files(filepath); +CREATE INDEX IF NOT EXISTS idx_files_folder_id ON files(folder_id); +CREATE INDEX IF NOT EXISTS idx_files_favorite ON files(favorite); +CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); +""" diff --git a/comfy_studio/parsers/__init__.py b/comfy_studio/parsers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/parsers/metadata_parser.py b/comfy_studio/parsers/metadata_parser.py new file mode 100644 index 0000000..f177e5b --- /dev/null +++ b/comfy_studio/parsers/metadata_parser.py @@ -0,0 +1,272 @@ +""" +Извлечение и разбор метаданных ComfyUI из изображений (PNG/WebP/JPEG). +""" + +import json +import logging +from pathlib import Path +from typing import Optional, Dict, Any, Tuple + +from PIL import Image + +try: + import piexif + import piexif.helper + HAS_PIEXIF = True +except ImportError: + HAS_PIEXIF = False + +logger = logging.getLogger(__name__) + + +class MetadataParser: + @staticmethod + def extract_raw_metadata(filepath: str) -> Tuple[Optional[str], Optional[str]]: + """Возвращает (prompt_json, workflow_json) из файла изображения.""" + path = Path(filepath) + if not path.exists(): + return None, None + suffix = path.suffix.lower() + if suffix in ('.png', '.webp'): + return MetadataParser._extract_from_png_webp(path) + elif suffix in ('.jpg', '.jpeg'): + return MetadataParser._extract_from_jpeg(path) + return None, None + + @staticmethod + def _extract_from_png_webp(path: Path) -> Tuple[Optional[str], Optional[str]]: + try: + with Image.open(path) as img: + info = img.info + prompt = info.get("prompt") + workflow = info.get("workflow") + if not prompt and "comment" in info: + prompt = info.get("comment") + return prompt, workflow + except Exception as e: + logger.error(f"Ошибка чтения PNG/WebP метаданных {path.name}: {e}") + return None, None + + @staticmethod + def _extract_from_jpeg(path: Path) -> Tuple[Optional[str], Optional[str]]: + if not HAS_PIEXIF: + logger.warning("piexif не установлен — JPEG-метаданные недоступны.") + return None, None + try: + with Image.open(path) as img: + if "exif" not in img.info: + return None, None + exif_dict = piexif.load(img.info["exif"]) + user_comment_bytes = exif_dict.get("Exif", {}).get(piexif.ExifIFD.UserComment, b"") + if not user_comment_bytes: + return None, None + try: + comment_str = piexif.helper.UserComment.load(user_comment_bytes) + except ValueError: + comment_str = user_comment_bytes.decode('utf-8', errors='ignore') + if comment_str.startswith("{"): + try: + data = json.loads(comment_str) + prompt = data.get("prompt") + workflow = data.get("workflow") + if isinstance(prompt, dict): + prompt = json.dumps(prompt) + if isinstance(workflow, dict): + workflow = json.dumps(workflow) + return prompt, workflow + except json.JSONDecodeError: + return comment_str, None + return None, None + except Exception as e: + logger.error(f"Ошибка чтения JPEG EXIF {path.name}: {e}") + return None, None + + @classmethod + def parse_comfy_parameters(cls, prompt_json_str: Optional[str]) -> Dict[str, Any]: + """Разбирает prompt-граф ComfyUI и вытаскивает основные параметры генерации.""" + result = { + "positive_prompt": None, "negative_prompt": None, "seed": None, + "model_name": None, "sampler": None, "steps": None, "cfg": None + } + if not prompt_json_str: + return result + try: + prompt_graph = json.loads(prompt_json_str) + if not isinstance(prompt_graph, dict): + return result + except json.JSONDecodeError: + return result + + sampler_node = None + for node_id, node in prompt_graph.items(): + if not isinstance(node, dict): + continue + class_type = node.get("class_type", "") + if "KSampler" in class_type: + sampler_node = node + break + + if sampler_node: + inputs = sampler_node.get("inputs", {}) + result["seed"] = inputs.get("seed") or inputs.get("noise_seed") + result["steps"] = inputs.get("steps") + result["cfg"] = inputs.get("cfg") + result["sampler"] = inputs.get("sampler_name") + result["positive_prompt"] = cls._trace_conditioning(inputs.get("positive"), prompt_graph) + result["negative_prompt"] = cls._trace_conditioning(inputs.get("negative"), prompt_graph) + result["model_name"] = cls._trace_model(inputs.get("model"), prompt_graph) + else: + # Fallback: собираем все CLIPTextEncode-ноды + positives = [] + for node in prompt_graph.values(): + if isinstance(node, dict) and node.get("class_type") == "CLIPTextEncode": + text = node.get("inputs", {}).get("text", "") + if isinstance(text, str) and text.strip(): + positives.append(text.strip()) + if positives: + result["positive_prompt"] = "\n---\n".join(positives) + + def clean_string(val) -> Optional[str]: + if val is None: + return None + if isinstance(val, list): + if all(isinstance(x, str) for x in val): + return "\n".join(val) + return json.dumps(val) + if isinstance(val, dict): + return json.dumps(val) + return str(val) + + def clean_int(val) -> Optional[int]: + if val is None or isinstance(val, (list, dict)): + return None + try: + return int(val) + except (ValueError, TypeError): + return None + + def clean_float(val) -> Optional[float]: + if val is None or isinstance(val, (list, dict)): + return None + try: + return float(val) + except (ValueError, TypeError): + return None + + result["positive_prompt"] = clean_string(result["positive_prompt"]) + result["negative_prompt"] = clean_string(result["negative_prompt"]) + result["model_name"] = clean_string(result["model_name"]) + result["sampler"] = clean_string(result["sampler"]) + result["seed"] = clean_int(result["seed"]) + result["steps"] = clean_int(result["steps"]) + result["cfg"] = clean_float(result["cfg"]) + return result + + @classmethod + def inject_parameters(cls, prompt_json_str: str, + positive: Optional[str] = None, + negative: Optional[str] = None, + seed: Optional[int] = None) -> str: + """ + Внедряет отредактированные параметры обратно в prompt-граф ComfyUI. + Возвращает обновлённый JSON (или исходный, если граф не разобрать). + + Важно: без изменения графа ComfyUI отдаёт всё из кэша и не генерирует + ("Prompt executed in 0.00 seconds"), поэтому при повторной генерации + нужно передавать новый seed. + """ + try: + graph = json.loads(prompt_json_str) + if not isinstance(graph, dict): + return prompt_json_str + except json.JSONDecodeError: + return prompt_json_str + + sampler_node = None + for node in graph.values(): + if isinstance(node, dict) and "KSampler" in node.get("class_type", ""): + sampler_node = node + break + if not sampler_node: + return prompt_json_str + + inputs = sampler_node.get("inputs", {}) + + if seed is not None: + # Ключ зависит от типа сэмплера: seed или noise_seed + if "noise_seed" in inputs: + inputs["noise_seed"] = seed + else: + inputs["seed"] = seed + + if positive is not None: + text_node_id = cls._trace_text_node_id(inputs.get("positive"), graph) + if text_node_id: + graph[text_node_id]["inputs"]["text"] = positive + + if negative is not None: + text_node_id = cls._trace_text_node_id(inputs.get("negative"), graph) + if text_node_id: + graph[text_node_id]["inputs"]["text"] = negative + + return json.dumps(graph, ensure_ascii=False) + + @classmethod + def _trace_text_node_id(cls, link: Optional[list], graph: dict, + _depth: int = 0) -> Optional[str]: + """Возвращает ID текстовой ноды (CLIPTextEncode), до которой ведёт ссылка.""" + if _depth > 20 or not link or not isinstance(link, list) or len(link) < 1: + return None + node_id = str(link[0]) + node = graph.get(node_id) + if not node: + return None + class_type = node.get("class_type", "") + if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSequence"): + return node_id + if "Conditioning" in class_type: + for val in node.get("inputs", {}).values(): + if isinstance(val, list) and len(val) >= 1: + found = cls._trace_text_node_id(val, graph, _depth + 1) + if found: + return found + return None + + @classmethod + def _trace_conditioning(cls, link: Optional[list], graph: dict, + _depth: int = 0) -> Optional[str]: + """Рекурсивно идёт по ссылкам графа до текстовой ноды промта.""" + if _depth > 20 or not link or not isinstance(link, list) or len(link) < 1: + return None + node = graph.get(str(link[0])) + if not node: + return None + class_type = node.get("class_type", "") + inputs = node.get("inputs", {}) + if class_type in ("CLIPTextEncode", "CLIPTextEncodeSDXL", "CLIPTextEncodeSequence"): + return inputs.get("text") or inputs.get("text_g") + if "Conditioning" in class_type: + for val in inputs.values(): + if isinstance(val, list) and len(val) >= 1: + text = cls._trace_conditioning(val, graph, _depth + 1) + if text: + return text + return None + + @classmethod + def _trace_model(cls, link: Optional[list], graph: dict, _depth: int = 0) -> Optional[str]: + """Рекурсивно идёт по ссылкам до ноды загрузки чекпоинта.""" + if _depth > 20 or not link or not isinstance(link, list) or len(link) < 1: + return None + node = graph.get(str(link[0])) + if not node: + return None + class_type = node.get("class_type", "") + inputs = node.get("inputs", {}) + if "CheckpointLoader" in class_type: + return inputs.get("ckpt_name") + elif "UNETLoader" in class_type: + return inputs.get("unet_name") + elif "LoraLoader" in class_type or "ModelMerge" in class_type: + return cls._trace_model(inputs.get("model"), graph, _depth + 1) + return None diff --git a/comfy_studio/services/__init__.py b/comfy_studio/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/services/comfy_api.py b/comfy_studio/services/comfy_api.py new file mode 100644 index 0000000..801c05e --- /dev/null +++ b/comfy_studio/services/comfy_api.py @@ -0,0 +1,65 @@ +""" +Клиент API ComfyUI + фоновый воркер отправки промтов (не блокирует GUI). +""" + +import json +import logging + +import requests +from PyQt6.QtCore import QThread, pyqtSignal + +from comfy_studio.core.config import COMFYUI_PROMPT_ENDPOINT +from comfy_studio.core.i18n import tr + +logger = logging.getLogger(__name__) + + +class ComfyAPIClient: + def __init__(self, base_url: str): + self.base_url = base_url.rstrip("/") + + +class ComfySendWorker(QThread): + """Фоновая отправка prompt-графа в очередь ComfyUI.""" + finished = pyqtSignal(bool, str) # (success, message) + + def __init__(self, api_client: ComfyAPIClient, prompt_json_str: str): + super().__init__() + self.api_client = api_client + self.prompt_json_str = prompt_json_str + + def run(self): + logger.info("Запущена фоновая отправка промта в ComfyUI.") + try: + prompt_data = json.loads(self.prompt_json_str) + payload = {"prompt": prompt_data} + url = f"{self.api_client.base_url}{COMFYUI_PROMPT_ENDPOINT}" + + response = requests.post(url, json=payload, timeout=5) + + if response.status_code == 200: + res_data = response.json() + prompt_id = res_data.get("prompt_id", "?") + logger.info(f"Задача принята ComfyUI. ID: {prompt_id}") + self.finished.emit( + True, + f"{tr('prompt_queued')}\n\n{tr('task_id')}: {prompt_id}" + ) + else: + logger.error(f"ComfyUI отклонил запрос: {response.status_code} {response.text}") + self.finished.emit( + False, + f"{tr('comfy_server_error', code=response.status_code)}:\n{response.text}" + ) + except json.JSONDecodeError as je: + logger.error(f"Не удалось распарсить prompt_json: {je}") + self.finished.emit(False, f"{tr('metadata_error')}:\n{je}") + except requests.exceptions.RequestException as re: + logger.error(f"Сетевое исключение при отправке: {re}") + self.finished.emit( + False, + f"{tr('comfy_connect_error', url=self.api_client.base_url)}\n\n{re}" + ) + except Exception as e: + logger.error(f"Непредвиденная ошибка воркера: {e}") + self.finished.emit(False, str(e)) diff --git a/comfy_studio/services/translator.py b/comfy_studio/services/translator.py new file mode 100644 index 0000000..3dd11b4 --- /dev/null +++ b/comfy_studio/services/translator.py @@ -0,0 +1,69 @@ +""" +Сервис перевода текста (бесплатный endpoint Google Translate, без API-ключа). +Перевод выполняется в фоновом QThread, чтобы не блокировать GUI. +""" + +import logging +import requests +from PyQt6.QtCore import QThread, pyqtSignal + +logger = logging.getLogger(__name__) + +TRANSLATE_URL = "https://translate.googleapis.com/translate_a/single" + +# Отображаемые названия языков (код -> название) +LANG_NAMES = { + "ru": "Русский", + "en": "English", + "de": "Deutsch", + "fr": "Français", + "es": "Español", + "it": "Italiano", + "pt": "Português", + "pl": "Polski", + "uk": "Українська", + "ja": "日本語", + "zh-CN": "中文", + "ko": "한국어", +} + + +def translate_text(text: str, target_lang: str, source_lang: str = "auto") -> str: + """ + Переводит текст через публичный endpoint Google Translate. + Бросает исключение при сетевой ошибке или неожиданном ответе. + """ + params = { + "client": "gtx", + "sl": source_lang, + "tl": target_lang, + "dt": "t", + "q": text, + } + response = requests.get(TRANSLATE_URL, params=params, timeout=10) + response.raise_for_status() + data = response.json() + # Ответ: [[["перевод1","оригинал1",...],["перевод2",...],...], ...] + segments = data[0] or [] + return "".join(seg[0] for seg in segments if seg and seg[0]) + + +class TranslateWorker(QThread): + """Фоновый перевод текста.""" + finished = pyqtSignal(bool, str) # (success, translated_text_or_error) + + def __init__(self, text: str, target_lang: str, parent=None): + super().__init__(parent) + self.text = text + self.target_lang = target_lang + + def run(self): + try: + translated = translate_text(self.text, self.target_lang) + self.finished.emit(True, translated) + except requests.exceptions.RequestException as e: + logger.error(f"Сетевая ошибка перевода: {e}") + self.finished.emit(False, str(e)) + except Exception as e: + logger.error(f"Ошибка перевода: {e}") + self.finished.emit(False, str(e)) diff --git a/comfy_studio/services/watcher.py b/comfy_studio/services/watcher.py new file mode 100644 index 0000000..a2748ce --- /dev/null +++ b/comfy_studio/services/watcher.py @@ -0,0 +1,146 @@ +""" +Фоновый мониторинг папок генерации (watchdog) с мостом сигналов в GUI-поток PyQt6. +""" + +import os +import time +import logging +from pathlib import Path +from typing import Optional, List + +from PyQt6.QtCore import QObject, pyqtSignal +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +from comfy_studio.core.config import SUPPORTED_EXTENSIONS +from comfy_studio.db.manager import DBManager +from comfy_studio.parsers.metadata_parser import MetadataParser + +logger = logging.getLogger(__name__) + + +class WatcherSignals(QObject): + """Мост между потоками Watchdog и основным GUI-потоком PyQt6.""" + file_added = pyqtSignal(str) # filepath + file_removed = pyqtSignal(str) # filepath + + +class GalleryEventHandler(FileSystemEventHandler): + def __init__(self, db_manager: DBManager, root_path: str, signals: WatcherSignals): + super().__init__() + self.db_manager = db_manager + self.root_path = Path(root_path).resolve() + self.signals = signals + + def on_created(self, event): + if event.is_directory: + self._register_folder_recursive(Path(event.src_path)) + else: + self._handle_file_creation(Path(event.src_path)) + + def on_deleted(self, event): + if event.is_directory: + self.db_manager.remove_folder_by_path(event.src_path) + else: + self.db_manager.remove_file_by_path(event.src_path) + self.signals.file_removed.emit(event.src_path) + + def on_moved(self, event): + src_path = Path(event.src_path) + dest_path = Path(event.dest_path) + if event.is_directory: + self.db_manager.remove_folder_by_path(str(src_path)) + self._register_folder_recursive(dest_path) + else: + self.db_manager.remove_file_by_path(str(src_path)) + self._handle_file_creation(dest_path) + + def _handle_file_creation(self, path: Path): + if path.suffix.lower() not in SUPPORTED_EXTENSIONS: + return + + # Пауза, гарантирующая полное высвобождение файла при записи из ComfyUI + time.sleep(0.3) + try: + stat = path.stat() + folder_id = self._get_or_create_folder_id(path.parent.resolve()) + if folder_id is None: + return + file_id = self.db_manager.register_file( + folder_id=folder_id, filename=path.name, + filepath=str(path.as_posix()), size=stat.st_size, mtime=stat.st_mtime + ) + if file_id: + prompt_raw, workflow_raw = MetadataParser.extract_raw_metadata(str(path)) + parsed_params = MetadataParser.parse_comfy_parameters(prompt_raw) + meta_payload = { + "prompt_json": prompt_raw, "workflow_json": workflow_raw, + **parsed_params + } + self.db_manager.save_metadata(file_id, meta_payload) + self.signals.file_added.emit(str(path.as_posix())) + except FileNotFoundError: + pass + except Exception as e: + logger.error(f"Не удалось обработать файл {path}: {e}") + + def _get_or_create_folder_id(self, folder_path: Path) -> Optional[int]: + normalized_path = str(folder_path.resolve().as_posix()) + root_str = str(self.root_path.as_posix()) + if not normalized_path.startswith(root_str): + return None + parent_path = folder_path.parent + parent_id = None + if parent_path != folder_path and normalized_path != root_str: + parent_id = self._get_or_create_folder_id(parent_path) + return self.db_manager.add_folder(normalized_path, parent_id) + + def _register_folder_recursive(self, folder_path: Path): + self._get_or_create_folder_id(folder_path) + try: + for entry in os.scandir(folder_path): + entry_path = Path(entry.path) + if entry.is_dir(): + self._register_folder_recursive(entry_path) + elif entry.is_file(): + self._handle_file_creation(entry_path) + except Exception as e: + logger.error(f"Ошибка сканирования папки {folder_path}: {e}") + + +class FolderWatcher: + """Управляет мониторингом списка директорий (multi-folder tracking).""" + + def __init__(self, db_manager: DBManager): + self.db_manager = db_manager + self.signals = WatcherSignals() + self.observer: Optional[Observer] = None + self.handlers: List[GalleryEventHandler] = [] + + def start_monitoring(self, root_paths: List[str]): + if self.observer and self.observer.is_alive(): + self.stop_monitoring() + + self.observer = Observer() + self.handlers = [] + + for p in root_paths: + path = Path(p).resolve() + path.mkdir(parents=True, exist_ok=True) + + handler = GalleryEventHandler(self.db_manager, str(path), self.signals) + logger.info(f"Первоначальная индексация: {path}") + handler._register_folder_recursive(path) + + self.observer.schedule(handler, str(path), recursive=True) + self.handlers.append(handler) + logger.info(f"Отслеживание изменений: {path}") + + self.observer.start() + + def stop_monitoring(self): + if self.observer: + self.observer.stop() + self.observer.join() + self.observer = None + self.handlers = [] diff --git a/comfy_studio/ui/__init__.py b/comfy_studio/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/ui/main_window.py b/comfy_studio/ui/main_window.py new file mode 100644 index 0000000..9e3dbce --- /dev/null +++ b/comfy_studio/ui/main_window.py @@ -0,0 +1,377 @@ +""" +Главное окно Comfy-Studio: трёхпанельный интерфейс, +контекстное меню, полноэкранный просмотр, интеграция с ComfyUI. +""" + +import os +import sys +import logging +import subprocess +from pathlib import Path + +from PyQt6.QtWidgets import ( + QMainWindow, QSplitter, QWidget, QHBoxLayout, QVBoxLayout, + QStatusBar, QMessageBox, QApplication, QMenu, QInputDialog, + QLineEdit, QPushButton +) +from PyQt6.QtCore import Qt, QModelIndex, QMimeData, QUrl + +from comfy_studio.core.config import DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT, APP_NAME +from comfy_studio.core.i18n import tr +from comfy_studio.core.settings import load_settings, save_settings +from comfy_studio.db.manager import DBManager +from comfy_studio.services.watcher import FolderWatcher +from comfy_studio.services.comfy_api import ComfyAPIClient, ComfySendWorker +from comfy_studio.ui.theme import DARK_THEME_QSS +from comfy_studio.ui.widgets.left_panel import LeftPanel +from comfy_studio.ui.widgets.center_grid import CenterGrid, FilePathRole, FileIdRole +from comfy_studio.ui.widgets.right_panel import RightPanel +from comfy_studio.ui.widgets.settings_dialog import SettingsDialog +from comfy_studio.ui.widgets.viewer import FullscreenViewer +from comfy_studio.ui.widgets.translatable_text_edit import set_translate_languages + +logger = logging.getLogger(__name__) + + +class MainWindow(QMainWindow): + def __init__(self, db_manager: DBManager, watcher: FolderWatcher): + super().__init__() + self.db_manager = db_manager + self.watcher = watcher + + self.settings = load_settings() + self.current_root_path = None + self.api_client = ComfyAPIClient(self.settings["comfyui_url"]) + set_translate_languages(self.settings.get("translate_languages", ["ru", "en"])) + self.viewer = None + self.comfy_sender = None + + self.setWindowTitle(APP_NAME) + self.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) + self.setStyleSheet(DARK_THEME_QSS) + + self.init_ui() + self.bind_events() + + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + self.left_panel.update_tags(self.db_manager.get_all_tags()) + logger.info("Главное окно инициализировано.") + + def init_ui(self): + central_widget = QWidget() + self.setCentralWidget(central_widget) + + main_layout = QVBoxLayout(central_widget) + main_layout.setContentsMargins(8, 8, 8, 8) + + top_bar = QHBoxLayout() + self.settings_btn = QPushButton(tr("settings_btn")) + top_bar.addWidget(self.settings_btn) + top_bar.addStretch() + main_layout.addLayout(top_bar) + + self.splitter = QSplitter(Qt.Orientation.Horizontal) + self.left_panel = LeftPanel() + self.center_grid = CenterGrid() + self.right_panel = RightPanel() + + self.splitter.addWidget(self.left_panel) + self.splitter.addWidget(self.center_grid) + self.splitter.addWidget(self.right_panel) + self.splitter.setSizes([240, 760, 380]) + main_layout.addWidget(self.splitter) + + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage(tr("app_ready")) + + def bind_events(self): + # Левая панель + self.left_panel.tree_view.clicked.connect(self._on_subfolder_selected) + self.left_panel.root_folder_changed.connect(self.load_folder_images) + self.left_panel.search_changed.connect(self._on_search) + self.left_panel.filters_changed.connect(self._reload_current) + + # Центральный грид + self.center_grid.list_view.selectionModel().selectionChanged.connect( + self._on_image_selected) + self.center_grid.list_view.copy_pressed.connect(self._on_copy_file) + self.center_grid.list_view.delete_pressed.connect(self._on_delete_files) + self.center_grid.list_view.fullscreen_requested.connect(self._open_fullscreen) + self.center_grid.list_view.customContextMenuRequested.connect(self._on_context_menu) + + # Правая панель + self.right_panel.save_clicked.connect(self._on_save_metadata) + self.right_panel.send_to_comfy_clicked.connect(self._on_send_to_comfy) + self.right_panel.favorite_toggled.connect(self._on_favorite_toggled) + self.right_panel.tag_added.connect(self._on_tag_added) + self.right_panel.tag_removed.connect(self._on_tag_removed) + self.right_panel.json_saved.connect(self._on_json_saved) + + self.settings_btn.clicked.connect(self._open_settings) + + # Реактивное обновление от watcher + self.watcher.signals.file_added.connect(self._on_file_changed_externally) + self.watcher.signals.file_removed.connect(self._on_file_changed_externally) + + # ──────────────────────────────── + # Загрузка изображений + # ──────────────────────────────── + + def load_folder_images(self, folder_path: str, search_query: str = ""): + self.current_root_path = folder_path + self.center_grid.path_label.setText(f"{tr('folder_label')}: {folder_path}") + + filters = self.left_panel.get_filters() + files = self.db_manager.get_files_in_folder( + folder_path, search_query, + favorites_only=filters["favorites_only"], + tag=filters["tag"], + ) + self.center_grid.model.set_files(files) + self.status_bar.showMessage(f"{tr('files_shown')}: {len(files)}") + + def _reload_current(self): + if self.current_root_path: + self.load_folder_images(self.current_root_path, + self.left_panel.search_input.text()) + + def _on_subfolder_selected(self, index: QModelIndex): + folder_path = self.left_panel.folder_model.filePath(index) + self.load_folder_images(folder_path) + + def _on_search(self, query: str): + if self.current_root_path: + self.load_folder_images(self.current_root_path, query) + + def _on_image_selected(self): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: + self.right_panel.clear_fields() + return + file_id = self.center_grid.model.data(indexes[0], FileIdRole) + if file_id: + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + + def _on_file_changed_externally(self, filepath: str): + if not self.current_root_path: + return + file_dir = str(Path(filepath).parent.resolve().as_posix()) + current_dir = str(Path(self.current_root_path).resolve().as_posix()) + if file_dir == current_dir: + self._reload_current() + + # ──────────────────────────────── + # Полноэкранный просмотр + # ──────────────────────────────── + + def _open_fullscreen(self, row: int): + paths = [f["filepath"] for f in self.center_grid.model.files] + if not paths: + return + self.viewer = FullscreenViewer(paths, row) + self.viewer.open_fullscreen() + + # ──────────────────────────────── + # Контекстное меню и CRUD + # ──────────────────────────────── + + def _on_context_menu(self, position): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: + return + + menu = QMenu(self) + + fullscreen_action = menu.addAction(tr("open_fullscreen")) + fullscreen_action.setEnabled(len(indexes) == 1) + + favorite_action = menu.addAction(tr("toggle_favorite")) + + show_action = menu.addAction(tr("show_on_disk")) + show_action.setEnabled(len(indexes) == 1) + + menu.addSeparator() + + rename_action = menu.addAction(tr("rename")) + rename_action.setEnabled(len(indexes) == 1) + + delete_action = menu.addAction(tr("delete_selected")) + + action = menu.exec(self.center_grid.list_view.mapToGlobal(position)) + + if action == fullscreen_action: + self._open_fullscreen(indexes[0].row()) + + elif action == favorite_action: + for idx in indexes: + file_id = self.center_grid.model.data(idx, FileIdRole) + details = self.db_manager.get_file_details(file_id) + if details: + self.db_manager.set_favorite(file_id, not details["favorite"]) + self._reload_current() + self._on_image_selected() + + elif action == show_action: + filepath = self.center_grid.model.data(indexes[0], FilePathRole) + self._show_in_explorer(filepath) + + elif action == rename_action: + idx = indexes[0] + filepath = self.center_grid.model.data(idx, FilePathRole) + filename = self.center_grid.model.data(idx, Qt.ItemDataRole.DisplayRole) + new_name, ok = QInputDialog.getText( + self, tr("rename_title"), f"{tr('rename_prompt')} {filename}:", + QLineEdit.EchoMode.Normal, filename + ) + if ok and new_name.strip() and new_name != filename: + self._rename_file(filepath, new_name.strip()) + + elif action == delete_action: + paths = [self.center_grid.model.data(idx, FilePathRole) for idx in indexes] + self._on_delete_files(paths) + + def _show_in_explorer(self, filepath: str): + """Открывает файловый менеджер ОС с выделенным файлом.""" + norm_path = os.path.normpath(filepath) + if not os.path.exists(norm_path): + return + try: + if sys.platform == "win32": + subprocess.run(f'explorer /select,"{norm_path}"', shell=True) + elif sys.platform == "darwin": + subprocess.run(["open", "-R", norm_path]) + else: + subprocess.run(["xdg-open", os.path.dirname(norm_path)]) + except Exception as e: + logger.error(f"Не удалось открыть файловый менеджер: {e}") + + def _rename_file(self, old_filepath: str, new_filename: str): + old_path = Path(old_filepath) + new_filepath = old_path.parent / new_filename + if not new_filepath.suffix: + new_filepath = new_filepath.with_suffix(old_path.suffix) + try: + os.rename(str(old_path), str(new_filepath)) + self.status_bar.showMessage(f"{tr('renamed_ok')}: {new_filepath.name}") + self._reload_current() + except Exception as e: + logger.error(f"Ошибка переименования {old_path.name}: {e}") + QMessageBox.critical(self, tr("error"), f"{tr('rename_failed')}: {e}") + + def _on_copy_file(self, filepath: str): + if os.path.exists(filepath): + mime_data = QMimeData() + mime_data.setUrls([QUrl.fromLocalFile(filepath)]) + QApplication.clipboard().setMimeData(mime_data) + self.status_bar.showMessage(tr("copied_to_clipboard")) + + def _on_delete_files(self, filepaths: list): + if not filepaths: + return + confirm = QMessageBox.question( + self, tr("delete_confirm_title"), + tr("delete_confirm_text", n=len(filepaths)), + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No + ) + if confirm == QMessageBox.StandardButton.Yes: + deleted_count = 0 + for path in filepaths: + try: + if os.path.exists(path): + os.remove(path) + deleted_count += 1 + except Exception as e: + logger.error(f"Ошибка удаления файла {path}: {e}") + self.status_bar.showMessage(f"{tr('deleted_from_disk')}: {deleted_count}") + self._reload_current() + + # ──────────────────────────────── + # Метаданные / избранное / теги + # ──────────────────────────────── + + def _on_save_metadata(self, file_id: int, payload: dict): + success = self.db_manager.update_file_details( + file_id, payload["positive_prompt"], payload["negative_prompt"], payload["rating"] + ) + if success: + self.status_bar.showMessage(tr("params_updated")) + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + self._reload_current() + else: + QMessageBox.critical(self, tr("error"), tr("db_save_error")) + + def _on_json_saved(self, file_id: int, field: str, json_text: str): + """Сохранение отредактированного workflow/prompt JSON в БД.""" + success = self.db_manager.update_json_field(file_id, field, json_text or None) + if success: + self.status_bar.showMessage(tr("json_saved")) + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + self._reload_current() + else: + QMessageBox.critical(self, tr("error"), tr("db_save_error")) + + def _on_favorite_toggled(self, file_id: int, favorite: bool): + self.db_manager.set_favorite(file_id, favorite) + self._reload_current() + + def _on_tag_added(self, file_id: int, tag: str): + self.db_manager.add_tag_to_file(file_id, tag) + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + self.left_panel.update_tags(self.db_manager.get_all_tags()) + + def _on_tag_removed(self, file_id: int, tag: str): + self.db_manager.remove_tag_from_file(file_id, tag) + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + self.left_panel.update_tags(self.db_manager.get_all_tags()) + self._reload_current() + + # ──────────────────────────────── + # Настройки + # ──────────────────────────────── + + def _open_settings(self): + dialog = SettingsDialog(self.settings, self) + if dialog.exec() == SettingsDialog.DialogCode.Accepted: + save_settings(self.settings) + + self.api_client = ComfyAPIClient(self.settings["comfyui_url"]) + set_translate_languages(self.settings.get("translate_languages", ["ru", "en"])) + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + + self.status_bar.showMessage(tr("watcher_restarting")) + self.watcher.stop_monitoring() + self.watcher.start_monitoring(self.settings["tracked_paths"]) + + if self.settings["tracked_paths"]: + first_path = self.settings["tracked_paths"][0] + self.left_panel.set_root_path(first_path) + self.load_folder_images(first_path) + + self.status_bar.showMessage(tr("settings_applied")) + + # ──────────────────────────────── + # ComfyUI + # ──────────────────────────────── + + def _on_send_to_comfy(self, prompt_json: str): + self.status_bar.showMessage(tr("sending_prompt")) + self.right_panel.send_btn.setEnabled(False) + + self.comfy_sender = ComfySendWorker(self.api_client, prompt_json) + self.comfy_sender.finished.connect(self._on_send_finished) + self.comfy_sender.start() + + def _on_send_finished(self, success: bool, message: str): + self.right_panel.send_btn.setEnabled(True) + if success: + self.status_bar.showMessage(tr("prompt_queued")) + QMessageBox.information(self, tr("success"), message) + else: + self.status_bar.showMessage(tr("prompt_send_failed")) + QMessageBox.critical(self, tr("network_error_title"), message) diff --git a/comfy_studio/ui/theme.py b/comfy_studio/ui/theme.py new file mode 100644 index 0000000..18774c1 --- /dev/null +++ b/comfy_studio/ui/theme.py @@ -0,0 +1,136 @@ +"""Тёмная тема оформления (QSS).""" + +DARK_THEME_QSS = """ +QMainWindow { + background-color: #121212; +} +QWidget { + background-color: #121212; + color: #e0e0e0; + font-family: 'Segoe UI', Arial, sans-serif; + font-size: 13px; +} +QSplitter::handle { + background-color: #252525; +} +QSplitter::handle:horizontal { + width: 6px; +} +QTreeView { + background-color: #1e1e1e; + border: 1px solid #2d2d2d; + border-radius: 4px; +} +QTreeView::item:hover { + background-color: #2a2a2a; +} +QTreeView::item:selected { + background-color: #0d47a1; + color: #ffffff; +} +QListWidget { + background-color: #1e1e1e; + border: 1px solid #2d2d2d; + border-radius: 4px; +} +QLineEdit { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 6px; + color: #ffffff; +} +QLineEdit:focus { + border: 1px solid #1976d2; +} +QTabWidget::pane { + border: 1px solid #2d2d2d; + background-color: #1a1a1a; + border-radius: 4px; +} +QTabBar::tab { + background-color: #151515; + color: #888888; + padding: 8px 16px; + border: 1px solid #2d2d2d; + border-bottom: none; + margin-right: 2px; +} +QTabBar::tab:selected { + background-color: #1a1a1a; + color: #ffffff; + border-bottom: 2px solid #1976d2; +} +QTextEdit { + background-color: #181818; + border: 1px solid #2d2d2d; + border-radius: 4px; + color: #e0e0e0; + font-family: 'Consolas', monospace; +} +QPushButton { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 8px 14px; + color: #ffffff; + font-weight: 500; +} +QPushButton:hover { + background-color: #2d2d2d; + border-color: #555555; +} +QPushButton:pressed { + background-color: #1976d2; +} +QPushButton:checked { + background-color: #0d47a1; + border-color: #1976d2; +} +QComboBox { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #ffffff; +} +QComboBox QAbstractItemView { + background-color: #242424; + color: #ffffff; + selection-background-color: #0d47a1; +} +QCheckBox { + spacing: 6px; +} +QCheckBox::indicator { + width: 16px; + height: 16px; + border: 1px solid #3d3d3d; + border-radius: 3px; + background-color: #242424; +} +QCheckBox::indicator:checked { + background-color: #1976d2; + border-color: #1976d2; +} +QScrollBar:vertical { + border: none; + background-color: #121212; + width: 8px; +} +QScrollBar::handle:vertical { + background-color: #3a3a3a; + border-radius: 4px; +} +QStatusBar { + background-color: #181818; + color: #9e9e9e; +} +QMenu { + background-color: #1e1e1e; + border: 1px solid #2d2d2d; +} +QMenu::item:selected { + background-color: #0d47a1; +} +""" diff --git a/comfy_studio/ui/widgets/__init__.py b/comfy_studio/ui/widgets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/comfy_studio/ui/widgets/center_grid.py b/comfy_studio/ui/widgets/center_grid.py new file mode 100644 index 0000000..0ce9ecb --- /dev/null +++ b/comfy_studio/ui/widgets/center_grid.py @@ -0,0 +1,312 @@ +""" +Центральная панель: грид миниатюр с асинхронной загрузкой, +drag-and-drop наружу, зум, значки workflow/избранного. +""" + +import os +import logging +from typing import List, Dict, Any + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSlider, + QListView, QStyledItemDelegate, QStyle +) +from PyQt6.QtGui import QPainter, QColor, QPen, QBrush, QImage, QPixmap, QFontMetrics, QDrag +from PyQt6.QtCore import ( + Qt, QAbstractListModel, QModelIndex, QSize, QRect, + QRunnable, QObject, pyqtSignal, QThreadPool, QMimeData, QUrl +) + +from comfy_studio.core.config import MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE, DEFAULT_THUMBNAIL_SIZE +from comfy_studio.core.i18n import tr + +logger = logging.getLogger(__name__) + +FileIdRole = Qt.ItemDataRole.UserRole + 1 +FilePathRole = Qt.ItemDataRole.UserRole + 2 +HasWorkflowRole = Qt.ItemDataRole.UserRole + 3 +RatingRole = Qt.ItemDataRole.UserRole + 4 +PixmapRole = Qt.ItemDataRole.UserRole + 5 +FavoriteRole = Qt.ItemDataRole.UserRole + 6 + + +class ThumbnailSignals(QObject): + loaded = pyqtSignal(str, QImage) + + +class ThumbnailRunnable(QRunnable): + def __init__(self, filepath: str, target_size: int, signals: ThumbnailSignals): + super().__init__() + self.filepath = filepath + self.target_size = target_size + self.signals = signals + + def run(self): + try: + image = QImage(self.filepath) + if not image.isNull(): + scaled = image.scaled( + self.target_size, self.target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.signals.loaded.emit(self.filepath, scaled) + except Exception as e: + logger.debug(f"Ошибка загрузки миниатюры {self.filepath}: {e}") + + +class ImageModel(QAbstractListModel): + def __init__(self, parent=None): + super().__init__(parent) + self.files: List[Dict[str, Any]] = [] + self.pixmap_cache: Dict[str, QPixmap] = {} + self.loading_paths = set() + self.thread_pool = QThreadPool.globalInstance() + self.signals = ThumbnailSignals() + self.signals.loaded.connect(self._on_thumbnail_loaded) + self.thumbnail_size = DEFAULT_THUMBNAIL_SIZE + + def set_files(self, files: List[Dict[str, Any]]): + self.beginResetModel() + self.files = files + self.pixmap_cache.clear() + self.loading_paths.clear() + self.endResetModel() + + def set_thumbnail_size(self, size: int): + self.beginResetModel() + self.thumbnail_size = size + self.pixmap_cache.clear() + self.loading_paths.clear() + self.endResetModel() + + def rowCount(self, parent=QModelIndex()) -> int: + return len(self.files) + + def supportedDragActions(self) -> Qt.DropAction: + return Qt.DropAction.CopyAction + + def mimeTypes(self) -> List[str]: + return ["text/uri-list"] + + def mimeData(self, indexes: List[QModelIndex]) -> QMimeData: + """Преобразует выделенные файлы в UriList для ОС и браузеров.""" + mime_data = QMimeData() + urls = [] + for index in indexes: + if index.isValid(): + filepath = self.data(index, FilePathRole) + if filepath and os.path.exists(filepath): + urls.append(QUrl.fromLocalFile(filepath)) + mime_data.setUrls(urls) + return mime_data + + def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> Any: + if not index.isValid() or index.row() >= len(self.files): + return None + file_data = self.files[index.row()] + filepath = file_data["filepath"] + + if role == Qt.ItemDataRole.DisplayRole: + return file_data["filename"] + elif role == FileIdRole: + return file_data["id"] + elif role == FilePathRole: + return filepath + elif role == HasWorkflowRole: + return bool(file_data.get("has_workflow")) + elif role == RatingRole: + return file_data.get("rating", 0) + elif role == FavoriteRole: + return bool(file_data.get("favorite")) + elif role == PixmapRole: + if filepath in self.pixmap_cache: + return self.pixmap_cache[filepath] + if filepath not in self.loading_paths: + self.loading_paths.add(filepath) + runnable = ThumbnailRunnable(filepath, self.thumbnail_size, self.signals) + self.thread_pool.start(runnable) + return None + return None + + def _on_thumbnail_loaded(self, filepath: str, qimage: QImage): + self.loading_paths.discard(filepath) + pixmap = QPixmap.fromImage(qimage) + self.pixmap_cache[filepath] = pixmap + for row, f in enumerate(self.files): + if f["filepath"] == filepath: + idx = self.index(row) + self.dataChanged.emit(idx, idx, [PixmapRole]) + break + + +class ImageDelegate(QStyledItemDelegate): + def __init__(self, model: ImageModel, parent=None): + super().__init__(parent) + self.model = model + + def paint(self, painter: QPainter, option, index: QModelIndex): + painter.save() + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = option.rect + selected = option.state & QStyle.StateFlag.State_Selected + hovered = option.state & QStyle.StateFlag.State_MouseOver + + bg_color = QColor("#1c1c1c") if hovered else QColor("#141414") + if selected: + bg_color = QColor("#0d47a1") + painter.setBrush(QBrush(bg_color)) + painter.setPen(QPen(QColor("#1976d2") if selected else QColor("#2d2d2d"), 1.5)) + painter.drawRoundedRect(rect.adjusted(2, 2, -2, -2), 6, 6) + + cell_size = self.model.thumbnail_size + img_rect = QRect(rect.left() + 8, rect.top() + 8, cell_size - 16, cell_size - 16) + + pixmap = index.data(PixmapRole) + if pixmap and not pixmap.isNull(): + w, h = pixmap.width(), pixmap.height() + target_w, target_h = img_rect.width(), img_rect.height() + if w > 0 and h > 0: + ratio = min(target_w / w, target_h / h) + final_w, final_h = int(w * ratio), int(h * ratio) + x_offset = img_rect.left() + (target_w - final_w) // 2 + y_offset = img_rect.top() + (target_h - final_h) // 2 + painter.drawPixmap(x_offset, y_offset, final_w, final_h, pixmap) + else: + painter.setBrush(QBrush(QColor("#0a0a0a"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(img_rect, 4, 4) + + # Имя файла + text = index.data(Qt.ItemDataRole.DisplayRole) + if text: + font = painter.font() + font.setPointSize(8) + painter.setFont(font) + fm = QFontMetrics(font) + text_rect = QRect(rect.left() + 4, rect.bottom() - 20, rect.width() - 8, 16) + elided_text = fm.elidedText(text, Qt.TextElideMode.ElideMiddle, text_rect.width()) + painter.setPen(QPen(QColor("#ffffff") if selected else QColor("#8e8e8e"))) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, elided_text) + + # Значок наличия workflow (зелёный кружок "W") + if index.data(HasWorkflowRole): + badge_rect = QRect(rect.right() - 22, rect.top() + 8, 14, 14) + painter.setBrush(QBrush(QColor("#2ea44f"))) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawEllipse(badge_rect) + painter.setPen(QPen(QColor("#ffffff"))) + font = painter.font() + font.setBold(True) + font.setPointSize(7) + painter.setFont(font) + painter.drawText(badge_rect.adjusted(0, -1, 0, 0), Qt.AlignmentFlag.AlignCenter, "W") + + # Значок избранного (жёлтая звезда) + if index.data(FavoriteRole): + star_rect = QRect(rect.left() + 8, rect.top() + 6, 16, 16) + painter.setPen(QPen(QColor("#ffc107"))) + font = painter.font() + font.setPointSize(11) + painter.setFont(font) + painter.drawText(star_rect, Qt.AlignmentFlag.AlignCenter, "★") + + painter.restore() + + def sizeHint(self, option, index) -> QSize: + size = self.model.thumbnail_size + return QSize(size, size + 22) + + +class GalleryListView(QListView): + delete_pressed = pyqtSignal(list) + copy_pressed = pyqtSignal(str) + fullscreen_requested = pyqtSignal(int) # row + + def keyPressEvent(self, event): + if event.modifiers() == Qt.KeyboardModifier.ControlModifier and event.key() == Qt.Key.Key_C: + indexes = self.selectedIndexes() + if indexes: + filepath = self.model().data(indexes[0], FilePathRole) + if filepath: + self.copy_pressed.emit(filepath) + event.accept() + return + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace): + indexes = self.selectedIndexes() + if indexes: + paths = [self.model().data(idx, FilePathRole) for idx in indexes if idx.isValid()] + self.delete_pressed.emit(paths) + event.accept() + return + if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): + indexes = self.selectedIndexes() + if indexes: + self.fullscreen_requested.emit(indexes[0].row()) + event.accept() + return + super().keyPressEvent(event) + + def mouseDoubleClickEvent(self, event): + index = self.indexAt(event.pos()) + if index.isValid(): + self.fullscreen_requested.emit(index.row()) + super().mouseDoubleClickEvent(event) + + def startDrag(self, supportedActions): + """Системный Drag-and-Drop файлов во внешние окна.""" + indexes = self.selectedIndexes() + if not indexes: + return + drag = QDrag(self) + drag.setMimeData(self.model().mimeData(indexes)) + pixmap = self.model().data(indexes[0], PixmapRole) + if pixmap and not pixmap.isNull(): + drag.setPixmap(pixmap.scaled(70, 70, Qt.AspectRatioMode.KeepAspectRatio)) + drag.exec(Qt.DropAction.CopyAction) + + +class CenterGrid(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + control_layout = QHBoxLayout() + self.path_label = QLabel(tr("select_folder")) + control_layout.addWidget(self.path_label) + control_layout.addStretch() + + control_layout.addWidget(QLabel(tr("zoom"))) + self.zoom_slider = QSlider(Qt.Orientation.Horizontal) + self.zoom_slider.setRange(MIN_THUMBNAIL_SIZE, MAX_THUMBNAIL_SIZE) + self.zoom_slider.setValue(DEFAULT_THUMBNAIL_SIZE) + self.zoom_slider.setFixedWidth(150) + control_layout.addWidget(self.zoom_slider) + layout.addLayout(control_layout) + + self.list_view = GalleryListView() + self.list_view.setViewMode(QListView.ViewMode.IconMode) + self.list_view.setResizeMode(QListView.ResizeMode.Adjust) + self.list_view.setSelectionMode(QListView.SelectionMode.ExtendedSelection) + self.list_view.setDragEnabled(True) + self.list_view.setSpacing(10) + self.list_view.setUniformItemSizes(True) + self.list_view.setStyleSheet( + "QListView { border: 1px solid #2d2d2d; background-color: #121212; }") + + self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + + self.model = ImageModel() + self.delegate = ImageDelegate(self.model) + self.list_view.setModel(self.model) + self.list_view.setItemDelegate(self.delegate) + layout.addWidget(self.list_view) + + self.zoom_slider.valueChanged.connect(self._on_zoom_changed) + + def _on_zoom_changed(self, value: int): + self.model.set_thumbnail_size(value) diff --git a/comfy_studio/ui/widgets/left_panel.py b/comfy_studio/ui/widgets/left_panel.py new file mode 100644 index 0000000..ff47a16 --- /dev/null +++ b/comfy_studio/ui/widgets/left_panel.py @@ -0,0 +1,99 @@ +""" +Левая панель: поиск, выбор корневой папки, дерево подпапок, +фильтр избранного и фильтр по тегу. +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QLabel, QLineEdit, QTreeView, QComboBox, QCheckBox +) +from PyQt6.QtGui import QFileSystemModel +from PyQt6.QtCore import QDir, pyqtSignal + +from comfy_studio.core.i18n import tr +from comfy_studio.ui.widgets.translatable_text_edit import TranslatableLineEdit + + +class LeftPanel(QWidget): + search_changed = pyqtSignal(str) + root_folder_changed = pyqtSignal(str) + filters_changed = pyqtSignal() # избранное / тег + + def __init__(self, parent=None): + super().__init__(parent) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + self.search_input = TranslatableLineEdit() + self.search_input.setPlaceholderText(tr("search_placeholder")) + self.search_input.textChanged.connect(self.search_changed.emit) + layout.addWidget(self.search_input) + + # Фильтр избранного + self.favorites_check = QCheckBox(tr("filter_favorites")) + self.favorites_check.stateChanged.connect(lambda _: self.filters_changed.emit()) + layout.addWidget(self.favorites_check) + + # Фильтр по тегу + layout.addWidget(QLabel(tr("filter_tag"))) + self.tag_combo = QComboBox() + self.tag_combo.addItem(tr("all_tags"), None) + self.tag_combo.currentIndexChanged.connect(lambda _: self.filters_changed.emit()) + layout.addWidget(self.tag_combo) + + # Выбор активной корневой директории + layout.addWidget(QLabel(tr("active_folder"))) + self.folder_selector = QComboBox() + self.folder_selector.currentTextChanged.connect(self._on_folder_changed) + layout.addWidget(self.folder_selector) + + layout.addWidget(QLabel(tr("subfolder_tree"))) + + self.folder_model = QFileSystemModel() + self.folder_model.setFilter(QDir.Filter.Dirs | QDir.Filter.NoDotAndDotDot) + + self.tree_view = QTreeView() + self.tree_view.setModel(self.folder_model) + self.tree_view.setHeaderHidden(True) + + for i in range(1, self.folder_model.columnCount()): + self.tree_view.setColumnHidden(i, True) + + layout.addWidget(self.tree_view) + + # ── Публичные методы ── + + def set_tracked_folders(self, folders: list): + self.folder_selector.blockSignals(True) + self.folder_selector.clear() + self.folder_selector.addItems(folders) + self.folder_selector.blockSignals(False) + + def set_root_path(self, path: str): + self.folder_model.setRootPath(path) + self.tree_view.setRootIndex(self.folder_model.index(path)) + + def update_tags(self, tags: list): + """Обновляет список тегов, сохраняя текущий выбор если возможно.""" + current = self.tag_combo.currentData() + self.tag_combo.blockSignals(True) + self.tag_combo.clear() + self.tag_combo.addItem(tr("all_tags"), None) + for t in tags: + self.tag_combo.addItem(t, t) + if current in tags: + self.tag_combo.setCurrentIndex(tags.index(current) + 1) + self.tag_combo.blockSignals(False) + + def get_filters(self) -> dict: + return { + "favorites_only": self.favorites_check.isChecked(), + "tag": self.tag_combo.currentData(), + } + + def _on_folder_changed(self, path: str): + if path: + self.set_root_path(path) + self.root_folder_changed.emit(path) diff --git a/comfy_studio/ui/widgets/right_panel.py b/comfy_studio/ui/widgets/right_panel.py new file mode 100644 index 0000000..6cb1a00 --- /dev/null +++ b/comfy_studio/ui/widgets/right_panel.py @@ -0,0 +1,389 @@ +""" +Правая панель: превью, параметры генерации, промты, редакторы +workflow/prompt JSON, рейтинг, избранное и теги. +""" + +import json +import random +from typing import Optional + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget, + QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox, + QMessageBox, QApplication, QCheckBox +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, pyqtSignal + +from comfy_studio.core.i18n import tr +from comfy_studio.parsers.metadata_parser import MetadataParser +from comfy_studio.ui.widgets.translatable_text_edit import ( + TranslatableTextEdit, TranslatableLineEdit, +) + + +class TagChip(QWidget): + """Тег с кнопкой удаления.""" + removed = pyqtSignal(str) + + def __init__(self, tag_name: str, parent=None): + super().__init__(parent) + self.tag_name = tag_name + layout = QHBoxLayout(self) + layout.setContentsMargins(6, 2, 2, 2) + layout.setSpacing(4) + + label = QLabel(tag_name) + label.setStyleSheet("background: transparent; color: #cde5ff;") + layout.addWidget(label) + + btn = QPushButton("×") + btn.setFixedSize(16, 16) + btn.setStyleSheet( + "QPushButton { background: transparent; border: none; color: #90a4ae;" + " font-weight: bold; padding: 0; }" + "QPushButton:hover { color: #ef5350; }" + ) + btn.clicked.connect(lambda: self.removed.emit(self.tag_name)) + layout.addWidget(btn) + + self.setStyleSheet( + "TagChip { background-color: #1a3a5c; border: 1px solid #2a5a8c;" + " border-radius: 9px; }" + ) + + +class FlowTagContainer(QWidget): + """Контейнер тегов в строку с переносом.""" + tag_removed = pyqtSignal(str) + + def __init__(self, parent=None): + super().__init__(parent) + self._layout = QHBoxLayout(self) + self._layout.setContentsMargins(0, 0, 0, 0) + self._layout.setSpacing(4) + self._layout.addStretch() + + def set_tags(self, tags: list): + # Удаляем старые чипы + while self._layout.count() > 1: + item = self._layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + for t in tags: + chip = TagChip(t) + chip.removed.connect(self.tag_removed.emit) + self._layout.insertWidget(self._layout.count() - 1, chip) + + +class RightPanel(QWidget): + save_clicked = pyqtSignal(int, dict) + send_to_comfy_clicked = pyqtSignal(str) + favorite_toggled = pyqtSignal(int, bool) + tag_added = pyqtSignal(int, str) + tag_removed = pyqtSignal(int, str) + json_saved = pyqtSignal(int, str, str) # (file_id, field: "workflow_json"|"prompt_json", json_text) + + def __init__(self, parent=None): + super().__init__(parent) + self.current_file_id = None + self.current_filepath = None + self.current_raw_prompt = None + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(5, 5, 5, 5) + + self.preview_label = QLabel(tr("no_image_selected")) + self.preview_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.preview_label.setMinimumHeight(250) + self.preview_label.setStyleSheet( + "QLabel { background-color: #0f0f0f; border: 1px solid #2d2d2d; border-radius: 4px; }" + ) + layout.addWidget(self.preview_label) + + # Избранное + self.favorite_btn = QPushButton(tr("favorite")) + self.favorite_btn.setCheckable(True) + self.favorite_btn.clicked.connect(self._on_favorite_clicked) + layout.addWidget(self.favorite_btn) + + self.tabs = QTabWidget() + + # ── Вкладка Generation ── + self.gen_tab = QWidget() + gen_layout = QVBoxLayout(self.gen_tab) + gen_layout.setContentsMargins(5, 5, 5, 5) + + form_layout = QFormLayout() + self.model_input = QLineEdit() + self.model_input.setReadOnly(True) + form_layout.addRow(tr("model"), self.model_input) + + self.sampler_input = QLineEdit() + self.sampler_input.setReadOnly(True) + form_layout.addRow(tr("sampler"), self.sampler_input) + + params_row = QHBoxLayout() + self.seed_input = QLineEdit() + self.seed_input.setReadOnly(True) + self.steps_input = QLineEdit() + self.steps_input.setReadOnly(True) + self.cfg_input = QLineEdit() + self.cfg_input.setReadOnly(True) + + params_row.addWidget(QLabel("Seed:")) + params_row.addWidget(self.seed_input) + params_row.addWidget(QLabel("Steps:")) + params_row.addWidget(self.steps_input) + params_row.addWidget(QLabel("CFG:")) + params_row.addWidget(self.cfg_input) + form_layout.addRow(params_row) + + self.rating_combo = QComboBox() + self.rating_combo.addItems([tr("no_rating"), "★", "★★", "★★★", "★★★★", "★★★★★"]) + form_layout.addRow(tr("rating"), self.rating_combo) + gen_layout.addLayout(form_layout) + + # Теги + gen_layout.addWidget(QLabel(tr("tags"))) + self.tag_container = FlowTagContainer() + self.tag_container.tag_removed.connect(self._on_tag_removed) + gen_layout.addWidget(self.tag_container) + + self.tag_input = TranslatableLineEdit() + self.tag_input.setPlaceholderText(tr("add_tag_placeholder")) + self.tag_input.returnPressed.connect(self._on_tag_entered) + gen_layout.addWidget(self.tag_input) + + gen_layout.addWidget(QLabel(tr("positive_prompt"))) + self.positive_text = TranslatableTextEdit() + gen_layout.addWidget(self.positive_text) + + gen_layout.addWidget(QLabel(tr("negative_prompt"))) + self.negative_text = TranslatableTextEdit() + gen_layout.addWidget(self.negative_text) + + self.save_btn = QPushButton(tr("save_changes")) + self.save_btn.clicked.connect(self._on_save_clicked) + gen_layout.addWidget(self.save_btn) + + # ── Вкладка Workflow (редактируемая) ── + self.workflow_tab = QWidget() + wf_layout = QVBoxLayout(self.workflow_tab) + wf_layout.setContentsMargins(5, 5, 5, 5) + self.workflow_text = TranslatableTextEdit() + wf_layout.addWidget(self.workflow_text) + + wf_btn_layout = QHBoxLayout() + self.format_wf_btn = QPushButton(tr("format_json")) + self.format_wf_btn.clicked.connect(lambda: self._format_json(self.workflow_text)) + self.save_wf_btn = QPushButton(tr("save_workflow")) + self.save_wf_btn.clicked.connect( + lambda: self._save_json_field("workflow_json", self.workflow_text)) + wf_btn_layout.addWidget(self.format_wf_btn) + wf_btn_layout.addWidget(self.save_wf_btn) + wf_layout.addLayout(wf_btn_layout) + + # ── Вкладка Prompt JSON (редактируемая) ── + self.prompt_tab = QWidget() + pr_layout = QVBoxLayout(self.prompt_tab) + pr_layout.setContentsMargins(5, 5, 5, 5) + self.prompt_json_text = TranslatableTextEdit() + pr_layout.addWidget(self.prompt_json_text) + + pr_btn_layout = QHBoxLayout() + self.format_pr_btn = QPushButton(tr("format_json")) + self.format_pr_btn.clicked.connect(lambda: self._format_json(self.prompt_json_text)) + self.save_pr_btn = QPushButton(tr("save_prompt_json")) + self.save_pr_btn.clicked.connect( + lambda: self._save_json_field("prompt_json", self.prompt_json_text)) + pr_btn_layout.addWidget(self.format_pr_btn) + pr_btn_layout.addWidget(self.save_pr_btn) + pr_layout.addLayout(pr_btn_layout) + + self.tabs.addTab(self.gen_tab, tr("tab_generation")) + self.tabs.addTab(self.workflow_tab, tr("tab_workflow")) + self.tabs.addTab(self.prompt_tab, tr("tab_prompt_json")) + layout.addWidget(self.tabs) + + # Кнопки копирования + export_layout = QHBoxLayout() + self.copy_pos_btn = QPushButton(tr("copy_positive")) + self.copy_pos_btn.clicked.connect( + lambda: QApplication.clipboard().setText(self.positive_text.toPlainText())) + self.copy_neg_btn = QPushButton(tr("copy_negative")) + self.copy_neg_btn.clicked.connect( + lambda: QApplication.clipboard().setText(self.negative_text.toPlainText())) + self.copy_wf_btn = QPushButton(tr("copy_workflow")) + self.copy_wf_btn.clicked.connect( + lambda: QApplication.clipboard().setText(self.workflow_text.toPlainText())) + export_layout.addWidget(self.copy_pos_btn) + export_layout.addWidget(self.copy_neg_btn) + export_layout.addWidget(self.copy_wf_btn) + layout.addLayout(export_layout) + + send_layout = QHBoxLayout() + self.send_btn = QPushButton(tr("send_to_comfy")) + self.send_btn.clicked.connect(self._on_send_clicked) + send_layout.addWidget(self.send_btn, 1) + + self.randomize_seed_check = QCheckBox(tr("randomize_seed")) + self.randomize_seed_check.setChecked(True) + send_layout.addWidget(self.randomize_seed_check) + layout.addLayout(send_layout) + + # ── Отображение данных ── + + def display_metadata(self, file_details: Optional[dict]): + if not file_details: + self.clear_fields() + return + self.current_file_id = file_details["id"] + self.current_filepath = file_details["filepath"] + self.current_raw_prompt = file_details.get("prompt_json") + + pixmap = QPixmap(self.current_filepath) + if not pixmap.isNull(): + scaled = pixmap.scaled( + self.preview_label.width(), self.preview_label.height(), + Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation + ) + self.preview_label.setPixmap(scaled) + else: + self.preview_label.setText(tr("preview_error")) + + self.favorite_btn.setChecked(bool(file_details.get("favorite"))) + + self.model_input.setText(file_details.get("model_name") or tr("not_specified")) + self.sampler_input.setText(file_details.get("sampler") or tr("not_specified")) + self.seed_input.setText(str(file_details.get("seed") or "")) + self.steps_input.setText(str(file_details.get("steps") or "")) + self.cfg_input.setText(str(file_details.get("cfg") or "")) + + rating = file_details.get("rating", 0) + self.rating_combo.setCurrentIndex(rating if 0 <= rating <= 5 else 0) + + self.tag_container.set_tags(file_details.get("tags", [])) + + self.positive_text.setPlainText(file_details.get("positive_prompt") or "") + self.negative_text.setPlainText(file_details.get("negative_prompt") or "") + + self._display_json(self.workflow_text, file_details.get("workflow_json"), + tr("workflow_not_found")) + self._display_json(self.prompt_json_text, file_details.get("prompt_json"), + tr("prompt_json_not_found")) + has_wf = bool(file_details.get("workflow_json")) + has_pr = bool(file_details.get("prompt_json")) + self.save_wf_btn.setEnabled(True) + self.save_pr_btn.setEnabled(True) + self.format_wf_btn.setEnabled(has_wf) + self.format_pr_btn.setEnabled(has_pr) + + @staticmethod + def _display_json(widget: QTextEdit, raw: Optional[str], placeholder: str): + if raw: + try: + widget.setPlainText(json.dumps(json.loads(raw), indent=2, ensure_ascii=False)) + except Exception: + widget.setPlainText(raw) + else: + widget.clear() + widget.setPlaceholderText(placeholder) + + def clear_fields(self): + self.current_file_id = None + self.current_filepath = None + self.current_raw_prompt = None + self.preview_label.setText(tr("no_image_selected")) + self.preview_label.setPixmap(QPixmap()) + self.favorite_btn.setChecked(False) + self.model_input.clear() + self.sampler_input.clear() + self.seed_input.clear() + self.steps_input.clear() + self.cfg_input.clear() + self.rating_combo.setCurrentIndex(0) + self.tag_container.set_tags([]) + self.tag_input.clear() + self.positive_text.clear() + self.negative_text.clear() + self.workflow_text.clear() + self.prompt_json_text.clear() + self.save_wf_btn.setEnabled(False) + self.save_pr_btn.setEnabled(False) + self.format_wf_btn.setEnabled(False) + self.format_pr_btn.setEnabled(False) + + # ── Редактор JSON ── + + def _format_json(self, widget: QTextEdit): + """Валидирует и красиво форматирует JSON в редакторе.""" + text = widget.toPlainText().strip() + if not text: + return + try: + widget.setPlainText(json.dumps(json.loads(text), indent=2, ensure_ascii=False)) + except json.JSONDecodeError as e: + QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e))) + + def _save_json_field(self, field: str, widget: QTextEdit): + """Проверяет JSON и передаёт его на сохранение в БД.""" + if self.current_file_id is None: + return + text = widget.toPlainText().strip() + if text: + try: + # Сохраняем компактную форму, как пишет ComfyUI + text = json.dumps(json.loads(text), ensure_ascii=False) + except json.JSONDecodeError as e: + QMessageBox.warning(self, tr("error"), tr("invalid_json", err=str(e))) + return + if field == "prompt_json": + self.current_raw_prompt = text or None + self.json_saved.emit(self.current_file_id, field, text) + + # ── Обработчики ── + + def _on_save_clicked(self): + if self.current_file_id is None: + return + payload = { + "positive_prompt": self.positive_text.toPlainText(), + "negative_prompt": self.negative_text.toPlainText(), + "rating": self.rating_combo.currentIndex() + } + self.save_clicked.emit(self.current_file_id, payload) + + def _on_send_clicked(self): + if not self.current_raw_prompt: + QMessageBox.warning(self, tr("error"), tr("no_prompt_graph")) + return + # Внедряем текущие (возможно отредактированные) промты в граф. + # Без нового seed ComfyUI отдаёт идентичный граф из кэша + # и не генерирует ("Prompt executed in 0.00 seconds"). + seed = random.randint(0, 2**48) if self.randomize_seed_check.isChecked() else None + updated_prompt = MetadataParser.inject_parameters( + self.current_raw_prompt, + positive=self.positive_text.toPlainText(), + negative=self.negative_text.toPlainText(), + seed=seed, + ) + self.send_to_comfy_clicked.emit(updated_prompt) + + def _on_favorite_clicked(self, checked: bool): + if self.current_file_id is not None: + self.favorite_toggled.emit(self.current_file_id, checked) + + def _on_tag_entered(self): + if self.current_file_id is None: + return + tag = self.tag_input.text().strip() + if tag: + self.tag_added.emit(self.current_file_id, tag) + self.tag_input.clear() + + def _on_tag_removed(self, tag: str): + if self.current_file_id is not None: + self.tag_removed.emit(self.current_file_id, tag) diff --git a/comfy_studio/ui/widgets/settings_dialog.py b/comfy_studio/ui/widgets/settings_dialog.py new file mode 100644 index 0000000..48f5bc4 --- /dev/null +++ b/comfy_studio/ui/widgets/settings_dialog.py @@ -0,0 +1,118 @@ +""" +Диалог настроек: URL ComfyUI, отслеживаемые папки, язык интерфейса, +языки перевода в контекстном меню. +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QListWidgetItem, QFileDialog, + QMessageBox, QComboBox +) +from PyQt6.QtCore import Qt + +from comfy_studio.core.i18n import tr +from comfy_studio.services.translator import LANG_NAMES + + +class SettingsDialog(QDialog): + def __init__(self, current_settings: dict, parent=None): + super().__init__(parent) + self.settings = current_settings + self.setWindowTitle(tr("settings_title")) + self.resize(520, 420) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel(tr("comfy_api_url"))) + self.url_input = QLineEdit() + self.url_input.setText(self.settings.get("comfyui_url", "http://127.0.0.1:8188")) + layout.addWidget(self.url_input) + + # Язык интерфейса + layout.addWidget(QLabel(tr("language"))) + self.lang_combo = QComboBox() + self.lang_combo.addItem("Русский", "ru") + self.lang_combo.addItem("English", "en") + current_lang = self.settings.get("language", "ru") + self.lang_combo.setCurrentIndex(0 if current_lang == "ru" else 1) + layout.addWidget(self.lang_combo) + + note = QLabel(tr("language_restart_note")) + note.setStyleSheet("color: #9e9e9e; font-size: 11px;") + layout.addWidget(note) + + # Языки перевода (мультивыбор чекбоксами) + layout.addWidget(QLabel(tr("translation_languages"))) + self.translate_langs_list = QListWidget() + self.translate_langs_list.setMaximumHeight(120) + selected_langs = self.settings.get("translate_languages", ["ru", "en"]) + for code, name in LANG_NAMES.items(): + item = QListWidgetItem(f"{name} ({code})") + item.setData(Qt.ItemDataRole.UserRole, code) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsUserCheckable) + item.setCheckState( + Qt.CheckState.Checked if code in selected_langs + else Qt.CheckState.Unchecked) + self.translate_langs_list.addItem(item) + layout.addWidget(self.translate_langs_list) + + layout.addWidget(QLabel(tr("tracked_folders"))) + self.paths_list = QListWidget() + for p in self.settings.get("tracked_paths", []): + self.paths_list.addItem(p) + layout.addWidget(self.paths_list) + + paths_btn_layout = QHBoxLayout() + self.add_path_btn = QPushButton(tr("add_folder")) + self.add_path_btn.clicked.connect(self._add_path) + self.remove_path_btn = QPushButton(tr("remove_selected")) + self.remove_path_btn.clicked.connect(self._remove_path) + paths_btn_layout.addWidget(self.add_path_btn) + paths_btn_layout.addWidget(self.remove_path_btn) + layout.addLayout(paths_btn_layout) + + btn_layout = QHBoxLayout() + self.save_btn = QPushButton(tr("save")) + self.save_btn.clicked.connect(self._save) + self.cancel_btn = QPushButton(tr("cancel")) + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addStretch() + btn_layout.addWidget(self.save_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def _add_path(self): + dir_path = QFileDialog.getExistingDirectory(self, tr("choose_tracked_folder")) + if dir_path: + items = [self.paths_list.item(i).text() for i in range(self.paths_list.count())] + if dir_path not in items: + self.paths_list.addItem(dir_path) + + def _remove_path(self): + for s in self.paths_list.selectedItems(): + self.paths_list.takeItem(self.paths_list.row(s)) + + def _save(self): + url = self.url_input.text().strip() + if not url: + QMessageBox.warning(self, tr("error"), tr("url_empty_error")) + return + + paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())] + if not paths: + QMessageBox.warning(self, tr("error"), tr("paths_empty_error")) + return + + translate_langs = [] + for i in range(self.translate_langs_list.count()): + item = self.translate_langs_list.item(i) + if item.checkState() == Qt.CheckState.Checked: + translate_langs.append(item.data(Qt.ItemDataRole.UserRole)) + + self.settings["comfyui_url"] = url + self.settings["tracked_paths"] = paths + self.settings["language"] = self.lang_combo.currentData() + self.settings["translate_languages"] = translate_langs or ["ru", "en"] + self.accept() diff --git a/comfy_studio/ui/widgets/translatable_text_edit.py b/comfy_studio/ui/widgets/translatable_text_edit.py new file mode 100644 index 0000000..1fec976 --- /dev/null +++ b/comfy_studio/ui/widgets/translatable_text_edit.py @@ -0,0 +1,82 @@ +""" +QTextEdit / QLineEdit с пунктами «Перевести на …» в контекстном меню. +Пункты появляются при наличии выделенного текста; список языков +настраивается глобально через set_translate_languages() (из настроек). +""" + +from typing import List + +from PyQt6.QtWidgets import QTextEdit, QLineEdit + +from comfy_studio.core.i18n import tr +from comfy_studio.services.translator import LANG_NAMES +from comfy_studio.ui.widgets.translate_dialog import TranslateDialog + +# Глобальный список языков перевода (обновляется из настроек) +_translate_languages: List[str] = ["ru", "en"] + + +def set_translate_languages(langs: List[str]) -> None: + global _translate_languages + _translate_languages = [l for l in langs if l in LANG_NAMES] or ["ru", "en"] + + +def get_translate_languages() -> List[str]: + return list(_translate_languages) + + +class TranslatableTextEdit(QTextEdit): + """QTextEdit с переводом выделенного текста через контекстное меню.""" + + def contextMenuEvent(self, event): + menu = self.createStandardContextMenu() + + cursor = self.textCursor() + selected = cursor.selectedText() + if selected.strip() and not self.isReadOnly(): + menu.addSeparator() + for lang in _translate_languages: + lang_name = LANG_NAMES.get(lang, lang) + action = menu.addAction(tr("translate_to", lang=lang_name)) + action.setData(lang) + + chosen = menu.exec(event.globalPos()) + if chosen and chosen.data() in _translate_languages: + self._translate_selection(chosen.data()) + + def _translate_selection(self, target_lang: str): + cursor = self.textCursor() + # QTextCursor.selectedText() заменяет переводы строк на U+2029 + selected = cursor.selectedText().replace("
", "\n") + if not selected.strip(): + return + dialog = TranslateDialog(selected, target_lang, self) + if dialog.exec() == TranslateDialog.DialogCode.Accepted: + cursor.insertText(dialog.translated_text()) + + +class TranslatableLineEdit(QLineEdit): + """QLineEdit с переводом выделенного текста через контекстное меню.""" + + def contextMenuEvent(self, event): + menu = self.createStandardContextMenu() + + selected = self.selectedText() + if selected.strip() and not self.isReadOnly(): + menu.addSeparator() + for lang in _translate_languages: + lang_name = LANG_NAMES.get(lang, lang) + action = menu.addAction(tr("translate_to", lang=lang_name)) + action.setData(lang) + + chosen = menu.exec(event.globalPos()) + if chosen and chosen.data() in _translate_languages: + self._translate_selection(chosen.data()) + + def _translate_selection(self, target_lang: str): + selected = self.selectedText() + if not selected.strip(): + return + dialog = TranslateDialog(selected, target_lang, self) + if dialog.exec() == TranslateDialog.DialogCode.Accepted: + self.insert(dialog.translated_text()) # заменяет выделение diff --git a/comfy_studio/ui/widgets/translate_dialog.py b/comfy_studio/ui/widgets/translate_dialog.py new file mode 100644 index 0000000..155d94f --- /dev/null +++ b/comfy_studio/ui/widgets/translate_dialog.py @@ -0,0 +1,80 @@ +""" +Диалог перевода выделенного текста: +показывает оригинал и редактируемый перевод; «Применить» / «Отменить». +""" + +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QTextEdit, QPushButton +) + +from comfy_studio.core.i18n import tr +from comfy_studio.services.translator import TranslateWorker, LANG_NAMES + + +class TranslateDialog(QDialog): + """ + Запускает фоновый перевод текста и позволяет отредактировать результат. + После exec() == Accepted переведённый текст доступен через translated_text(). + """ + + def __init__(self, source_text: str, target_lang: str, parent=None): + super().__init__(parent) + self.source_text = source_text + self.target_lang = target_lang + self.worker = None + + lang_name = LANG_NAMES.get(target_lang, target_lang) + self.setWindowTitle(f"{tr('translation_title')} → {lang_name}") + self.resize(560, 440) + self.init_ui() + self._start_translation() + + def init_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel(tr("original_text"))) + self.original_edit = QTextEdit() + self.original_edit.setPlainText(self.source_text) + self.original_edit.setReadOnly(True) + self.original_edit.setMaximumHeight(140) + layout.addWidget(self.original_edit) + + layout.addWidget(QLabel(tr("translated_text"))) + self.translated_edit = QTextEdit() + self.translated_edit.setPlainText(tr("translating")) + self.translated_edit.setEnabled(False) + layout.addWidget(self.translated_edit) + + btn_layout = QHBoxLayout() + btn_layout.addStretch() + self.apply_btn = QPushButton(tr("apply")) + self.apply_btn.setEnabled(False) + self.apply_btn.clicked.connect(self.accept) + self.cancel_btn = QPushButton(tr("cancel")) + self.cancel_btn.clicked.connect(self.reject) + btn_layout.addWidget(self.apply_btn) + btn_layout.addWidget(self.cancel_btn) + layout.addLayout(btn_layout) + + def _start_translation(self): + self.worker = TranslateWorker(self.source_text, self.target_lang, self) + self.worker.finished.connect(self._on_translation_finished) + self.worker.start() + + def _on_translation_finished(self, success: bool, result: str): + if success: + self.translated_edit.setPlainText(result) + self.translated_edit.setEnabled(True) + self.apply_btn.setEnabled(True) + self.translated_edit.setFocus() + else: + self.translated_edit.setPlainText(f"{tr('translation_failed')}:\n{result}") + + def translated_text(self) -> str: + return self.translated_edit.toPlainText() + + def closeEvent(self, event): + if self.worker and self.worker.isRunning(): + self.worker.finished.disconnect() + self.worker.quit() + super().closeEvent(event) diff --git a/comfy_studio/ui/widgets/viewer.py b/comfy_studio/ui/widgets/viewer.py new file mode 100644 index 0000000..358924d --- /dev/null +++ b/comfy_studio/ui/widgets/viewer.py @@ -0,0 +1,99 @@ +""" +Полноэкранный просмотрщик изображений. +Стрелки ←/→ — листание, Esc / двойной клик — выход, колесо мыши — листание. +""" + +import logging +from typing import List + +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel +from PyQt6.QtGui import QPixmap, QKeyEvent +from PyQt6.QtCore import Qt + +logger = logging.getLogger(__name__) + + +class FullscreenViewer(QWidget): + def __init__(self, filepaths: List[str], start_index: int = 0, parent=None): + super().__init__(parent) + self.filepaths = filepaths + self.index = max(0, min(start_index, len(filepaths) - 1)) + + self.setWindowFlags(Qt.WindowType.Window | Qt.WindowType.FramelessWindowHint) + self.setStyleSheet("background-color: #000000;") + + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.image_label.setStyleSheet("background-color: #000000;") + layout.addWidget(self.image_label, 1) + + self.info_label = QLabel() + self.info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.info_label.setStyleSheet( + "color: #9e9e9e; background-color: #000000; padding: 6px; font-size: 12px;") + layout.addWidget(self.info_label) + + self._current_pixmap = None + + def open_fullscreen(self): + self.showFullScreen() + self._load_current() + + def _load_current(self): + if not self.filepaths: + return + path = self.filepaths[self.index] + pixmap = QPixmap(path) + if pixmap.isNull(): + self.image_label.setText("⚠") + self._current_pixmap = None + else: + self._current_pixmap = pixmap + self._rescale() + self.info_label.setText(f"{self.index + 1} / {len(self.filepaths)} — {path}") + + def _rescale(self): + if self._current_pixmap: + scaled = self._current_pixmap.scaled( + self.image_label.size(), + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + self.image_label.setPixmap(scaled) + + def resizeEvent(self, event): + self._rescale() + super().resizeEvent(event) + + def keyPressEvent(self, event: QKeyEvent): + key = event.key() + if key == Qt.Key.Key_Escape: + self.close() + elif key in (Qt.Key.Key_Right, Qt.Key.Key_Down, Qt.Key.Key_Space): + self._next() + elif key in (Qt.Key.Key_Left, Qt.Key.Key_Up): + self._prev() + else: + super().keyPressEvent(event) + + def wheelEvent(self, event): + if event.angleDelta().y() < 0: + self._next() + else: + self._prev() + + def mouseDoubleClickEvent(self, event): + self.close() + + def _next(self): + if self.index < len(self.filepaths) - 1: + self.index += 1 + self._load_current() + + def _prev(self): + if self.index > 0: + self.index -= 1 + self._load_current() diff --git a/main.py b/main.py new file mode 100644 index 0000000..a80c397 --- /dev/null +++ b/main.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 +""" +Comfy-Studio — галерея и менеджер изображений, сгенерированных в ComfyUI. +Точка входа: логирование, БД, вотчер папок, запуск GUI. +""" + +import sys +import os +import logging +from pathlib import Path + +# Корень проекта в путях поиска модулей +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from PyQt6.QtWidgets import QApplication +from PyQt6.QtCore import Qt + +from comfy_studio.core.config import APP_NAME, DB_PATH, LOG_PATH, LOGGING_LEVEL +from comfy_studio.core.settings import load_settings +from comfy_studio.core.i18n import set_language +from comfy_studio.db.manager import DBManager +from comfy_studio.services.watcher import FolderWatcher + + +def configure_logging() -> None: + logging.basicConfig( + level=getattr(logging, LOGGING_LEVEL.upper(), logging.INFO), + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + handlers=[ + logging.FileHandler(str(LOG_PATH), encoding="utf-8"), + logging.StreamHandler(sys.stdout), + ], + ) + logging.getLogger("PIL").setLevel(logging.WARNING) + logging.getLogger("watchdog").setLevel(logging.INFO) + + +def main() -> int: + configure_logging() + logger = logging.getLogger(__name__) + logger.info(f"{APP_NAME} запускается...") + + # Настройки и язык (до создания UI!) + settings = load_settings() + set_language(settings.get("language", "ru")) + + # База данных + try: + db_manager = DBManager(str(DB_PATH)) + logger.info(f"База данных готова: {DB_PATH}") + except Exception as e: + logger.critical(f"Не удалось инициализировать БД: {e}") + return 1 + + # Гарантируем существование отслеживаемых папок + tracked_paths = settings.get("tracked_paths", []) + for path_str in tracked_paths: + Path(path_str).mkdir(parents=True, exist_ok=True) + + # Фоновый вотчер + watcher = FolderWatcher(db_manager) + watcher.start_monitoring(tracked_paths) + + # High DPI (до создания QApplication) + QApplication.setHighDpiScaleFactorRoundingPolicy( + Qt.HighDpiScaleFactorRoundingPolicy.PassThrough + ) + + app = QApplication(sys.argv) + app.setApplicationName(APP_NAME) + app.setStyle("Fusion") + + # Импорт окна после создания QApplication + from comfy_studio.ui.main_window import MainWindow + + window = MainWindow(db_manager, watcher) + + if tracked_paths: + window.left_panel.set_root_path(tracked_paths[0]) + window.load_folder_images(tracked_paths[0]) + + window.show() + logger.info("UI запущен.") + + try: + exit_code = app.exec() + finally: + watcher.stop_monitoring() + logger.info(f"{APP_NAME} завершён.") + + return exit_code + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c58d928 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +PyQt6>=6.5 +Pillow>=10.0 +piexif>=1.1.3 +watchdog>=3.0 +requests>=2.31