Initial release: Comfy-Studio v1.0.0
Десктопная галерея и менеджер изображений 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) <noreply@anthropic.com>
This commit is contained in:
+15
@@ -0,0 +1,15 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Данные приложения (локальные, создаются при запуске)
|
||||||
|
comfystudio.db
|
||||||
|
comfystudio.log
|
||||||
|
settings.json
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Comfy-Studio — desktop gallery and workflow manager for ComfyUI images."""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 []
|
||||||
@@ -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);
|
||||||
|
"""
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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 = []
|
||||||
@@ -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)
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
"""
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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()) # заменяет выделение
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
PyQt6>=6.5
|
||||||
|
Pillow>=10.0
|
||||||
|
piexif>=1.1.3
|
||||||
|
watchdog>=3.0
|
||||||
|
requests>=2.31
|
||||||
Reference in New Issue
Block a user