Initial commit
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/` - обработка метаданных изображений
|
||||
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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);
|
||||
"""
|
||||
@@ -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()
|
||||
Binary file not shown.
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"comfyui_url": "http://192.168.1.151:8000",
|
||||
"tracked_paths": [
|
||||
"C:\\Users\\dimir\\proects\\ComfyGallery\\test_output"
|
||||
]
|
||||
}
|
||||
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
Binary file not shown.
@@ -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 = []
|
||||
Reference in New Issue
Block a user