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:
dinlo
2026-07-02 14:09:57 +08:00
commit 34a40ae759
29 changed files with 3109 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Данные приложения (локальные, создаются при запуске)
comfystudio.db
comfystudio.log
settings.json
# IDE
.idea/
.vscode/
+67
View File
@@ -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
```
+3
View File
@@ -0,0 +1,3 @@
"""Comfy-Studio — desktop gallery and workflow manager for ComfyUI images."""
__version__ = "1.0.0"
View File
+32
View File
@@ -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"
+153
View File
@@ -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
+45
View File
@@ -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
View File
+389
View File
@@ -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 []
+60
View File
@@ -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);
"""
View File
+272
View File
@@ -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
View File
+65
View File
@@ -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))
+69
View File
@@ -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))
+146
View File
@@ -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 = []
View File
+377
View File
@@ -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)
+136
View File
@@ -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;
}
"""
View File
+312
View File
@@ -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)
+99
View File
@@ -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)
+389
View File
@@ -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)
+118
View File
@@ -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)
+99
View File
@@ -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()
+96
View File
@@ -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())
+5
View File
@@ -0,0 +1,5 @@
PyQt6>=6.5
Pillow>=10.0
piexif>=1.1.3
watchdog>=3.0
requests>=2.31