commit b553c957f3dc4c0d110662f5a082ec20501d6597 Author: dinlo Date: Sun May 31 18:43:18 2026 +0800 Initial commit Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/README.md b/README.md new file mode 100644 index 0000000..9d6e012 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# ComfyGallery + +Галерея изображений для ComfyUI с автоматическим отслеживанием папок и базой данных метаданных. + +## Описание + +ComfyGallery - это приложение на PyQt6 для управления и просмотра изображений, сгенерированных в ComfyUI. Приложение автоматически отслеживает указанные папки, сохраняет метаданные изображений в SQLite базу данных и предоставляет удобный интерфейс для просмотра. + +## Возможности + +- 📁 Автоматическое отслеживание папок с изображениями +- 💾 База данных SQLite для хранения метаданных +- 🖼️ Удобный интерфейс для просмотра галереи +- 🔍 Извлечение метаданных из PNG файлов + +## Установка + +```bash +pip install PyQt6 Pillow watchdog +``` + +## Использование + +```bash +python main.py +``` + +Настройте отслеживаемые папки в файле `settings.json`: + +```json +{ + "tracked_paths": [ + "C:/path/to/your/comfyui/output" + ] +} +``` + +## Структура проекта + +- `main.py` - точка входа приложения +- `database/` - модули работы с БД +- `watcher/` - файловый мониторинг +- `ui/` - интерфейс приложения +- `utils/` - вспомогательные утилиты +- `metadata/` - обработка метаданных изображений diff --git a/database/__pycache__/db_manager.cpython-312.pyc b/database/__pycache__/db_manager.cpython-312.pyc new file mode 100644 index 0000000..7cf62b2 Binary files /dev/null and b/database/__pycache__/db_manager.cpython-312.pyc differ diff --git a/database/__pycache__/schema.cpython-312.pyc b/database/__pycache__/schema.cpython-312.pyc new file mode 100644 index 0000000..1875fb4 Binary files /dev/null and b/database/__pycache__/schema.cpython-312.pyc differ diff --git a/database/db_manager.py b/database/db_manager.py new file mode 100644 index 0000000..875b884 --- /dev/null +++ b/database/db_manager.py @@ -0,0 +1,231 @@ +# database/db_manager.py +import sqlite3 +from pathlib import Path +from typing import Optional, List, Dict, Any +from contextlib import contextmanager + +from utils.logger import logger +from database.schema import PRAGMA_FOREIGN_KEYS, CREATE_TABLES_SQL, CREATE_INDEXES_SQL + +class DBManager: + def __init__(self, db_path: str = "comfygallery.db"): + self.db_path = 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 = str(Path(folder_path).resolve().as_posix()) + 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 = str(Path(folder_path).resolve().as_posix()) + 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 = str(Path(filepath).resolve().as_posix()) + 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 = str(Path(filepath).resolve().as_posix()) + 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 = "") -> List[Dict[str, Any]]: + normalized_path = str(Path(folder_path).resolve().as_posix()) + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if search_query: + like_query = f"%{search_query}%" + cursor.execute( + """ + 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 = ? AND (f.filename LIKE ? OR m.positive_prompt LIKE ? OR m.negative_prompt LIKE ?) + ORDER BY f.filename ASC + """, + (normalized_path, like_query, like_query, like_query) + ) + else: + cursor.execute( + """ + 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 = ? + ORDER BY f.filename ASC + """, + (normalized_path,) + ) + 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() + return dict(row) if row else None + 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 \ No newline at end of file diff --git a/database/schema.py b/database/schema.py new file mode 100644 index 0000000..85b2679 --- /dev/null +++ b/database/schema.py @@ -0,0 +1,58 @@ +# database/schema.py +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_tags_name ON tags(name); +""" \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..682c4d6 --- /dev/null +++ b/main.py @@ -0,0 +1,55 @@ +# main.py +import sys +import os +from pathlib import Path +from PyQt6.QtWidgets import QApplication + +# Явное добавление текущей директории в пути поиска модулей Python +project_root = os.path.dirname(os.path.abspath(__file__)) +if project_root not in sys.path: + sys.path.insert(0, project_root) + +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher +from ui.main_window import MainWindow +from utils.settings import load_settings + +def main(): + db_path = "comfygallery.db" + + # 1. Инициализация БД + db_manager = DBManager(db_path) + + # 2. Загрузка конфигурационного файла (JSON) + settings = load_settings() + tracked_paths = settings.get("tracked_paths", []) + + # Гарантируем, что папки существуют + for path_str in tracked_paths: + Path(path_str).mkdir(parents=True, exist_ok=True) + + # 3. Инициализация и запуск фонового вотчера + watcher = FolderWatcher(db_manager) + watcher.start_monitoring(tracked_paths) + + # 4. Запуск GUI приложения + app = QApplication(sys.argv) + + window = MainWindow(db_manager, watcher) + + # Загружаем список путей в дерево + if tracked_paths: + window.left_panel.set_tracked_folders(tracked_paths) + window.left_panel.set_root_path(tracked_paths[0]) + window.load_folder_images(tracked_paths[0]) + + window.show() + + try: + sys.exit(app.exec()) + finally: + # Корректное завершение фоновых потоков при выходе из приложения + watcher.stop_monitoring() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/metadata/__pycache__/parser.cpython-312.pyc b/metadata/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000..c898508 Binary files /dev/null and b/metadata/__pycache__/parser.cpython-312.pyc differ diff --git a/metadata/parser.py b/metadata/parser.py new file mode 100644 index 0000000..d667ccf --- /dev/null +++ b/metadata/parser.py @@ -0,0 +1,172 @@ +# metadata/parser.py +import json +import logging +from pathlib import Path +from typing import Optional, Dict, Any, Tuple +from PIL import Image +import piexif +import piexif.helper + +logger = logging.getLogger("ComfyGallery.MetadataParser") + +class MetadataParser: + @staticmethod + def extract_raw_metadata(filepath: str) -> Tuple[Optional[str], Optional[str]]: + 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]]: + 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]: + 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(): + 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: + positives = [] + for node in prompt_graph.values(): + if node.get("class_type") == "CLIPTextEncode": + text = node.get("inputs", {}).get("text", "") + if text and len(text.strip()) > 0: + 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 _trace_conditioning(cls, link: Optional[list], graph: dict) -> Optional[str]: + if 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", "") + 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 key, val in inputs.items(): + if isinstance(val, list) and len(val) >= 1: + text = cls._trace_conditioning(val, graph) + if text: return text + return None + + @classmethod + def _trace_model(cls, link: Optional[list], graph: dict) -> Optional[str]: + if 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", "") + inputs = node.get("inputs", {}) + if "CheckpointLoader" in class_type: + return inputs.get("ckpt_name") + elif "LoraLoader" in class_type or "ModelMerge" in class_type: + return cls._trace_model(inputs.get("model"), graph) + return None \ No newline at end of file diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..0a6e709 --- /dev/null +++ b/settings.json @@ -0,0 +1,6 @@ +{ + "comfyui_url": "http://192.168.1.151:8000", + "tracked_paths": [ + "C:\\Users\\dimir\\proects\\ComfyGallery\\test_output" + ] +} \ No newline at end of file diff --git a/test_integration.py b/test_integration.py new file mode 100644 index 0000000..c5b795f --- /dev/null +++ b/test_integration.py @@ -0,0 +1,23 @@ +# test_integration.py +import time +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher + +if __name__ == "__main__": + # Инициализируем менеджер БД (создаст comfygallery.db в корне) + db = DBManager("comfygallery.db") + + # Создаем вотчер + watcher = FolderWatcher(db) + + # Запускаем отслеживание тестовой папки + watch_dir = "./test_output" + watcher.start_monitoring(watch_dir) + + print(f"Слежение запущено за '{watch_dir}'. Поместите туда PNG/JPG из ComfyUI...") + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("Остановка мониторинга...") + watcher.stop_monitoring() \ No newline at end of file diff --git a/ui/__pycache__/main_window.cpython-312.pyc b/ui/__pycache__/main_window.cpython-312.pyc new file mode 100644 index 0000000..b38fc62 Binary files /dev/null and b/ui/__pycache__/main_window.cpython-312.pyc differ diff --git a/ui/__pycache__/styles.cpython-312.pyc b/ui/__pycache__/styles.cpython-312.pyc new file mode 100644 index 0000000..563398d Binary files /dev/null and b/ui/__pycache__/styles.cpython-312.pyc differ diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000..885c3d8 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,354 @@ +# ui/main_window.py +import os +import json +import logging +import subprocess +from pathlib import Path +import requests +from PyQt6.QtWidgets import ( + QMainWindow, QSplitter, QWidget, QHBoxLayout, QVBoxLayout, + QStatusBar, QMessageBox, QApplication, QMenu, QInputDialog, QLineEdit, QPushButton +) +from PyQt6.QtCore import Qt, QModelIndex, QMimeData, QUrl, QThread, pyqtSignal + +from ui.styles import DARK_THEME_QSS +from ui.widgets.left_panel import LeftPanel +from ui.widgets.center_grid import CenterGrid +from ui.widgets.right_panel import RightPanel +from ui.widgets.settings_dialog import SettingsDialog + +from database.db_manager import DBManager +from watcher.fs_watcher import FolderWatcher +from utils.api_client import ComfyAPIClient +from utils.settings import load_settings, save_settings + +FilePathRole = Qt.ItemDataRole.UserRole + 2 + +logger = logging.getLogger("ComfyGallery.MainWindow") + + +class ComfySendWorker(QThread): + 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}/prompt" + + logger.debug(f"Отправка POST запроса на {url}") + 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"Промт успешно добавлен в очередь генерации ComfyUI!\n\nID задачи: {prompt_id}" + ) + else: + logger.error(f"Сервер ComfyUI отклонил запрос. Код: {response.status_code}, Ответ: {response.text}") + self.finished.emit( + False, + f"Сервер ComfyUI вернул ошибку {response.status_code}:\n{response.text}" + ) + except json.JSONDecodeError as je: + logger.error(f"Не удалось распарсить prompt_json метаданных: {je}") + self.finished.emit(False, f"Ошибка метаданных изображения (неверный формат JSON):\n{je}") + except requests.exceptions.RequestException as re: + logger.error(f"Сетевое исключение при отправке на {self.api_client.base_url}: {re}") + self.finished.emit( + False, + f"Не удалось подключиться к ComfyUI по адресу {self.api_client.base_url}.\n" + f"Убедитесь, что сервер ComfyUI запущен на этом порту.\n\nПодробности:\n{re}" + ) + except Exception as e: + logger.error(f"Непредвиденная ошибка в сетевом воркере: {e}") + self.finished.emit(False, f"Произошла непредвиденная ошибка при отправке: {e}") + + +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"]) + + self.setWindowTitle("ComfyGallery") + self.resize(1300, 850) + self.setStyleSheet(DARK_THEME_QSS) + + self.init_ui() + self.bind_events() + + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + 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("Настройки подключения и папок") + 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([230, 720, 350]) + main_layout.addWidget(self.splitter) + + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + self.status_bar.showMessage("Готов к работе") + + 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.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.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.left_panel.search_changed.connect(self._on_search) + + self.settings_btn.clicked.connect(self._open_settings) + + # Автоматическое реактивное обновление + self.watcher.signals.file_added.connect(self._on_file_added_externally) + self.watcher.signals.file_removed.connect(self._on_file_removed_externally) + + def load_folder_images(self, folder_path: str, search_query: str = ""): + logger.info(f"Загрузка изображений из папки: {folder_path} (фильтр: '{search_query}')") + self.current_root_path = folder_path + self.center_grid.path_label.setText(f"Папка: {folder_path}") + + files = self.db_manager.get_files_in_folder(folder_path, search_query) + self.center_grid.model.set_files(files) + self.status_bar.showMessage(f"Файлов отображено: {len(files)}") + + def _on_subfolder_selected(self, index: QModelIndex): + folder_path = self.left_panel.folder_model.filePath(index) + self.load_folder_images(folder_path) + + def _on_image_selected(self): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: + self.right_panel.clear_fields() + return + + idx = indexes[0] + file_id = self.center_grid.model.data(idx, Qt.ItemDataRole.UserRole + 1) + if file_id: + logger.debug(f"Выделен файл с ID: {file_id}") + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + + def _on_search(self, query: str): + if self.current_root_path: + self.load_folder_images(self.current_root_path, query) + + # --- РЕАКТИВНЫЕ СОБЫТИЯ WATCHDOG --- + + def _on_file_added_externally(self, filepath: str): + logger.debug(f"Событие Watcher: обнаружен новый файл {filepath}") + file_dir = str(Path(filepath).parent.resolve().as_posix()) + current_dir = str(Path(self.current_root_path).resolve().as_posix()) if self.current_root_path else "" + + if file_dir == current_dir: + self.load_folder_images(self.current_root_path) + + def _on_file_removed_externally(self, filepath: str): + logger.debug(f"Событие Watcher: файл удален {filepath}") + file_dir = str(Path(filepath).parent.resolve().as_posix()) + current_dir = str(Path(self.current_root_path).resolve().as_posix()) if self.current_root_path else "" + + if file_dir == current_dir: + self.load_folder_images(self.current_root_path) + + # --- КОНТЕКСТНОЕ МЕНЮ И CRUD --- + + def _on_context_menu(self, position): + indexes = self.center_grid.list_view.selectedIndexes() + if not indexes: return + + menu = QMenu(self) + + # Новый пункт контекстного меню + show_action = menu.addAction("Показать на диске") + show_action.setEnabled(len(indexes) == 1) + + menu.addSeparator() + + rename_action = menu.addAction("Переименовать") + rename_action.setEnabled(len(indexes) == 1) + + delete_action = menu.addAction("Удалить выбранные") + + action = menu.exec(self.center_grid.list_view.mapToGlobal(position)) + + if action == show_action: + idx = indexes[0] + filepath = self.center_grid.model.data(idx, 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, "Переименование", f"Новое имя файла для {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): + """ Открывает Проводник Windows и подсвечивает (выделяет) данный файл. """ + norm_path = os.path.normpath(filepath) + if not os.path.exists(norm_path): + logger.warning(f"Попытка показать несуществующий файл: {norm_path}") + return + + logger.info(f"Открытие проводника для файла: {norm_path}") + try: + # Команда explorer /select открывает папку и фокусом выделяет файл + subprocess.run(f'explorer /select,"{norm_path}"', shell=True) + except Exception as e: + logger.error(f"Не удалось открыть Проводник Windows: {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) + + logger.info(f"Переименование файла: {old_path.name} -> {new_filepath.name}") + try: + os.rename(str(old_path), str(new_filepath)) + self.status_bar.showMessage(f"Файл успешно переименован: {new_filepath.name}") + if self.current_root_path: + self.load_folder_images(self.current_root_path) + except Exception as e: + logger.error(f"Ошибка переименования файла {old_path.name}: {e}") + QMessageBox.critical(self, "Ошибка", f"Не удалось переименовать файл: {e}") + + def _on_copy_file(self, filepath: str): + logger.info(f"Копирование файла в буфер обмена: {filepath}") + if os.path.exists(filepath): + mime_data = QMimeData() + mime_data.setUrls([QUrl.fromLocalFile(filepath)]) + QApplication.clipboard().setMimeData(mime_data) + self.status_bar.showMessage("Изображение скопировано в буфер обмена.") + + def _on_delete_files(self, filepaths: list): + if not filepaths: return + + logger.info(f"Запрос удаления группы файлов: {len(filepaths)} шт.") + confirm = QMessageBox.question( + self, "Удаление", f"Вы действительно хотите безвозвратно удалить {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 + logger.debug(f"Файл успешно удален физически: {path}") + except Exception as e: + logger.error(f"Ошибка физического удаления файла {path}: {e}") + self.status_bar.showMessage(f"Удалено файлов с диска: {deleted_count}") + if self.current_root_path: + self.load_folder_images(self.current_root_path) + + # --- НАСТРОЙКИ --- + + def _open_settings(self): + logger.info("Открытие диалогового окна настроек.") + dialog = SettingsDialog(self.settings, self) + if dialog.exec() == SettingsDialog.DialogCode.Accepted: + save_settings(self.settings) + logger.info("Пользователь сохранил новые настройки.") + + self.api_client = ComfyAPIClient(self.settings["comfyui_url"]) + self.left_panel.set_tracked_folders(self.settings["tracked_paths"]) + + self.status_bar.showMessage("Перезапуск службы отслеживания...") + 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("Настройки успешно применены.") + + # --- ВЗАИМОДЕЙСТВИЕ С COMFYUI --- + + def _on_save_metadata(self, file_id: int, payload: dict): + logger.info(f"Сохранение измененных пользователем метаданных для ID {file_id}") + success = self.db_manager.update_file_details( + file_id, payload["positive_prompt"], payload["negative_prompt"], payload["rating"] + ) + if success: + self.status_bar.showMessage("Параметры успешно обновлены.") + details = self.db_manager.get_file_details(file_id) + self.right_panel.display_metadata(details) + if self.current_root_path: + self.load_folder_images(self.current_root_path) + else: + logger.error(f"Не удалось обновить метаданные в БД для ID {file_id}") + QMessageBox.critical(self, "Ошибка", "Не удалось сохранить изменения в БД.") + + def _on_send_to_comfy(self, prompt_json: str): + self.status_bar.showMessage("Отправка промта в ComfyUI...") + 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("Промт успешно добавлен в очередь ComfyUI!") + QMessageBox.information(self, "Успешно отправлено", message) + else: + self.status_bar.showMessage("Не удалось отправить промт.") + QMessageBox.critical(self, "Ошибка сети / API", message) \ No newline at end of file diff --git a/ui/styles.py b/ui/styles.py new file mode 100644 index 0000000..58d752e --- /dev/null +++ b/ui/styles.py @@ -0,0 +1,96 @@ +# ui/styles.py +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; +} +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; +} +QComboBox { + background-color: #242424; + border: 1px solid #3d3d3d; + border-radius: 4px; + padding: 4px; + color: #ffffff; +} +QScrollBar:vertical { + border: none; + background-color: #121212; + width: 8px; +} +QScrollBar::handle:vertical { + background-color: #3a3a3a; + border-radius: 4px; +} +""" \ No newline at end of file diff --git a/ui/widgets/__pycache__/center_grid.cpython-312.pyc b/ui/widgets/__pycache__/center_grid.cpython-312.pyc new file mode 100644 index 0000000..f85c679 Binary files /dev/null and b/ui/widgets/__pycache__/center_grid.cpython-312.pyc differ diff --git a/ui/widgets/__pycache__/left_panel.cpython-312.pyc b/ui/widgets/__pycache__/left_panel.cpython-312.pyc new file mode 100644 index 0000000..bfeea3d Binary files /dev/null and b/ui/widgets/__pycache__/left_panel.cpython-312.pyc differ diff --git a/ui/widgets/__pycache__/right_panel.cpython-312.pyc b/ui/widgets/__pycache__/right_panel.cpython-312.pyc new file mode 100644 index 0000000..991ddeb Binary files /dev/null and b/ui/widgets/__pycache__/right_panel.cpython-312.pyc differ diff --git a/ui/widgets/__pycache__/settings_dialog.cpython-312.pyc b/ui/widgets/__pycache__/settings_dialog.cpython-312.pyc new file mode 100644 index 0000000..94a5ab7 Binary files /dev/null and b/ui/widgets/__pycache__/settings_dialog.cpython-312.pyc differ diff --git a/ui/widgets/center_grid.py b/ui/widgets/center_grid.py new file mode 100644 index 0000000..2416b8e --- /dev/null +++ b/ui/widgets/center_grid.py @@ -0,0 +1,285 @@ +# ui/widgets/center_grid.py +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 +) + +logger = logging.getLogger("ComfyGallery.CenterGrid") + +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 + +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 = 130 + + 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: + """ Указываем, что модель поддерживает копирование при Drag-and-Drop """ + 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["has_workflow"]) + elif role == RatingRole: + return file_data["rating"] + 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): + if filepath in self.loading_paths: + self.loading_paths.remove(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: "QStyleOptionViewItem", 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) + + has_workflow = index.data(HasWorkflowRole) + if has_workflow: + 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") + + 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) + + 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 + super().keyPressEvent(event) + + def startDrag(self, supportedActions): + """ + Переопределяем метод начала перетаскивания. + Принудительно инициализирует системное событие Drag-and-Drop для внешних окон. + """ + indexes = self.selectedIndexes() + if not indexes: + return + + logger.debug(f"Начало перетаскивания файлов: {len(indexes)} шт.") + drag = QDrag(self) + mime_data = self.model().mimeData(indexes) + drag.setMimeData(mime_data) + + # Установка миниатюры под курсор при перетаскивании + 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("Выберите папку...") + control_layout.addWidget(self.path_label) + control_layout.addStretch() + + control_layout.addWidget(QLabel("Масштаб:")) + self.zoom_slider = QSlider(Qt.Orientation.Horizontal) + self.zoom_slider.setRange(80, 250) + self.zoom_slider.setValue(130) + 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) \ No newline at end of file diff --git a/ui/widgets/left_panel.py b/ui/widgets/left_panel.py new file mode 100644 index 0000000..eb44338 --- /dev/null +++ b/ui/widgets/left_panel.py @@ -0,0 +1,57 @@ +# ui/widgets/left_panel.py +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QLabel, QLineEdit, QTreeView, QComboBox +from PyQt6.QtGui import QFileSystemModel +from PyQt6.QtCore import QDir, pyqtSignal + +class LeftPanel(QWidget): + search_changed = pyqtSignal(str) + root_folder_changed = pyqtSignal(str) # Сигнал переключения активной корневой папки + + 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 = QLineEdit() + self.search_input.setPlaceholderText("Поиск по тегам или промтам...") + self.search_input.textChanged.connect(self.search_changed.emit) + layout.addWidget(self.search_input) + + # Выпадающий список выбора активной корневой директории + layout.addWidget(QLabel("Активная папка:")) + self.folder_selector = QComboBox() + self.folder_selector.currentTextChanged.connect(self._on_folder_changed) + layout.addWidget(self.folder_selector) + + layout.addWidget(QLabel("Дерево подпапок:")) + + 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 _on_folder_changed(self, path: str): + if path: + self.set_root_path(path) + self.root_folder_changed.emit(path) \ No newline at end of file diff --git a/ui/widgets/right_panel.py b/ui/widgets/right_panel.py new file mode 100644 index 0000000..d213ed2 --- /dev/null +++ b/ui/widgets/right_panel.py @@ -0,0 +1,186 @@ +# ui/widgets/right_panel.py +import json +from typing import Optional # <-- Добавлен пропущенный импорт +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTabWidget, + QTextEdit, QLineEdit, QPushButton, QFormLayout, QComboBox, QMessageBox, QApplication +) +from PyQt6.QtGui import QPixmap +from PyQt6.QtCore import Qt, pyqtSignal + +class RightPanel(QWidget): + save_clicked = pyqtSignal(int, dict) + send_to_comfy_clicked = pyqtSignal(str) + + 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("Нет выделенного изображения") + 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.tabs = QTabWidget() + + 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("Модель:", self.model_input) + + self.sampler_input = QLineEdit() + self.sampler_input.setReadOnly(True) + form_layout.addRow("Сэмплер:", 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(["Без рейтинга", "★", "★★", "★★★", "★★★★", "★★★★★"]) + form_layout.addRow("Рейтинг:", self.rating_combo) + gen_layout.addLayout(form_layout) + + gen_layout.addWidget(QLabel("Позитивный промт:")) + self.positive_text = QTextEdit() + gen_layout.addWidget(self.positive_text) + + gen_layout.addWidget(QLabel("Негативный промт:")) + self.negative_text = QTextEdit() + gen_layout.addWidget(self.negative_text) + + self.save_btn = QPushButton("Сохранить изменения") + self.save_btn.clicked.connect(self._on_save_clicked) + gen_layout.addWidget(self.save_btn) + + self.workflow_tab = QWidget() + wf_layout = QVBoxLayout(self.workflow_tab) + wf_layout.setContentsMargins(5, 5, 5, 5) + self.workflow_text = QTextEdit() + self.workflow_text.setReadOnly(True) + wf_layout.addWidget(self.workflow_text) + + self.tabs.addTab(self.gen_tab, "Generation") + self.tabs.addTab(self.workflow_tab, "Workflow") + layout.addWidget(self.tabs) + + export_layout = QHBoxLayout() + self.copy_pos_btn = QPushButton("Коп. Pos") + self.copy_pos_btn.clicked.connect(self._copy_positive) + self.copy_neg_btn = QPushButton("Коп. Neg") + self.copy_neg_btn.clicked.connect(self._copy_negative) + self.copy_wf_btn = QPushButton("Коп. Work") + self.copy_wf_btn.clicked.connect(self._copy_workflow) + 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) + + self.send_btn = QPushButton("Отправить в ComfyUI") + self.send_btn.clicked.connect(self._on_send_clicked) + layout.addWidget(self.send_btn) + + 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("Ошибка рендеринга превью") + + self.model_input.setText(file_details.get("model_name") or "Не указано") + self.sampler_input.setText(file_details.get("sampler") or "Не указано") + 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.positive_text.setPlainText(file_details.get("positive_prompt") or "") + self.negative_text.setPlainText(file_details.get("negative_prompt") or "") + + wf_raw = file_details.get("workflow_json") + if wf_raw: + try: + self.workflow_text.setPlainText(json.dumps(json.loads(wf_raw), indent=2, ensure_ascii=False)) + except Exception: + self.workflow_text.setPlainText(wf_raw) + else: + self.workflow_text.setPlainText("Workflow не найден") + + def clear_fields(self): + self.current_file_id = None + self.current_filepath = None + self.current_raw_prompt = None + self.preview_label.setText("Нет выделенного изображения") + self.preview_label.setPixmap(QPixmap()) + 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.positive_text.clear() + self.negative_text.clear() + self.workflow_text.clear() + + 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, "Ошибка", "У изображения отсутствует prompt-граф.") + return + self.send_to_comfy_clicked.emit(self.current_raw_prompt) + + def _copy_positive(self): + QApplication.clipboard().setText(self.positive_text.toPlainText()) + + def _copy_negative(self): + QApplication.clipboard().setText(self.negative_text.toPlainText()) + + def _copy_workflow(self): + QApplication.clipboard().setText(self.workflow_text.toPlainText()) \ No newline at end of file diff --git a/ui/widgets/settings_dialog.py b/ui/widgets/settings_dialog.py new file mode 100644 index 0000000..cf6b18f --- /dev/null +++ b/ui/widgets/settings_dialog.py @@ -0,0 +1,74 @@ +# ui/widgets/settings_dialog.py +from PyQt6.QtWidgets import ( + QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, + QPushButton, QListWidget, QFileDialog, QMessageBox +) + +class SettingsDialog(QDialog): + def __init__(self, current_settings, parent=None): + super().__init__(parent) + self.settings = current_settings + self.setWindowTitle("Настройки подключения и папок") + self.resize(500, 380) + self.init_ui() + + def init_ui(self): + layout = QVBoxLayout(self) + + layout.addWidget(QLabel("Адрес API ComfyUI:")) + self.url_input = QLineEdit() + self.url_input.setText(self.settings.get("comfyui_url", "http://127.0.0.1:8000")) + layout.addWidget(self.url_input) + + layout.addWidget(QLabel("Отслеживаемые папки генерации:")) + 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("Добавить дополнительную папку") + self.add_path_btn.clicked.connect(self._add_path) + self.remove_path_btn = QPushButton("Удалить выбранную") + 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("Сохранить") + self.save_btn.clicked.connect(self._save) + self.cancel_btn = QPushButton("Отмена") + 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, "Выбрать отслеживаемую папку") + 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): + selected = self.paths_list.selectedItems() + if not selected: return + for s in selected: + self.paths_list.takeItem(self.paths_list.row(s)) + + def _save(self): + url = self.url_input.text().strip() + if not url: + QMessageBox.warning(self, "Ошибка", "Адрес API не может быть пустым.") + return + + paths = [self.paths_list.item(i).text() for i in range(self.paths_list.count())] + if not paths: + QMessageBox.warning(self, "Ошибка", "Должна быть добавлена хотя бы одна папка.") + return + + self.settings["comfyui_url"] = url + self.settings["tracked_paths"] = paths + self.accept() \ No newline at end of file diff --git a/utils/__pycache__/api_client.cpython-312.pyc b/utils/__pycache__/api_client.cpython-312.pyc new file mode 100644 index 0000000..d9bef85 Binary files /dev/null and b/utils/__pycache__/api_client.cpython-312.pyc differ diff --git a/utils/__pycache__/logger.cpython-312.pyc b/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000..3f76d0a Binary files /dev/null and b/utils/__pycache__/logger.cpython-312.pyc differ diff --git a/utils/__pycache__/settings.cpython-312.pyc b/utils/__pycache__/settings.cpython-312.pyc new file mode 100644 index 0000000..7c47742 Binary files /dev/null and b/utils/__pycache__/settings.cpython-312.pyc differ diff --git a/utils/api_client.py b/utils/api_client.py new file mode 100644 index 0000000..64584ef --- /dev/null +++ b/utils/api_client.py @@ -0,0 +1,27 @@ +# utils/api_client.py +import requests +import json +import logging + +logger = logging.getLogger("ComfyGallery.API") + +class ComfyAPIClient: + def __init__(self, base_url: str = "http://127.0.0.1:8000"): + self.base_url = base_url.rstrip("/") + + def send_prompt(self, prompt_json_str: str) -> bool: + if not prompt_json_str: + return False + try: + prompt_data = json.loads(prompt_json_str) + payload = {"prompt": prompt_data} + url = f"{self.base_url}/prompt" + response = requests.post(url, json=payload, timeout=4) + if response.status_code == 200: + return True + else: + logger.error(f"Ошибка API ComfyUI: {response.status_code} - {response.text}") + return False + except Exception as e: + logger.error(f"Не удалось подключиться к ComfyUI по адресу {self.base_url}: {e}") + return False \ No newline at end of file diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..ea78291 --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,33 @@ +# utils/logger.py +import logging +import sys +from pathlib import Path +from typing import Optional + +def setup_logger(log_file: Optional[Path] = None) -> logging.Logger: + """ Настраивает логирование работы приложения в консоль и в файл comfygallery.log """ + logger = logging.getLogger("ComfyGallery") + logger.setLevel(logging.DEBUG) + + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + + # Вывод в консоль + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(formatter) + logger.addHandler(console_handler) + + # Вывод в файл + if log_file is None: + log_file = Path("comfygallery.log") + + try: + log_file.parent.mkdir(parents=True, exist_ok=True) + file_handler = logging.FileHandler(log_file, encoding='utf-8') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + except Exception as e: + logger.error(f"Не удалось инициализировать файловый логгер: {e}") + + return logger + +logger = setup_logger() \ No newline at end of file diff --git a/utils/settings.py b/utils/settings.py new file mode 100644 index 0000000..62d9c0c --- /dev/null +++ b/utils/settings.py @@ -0,0 +1,35 @@ +# utils/settings.py +import json +import os +from typing import List, Dict, Any + +SETTINGS_FILE = "settings.json" + +DEFAULT_SETTINGS = { + "comfyui_url": "http://127.0.0.1:8000", # Новый порт по умолчанию + "tracked_paths": [os.path.abspath("./test_output")] +} + +def load_settings() -> Dict[str, Any]: + """ Загружает настройки из файла settings.json. """ + if not os.path.exists(SETTINGS_FILE): + save_settings(DEFAULT_SETTINGS) + return DEFAULT_SETTINGS + try: + with open(SETTINGS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + # Заполняем пропущенные ключи значениями по умолчанию + for k, v in DEFAULT_SETTINGS.items(): + if k not in data: + data[k] = v + return data + except Exception: + return DEFAULT_SETTINGS + +def save_settings(settings: Dict[str, Any]): + """ Сохраняет настройки в файл settings.json. """ + try: + with open(SETTINGS_FILE, "w", encoding="utf-8") as f: + json.dump(settings, f, indent=4, ensure_ascii=False) + except Exception as e: + print(f"Ошибка сохранения настроек: {e}") \ No newline at end of file diff --git a/watcher/__pycache__/fs_watcher.cpython-312.pyc b/watcher/__pycache__/fs_watcher.cpython-312.pyc new file mode 100644 index 0000000..fa74470 Binary files /dev/null and b/watcher/__pycache__/fs_watcher.cpython-312.pyc differ diff --git a/watcher/fs_watcher.py b/watcher/fs_watcher.py new file mode 100644 index 0000000..2a579b3 --- /dev/null +++ b/watcher/fs_watcher.py @@ -0,0 +1,142 @@ +# watcher/fs_watcher.py +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 database.db_manager import DBManager +from metadata.parser import MetadataParser + +logger = logging.getLogger("ComfyGallery.Watcher") + +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 + self._supported_extensions = ('.png', '.jpg', '.jpeg', '.webp') + + 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 self._supported_extensions: + return + + # Пауза, гарантирующая полное высвобождение файлового дескриптора при записи из ComfyUI + time.sleep(0.3) + try: + stat = path.stat() + size = stat.st_size + mtime = stat.st_mtime + parent_path = path.parent.resolve() + folder_id = self._get_or_create_folder_id(parent_path) + if folder_id is not None: + file_id = self.db_manager.register_file( + folder_id=folder_id, filename=path.name, + filepath=str(path.as_posix()), size=size, mtime=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) + # Мгновенная реактивная отправка сигнала о добавлении файла в GUI + 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()) + if not normalized_path.startswith(str(self.root_path.as_posix())): + return None + parent_path = folder_path.parent + parent_id = None + if parent_path != folder_path and normalized_path != str(self.root_path.as_posix()): + 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 = [] \ No newline at end of file