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:
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Клиент API ComfyUI + фоновый воркер отправки промтов (не блокирует GUI).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
from comfy_studio.core.config import COMFYUI_PROMPT_ENDPOINT
|
||||
from comfy_studio.core.i18n import tr
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ComfyAPIClient:
|
||||
def __init__(self, base_url: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
|
||||
|
||||
class ComfySendWorker(QThread):
|
||||
"""Фоновая отправка prompt-графа в очередь ComfyUI."""
|
||||
finished = pyqtSignal(bool, str) # (success, message)
|
||||
|
||||
def __init__(self, api_client: ComfyAPIClient, prompt_json_str: str):
|
||||
super().__init__()
|
||||
self.api_client = api_client
|
||||
self.prompt_json_str = prompt_json_str
|
||||
|
||||
def run(self):
|
||||
logger.info("Запущена фоновая отправка промта в ComfyUI.")
|
||||
try:
|
||||
prompt_data = json.loads(self.prompt_json_str)
|
||||
payload = {"prompt": prompt_data}
|
||||
url = f"{self.api_client.base_url}{COMFYUI_PROMPT_ENDPOINT}"
|
||||
|
||||
response = requests.post(url, json=payload, timeout=5)
|
||||
|
||||
if response.status_code == 200:
|
||||
res_data = response.json()
|
||||
prompt_id = res_data.get("prompt_id", "?")
|
||||
logger.info(f"Задача принята ComfyUI. ID: {prompt_id}")
|
||||
self.finished.emit(
|
||||
True,
|
||||
f"{tr('prompt_queued')}\n\n{tr('task_id')}: {prompt_id}"
|
||||
)
|
||||
else:
|
||||
logger.error(f"ComfyUI отклонил запрос: {response.status_code} {response.text}")
|
||||
self.finished.emit(
|
||||
False,
|
||||
f"{tr('comfy_server_error', code=response.status_code)}:\n{response.text}"
|
||||
)
|
||||
except json.JSONDecodeError as je:
|
||||
logger.error(f"Не удалось распарсить prompt_json: {je}")
|
||||
self.finished.emit(False, f"{tr('metadata_error')}:\n{je}")
|
||||
except requests.exceptions.RequestException as re:
|
||||
logger.error(f"Сетевое исключение при отправке: {re}")
|
||||
self.finished.emit(
|
||||
False,
|
||||
f"{tr('comfy_connect_error', url=self.api_client.base_url)}\n\n{re}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Непредвиденная ошибка воркера: {e}")
|
||||
self.finished.emit(False, str(e))
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Сервис перевода текста (бесплатный endpoint Google Translate, без API-ключа).
|
||||
Перевод выполняется в фоновом QThread, чтобы не блокировать GUI.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import requests
|
||||
from PyQt6.QtCore import QThread, pyqtSignal
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TRANSLATE_URL = "https://translate.googleapis.com/translate_a/single"
|
||||
|
||||
# Отображаемые названия языков (код -> название)
|
||||
LANG_NAMES = {
|
||||
"ru": "Русский",
|
||||
"en": "English",
|
||||
"de": "Deutsch",
|
||||
"fr": "Français",
|
||||
"es": "Español",
|
||||
"it": "Italiano",
|
||||
"pt": "Português",
|
||||
"pl": "Polski",
|
||||
"uk": "Українська",
|
||||
"ja": "日本語",
|
||||
"zh-CN": "中文",
|
||||
"ko": "한국어",
|
||||
}
|
||||
|
||||
|
||||
def translate_text(text: str, target_lang: str, source_lang: str = "auto") -> str:
|
||||
"""
|
||||
Переводит текст через публичный endpoint Google Translate.
|
||||
Бросает исключение при сетевой ошибке или неожиданном ответе.
|
||||
"""
|
||||
params = {
|
||||
"client": "gtx",
|
||||
"sl": source_lang,
|
||||
"tl": target_lang,
|
||||
"dt": "t",
|
||||
"q": text,
|
||||
}
|
||||
response = requests.get(TRANSLATE_URL, params=params, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
# Ответ: [[["перевод1","оригинал1",...],["перевод2",...],...], ...]
|
||||
segments = data[0] or []
|
||||
return "".join(seg[0] for seg in segments if seg and seg[0])
|
||||
|
||||
|
||||
class TranslateWorker(QThread):
|
||||
"""Фоновый перевод текста."""
|
||||
finished = pyqtSignal(bool, str) # (success, translated_text_or_error)
|
||||
|
||||
def __init__(self, text: str, target_lang: str, parent=None):
|
||||
super().__init__(parent)
|
||||
self.text = text
|
||||
self.target_lang = target_lang
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
translated = translate_text(self.text, self.target_lang)
|
||||
self.finished.emit(True, translated)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Сетевая ошибка перевода: {e}")
|
||||
self.finished.emit(False, str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка перевода: {e}")
|
||||
self.finished.emit(False, str(e))
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
Фоновый мониторинг папок генерации (watchdog) с мостом сигналов в GUI-поток PyQt6.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
|
||||
from PyQt6.QtCore import QObject, pyqtSignal
|
||||
from watchdog.observers import Observer
|
||||
from watchdog.events import FileSystemEventHandler
|
||||
|
||||
from comfy_studio.core.config import SUPPORTED_EXTENSIONS
|
||||
from comfy_studio.db.manager import DBManager
|
||||
from comfy_studio.parsers.metadata_parser import MetadataParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WatcherSignals(QObject):
|
||||
"""Мост между потоками Watchdog и основным GUI-потоком PyQt6."""
|
||||
file_added = pyqtSignal(str) # filepath
|
||||
file_removed = pyqtSignal(str) # filepath
|
||||
|
||||
|
||||
class GalleryEventHandler(FileSystemEventHandler):
|
||||
def __init__(self, db_manager: DBManager, root_path: str, signals: WatcherSignals):
|
||||
super().__init__()
|
||||
self.db_manager = db_manager
|
||||
self.root_path = Path(root_path).resolve()
|
||||
self.signals = signals
|
||||
|
||||
def on_created(self, event):
|
||||
if event.is_directory:
|
||||
self._register_folder_recursive(Path(event.src_path))
|
||||
else:
|
||||
self._handle_file_creation(Path(event.src_path))
|
||||
|
||||
def on_deleted(self, event):
|
||||
if event.is_directory:
|
||||
self.db_manager.remove_folder_by_path(event.src_path)
|
||||
else:
|
||||
self.db_manager.remove_file_by_path(event.src_path)
|
||||
self.signals.file_removed.emit(event.src_path)
|
||||
|
||||
def on_moved(self, event):
|
||||
src_path = Path(event.src_path)
|
||||
dest_path = Path(event.dest_path)
|
||||
if event.is_directory:
|
||||
self.db_manager.remove_folder_by_path(str(src_path))
|
||||
self._register_folder_recursive(dest_path)
|
||||
else:
|
||||
self.db_manager.remove_file_by_path(str(src_path))
|
||||
self._handle_file_creation(dest_path)
|
||||
|
||||
def _handle_file_creation(self, path: Path):
|
||||
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
||||
return
|
||||
|
||||
# Пауза, гарантирующая полное высвобождение файла при записи из ComfyUI
|
||||
time.sleep(0.3)
|
||||
try:
|
||||
stat = path.stat()
|
||||
folder_id = self._get_or_create_folder_id(path.parent.resolve())
|
||||
if folder_id is None:
|
||||
return
|
||||
file_id = self.db_manager.register_file(
|
||||
folder_id=folder_id, filename=path.name,
|
||||
filepath=str(path.as_posix()), size=stat.st_size, mtime=stat.st_mtime
|
||||
)
|
||||
if file_id:
|
||||
prompt_raw, workflow_raw = MetadataParser.extract_raw_metadata(str(path))
|
||||
parsed_params = MetadataParser.parse_comfy_parameters(prompt_raw)
|
||||
meta_payload = {
|
||||
"prompt_json": prompt_raw, "workflow_json": workflow_raw,
|
||||
**parsed_params
|
||||
}
|
||||
self.db_manager.save_metadata(file_id, meta_payload)
|
||||
self.signals.file_added.emit(str(path.as_posix()))
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось обработать файл {path}: {e}")
|
||||
|
||||
def _get_or_create_folder_id(self, folder_path: Path) -> Optional[int]:
|
||||
normalized_path = str(folder_path.resolve().as_posix())
|
||||
root_str = str(self.root_path.as_posix())
|
||||
if not normalized_path.startswith(root_str):
|
||||
return None
|
||||
parent_path = folder_path.parent
|
||||
parent_id = None
|
||||
if parent_path != folder_path and normalized_path != root_str:
|
||||
parent_id = self._get_or_create_folder_id(parent_path)
|
||||
return self.db_manager.add_folder(normalized_path, parent_id)
|
||||
|
||||
def _register_folder_recursive(self, folder_path: Path):
|
||||
self._get_or_create_folder_id(folder_path)
|
||||
try:
|
||||
for entry in os.scandir(folder_path):
|
||||
entry_path = Path(entry.path)
|
||||
if entry.is_dir():
|
||||
self._register_folder_recursive(entry_path)
|
||||
elif entry.is_file():
|
||||
self._handle_file_creation(entry_path)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка сканирования папки {folder_path}: {e}")
|
||||
|
||||
|
||||
class FolderWatcher:
|
||||
"""Управляет мониторингом списка директорий (multi-folder tracking)."""
|
||||
|
||||
def __init__(self, db_manager: DBManager):
|
||||
self.db_manager = db_manager
|
||||
self.signals = WatcherSignals()
|
||||
self.observer: Optional[Observer] = None
|
||||
self.handlers: List[GalleryEventHandler] = []
|
||||
|
||||
def start_monitoring(self, root_paths: List[str]):
|
||||
if self.observer and self.observer.is_alive():
|
||||
self.stop_monitoring()
|
||||
|
||||
self.observer = Observer()
|
||||
self.handlers = []
|
||||
|
||||
for p in root_paths:
|
||||
path = Path(p).resolve()
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
handler = GalleryEventHandler(self.db_manager, str(path), self.signals)
|
||||
logger.info(f"Первоначальная индексация: {path}")
|
||||
handler._register_folder_recursive(path)
|
||||
|
||||
self.observer.schedule(handler, str(path), recursive=True)
|
||||
self.handlers.append(handler)
|
||||
logger.info(f"Отслеживание изменений: {path}")
|
||||
|
||||
self.observer.start()
|
||||
|
||||
def stop_monitoring(self):
|
||||
if self.observer:
|
||||
self.observer.stop()
|
||||
self.observer.join()
|
||||
self.observer = None
|
||||
self.handlers = []
|
||||
Reference in New Issue
Block a user