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
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 = []