Phase 4 structural refactoring: - Split monolithic py/api.py (5.3k lines) into domain mixins: prompts.py, images.py, admin.py, logging_routes.py, autotag_routes.py - Extract inline JS from admin.html into web/js/admin.js - Extract inline JS from gallery.html into web/js/gallery.js - Add gzip compression middleware to API responses (Phase 5.4) - Standardize API error envelope with success: false (Phase 5.3) - Add filmstrip image enrichment to prompt list responses (Phase 5.2)
736 lines
28 KiB
Python
736 lines
28 KiB
Python
"""REST API module for ComfyUI PromptManager.
|
|
|
|
Split into domain-specific mixins for maintainability:
|
|
- PromptRoutesMixin: prompt CRUD, tags, categories, bulk ops, export
|
|
- ImageRoutesMixin: gallery, thumbnails, image serving and linking
|
|
- AdminRoutesMixin: duplicates, stats, settings, diagnostics, maintenance, backup, scan
|
|
- LoggingRoutesMixin: log management endpoints
|
|
- AutotagRoutesMixin: auto-tagging model management and tagging
|
|
"""
|
|
|
|
import asyncio
|
|
import datetime
|
|
import functools
|
|
import gzip as gzip_module
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from aiohttp import web
|
|
from PIL import Image
|
|
|
|
from .prompts import PromptRoutesMixin
|
|
from .images import ImageRoutesMixin
|
|
from .admin import AdminRoutesMixin
|
|
from .logging_routes import LoggingRoutesMixin
|
|
from .autotag_routes import AutotagRoutesMixin
|
|
|
|
try:
|
|
from ...database.operations import PromptDatabase
|
|
from ...utils.logging_config import get_logger
|
|
except ImportError:
|
|
import sys
|
|
|
|
sys.path.insert(
|
|
0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
)
|
|
from database.operations import PromptDatabase
|
|
from utils.logging_config import get_logger
|
|
|
|
|
|
def _get_project_root():
|
|
"""Get the project root directory (3 levels up from py/api/__init__.py)."""
|
|
return os.path.dirname(
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
)
|
|
|
|
|
|
# ── Gzip compression middleware ────────────────────────────────────────
|
|
_GZIP_MIN_SIZE = 1024 # Only compress bodies larger than 1 KB
|
|
_GZIP_TYPES = frozenset((
|
|
'application/json',
|
|
'text/html',
|
|
'text/css',
|
|
'application/javascript',
|
|
'text/plain',
|
|
))
|
|
_gzip_registered = False
|
|
|
|
|
|
@web.middleware
|
|
async def _gzip_middleware(request, handler):
|
|
"""Compress PromptManager responses when the client accepts gzip."""
|
|
response = await handler(request)
|
|
|
|
if not request.path.startswith('/prompt_manager/'):
|
|
return response
|
|
|
|
# Only handle regular Response objects (not StreamResponse/WebSocket)
|
|
if not isinstance(response, web.Response):
|
|
return response
|
|
|
|
if 'gzip' not in request.headers.get('Accept-Encoding', ''):
|
|
return response
|
|
|
|
if 'Content-Encoding' in response.headers:
|
|
return response
|
|
|
|
body = response.body
|
|
if body is None or len(body) < _GZIP_MIN_SIZE:
|
|
return response
|
|
|
|
content_type = response.content_type or ''
|
|
if not any(ct in content_type for ct in _GZIP_TYPES):
|
|
return response
|
|
|
|
compressed = gzip_module.compress(body, compresslevel=6)
|
|
if len(compressed) >= len(body):
|
|
return response
|
|
|
|
response.body = compressed
|
|
response.headers['Content-Encoding'] = 'gzip'
|
|
response.headers['Vary'] = 'Accept-Encoding'
|
|
return response
|
|
|
|
|
|
class PromptManagerAPI(
|
|
PromptRoutesMixin,
|
|
ImageRoutesMixin,
|
|
AdminRoutesMixin,
|
|
LoggingRoutesMixin,
|
|
AutotagRoutesMixin,
|
|
):
|
|
"""REST API handler for PromptManager operations and web interface.
|
|
|
|
This class provides comprehensive REST API endpoints for managing prompts,
|
|
images, and system operations. It handles database interactions, file
|
|
operations, image processing, and web UI serving.
|
|
|
|
The API is designed to integrate seamlessly with ComfyUI's aiohttp server
|
|
and provides both JSON API endpoints and static file serving for the
|
|
web interface.
|
|
|
|
Attributes:
|
|
logger: Configured logger instance for API operations
|
|
db (PromptDatabase): Database connection and operations handler
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the PromptManager API with database connection and cleanup."""
|
|
self.logger = get_logger('prompt_manager.api')
|
|
self.logger.info("Initializing PromptManager API")
|
|
|
|
self.db = PromptDatabase()
|
|
self._cached_output_dir = None # Lazy-cached by _find_comfyui_output_dir()
|
|
self._html_cache = {} # Cached HTML file contents keyed by path
|
|
self._gallery_cache = None # Cached gallery file listing (Fix 2.4)
|
|
self._gallery_cache_time = 0 # Timestamp of last cache fill
|
|
self._gallery_cache_ttl = 30 # Cache TTL in seconds
|
|
|
|
# Run cleanup on initialization to remove any existing duplicates
|
|
try:
|
|
removed = self.db.cleanup_duplicates()
|
|
if removed > 0:
|
|
self.logger.info(f"Startup cleanup: removed {removed} duplicate prompts")
|
|
except Exception as e:
|
|
self.logger.error(f"Startup cleanup failed: {e}")
|
|
|
|
self.logger.info("PromptManager API initialization completed")
|
|
|
|
async def _run_in_executor(self, func, *args, **kwargs):
|
|
"""Run a blocking function in the default thread pool executor.
|
|
|
|
Prevents synchronous database calls, file I/O, and PIL operations
|
|
from blocking the aiohttp event loop.
|
|
"""
|
|
loop = asyncio.get_event_loop()
|
|
if kwargs:
|
|
call = functools.partial(func, *args, **kwargs)
|
|
return await loop.run_in_executor(None, call)
|
|
return await loop.run_in_executor(None, func, *args)
|
|
|
|
def invalidate_gallery_cache(self):
|
|
"""Invalidate the cached gallery file listing.
|
|
|
|
Called by the image monitor when new files are detected.
|
|
"""
|
|
self._gallery_cache = None
|
|
self._gallery_cache_time = 0
|
|
|
|
def add_routes(self, routes):
|
|
"""Register all API routes with the ComfyUI server.
|
|
|
|
Args:
|
|
routes: aiohttp RouteTableDef object from ComfyUI server instance.
|
|
"""
|
|
|
|
# Test route to verify registration works
|
|
@routes.get("/prompt_manager/test")
|
|
async def test_route(request):
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"message": "PromptManager API is working!",
|
|
"timestamp": str(datetime.datetime.now()),
|
|
}
|
|
)
|
|
|
|
# ── Web UI serving routes ─────────────────────────────────────
|
|
|
|
@routes.get("/prompt_manager/web")
|
|
async def serve_web_ui(request):
|
|
try:
|
|
html_path = os.path.join(_get_project_root(), "web", "index.html")
|
|
|
|
if os.path.exists(html_path):
|
|
with open(html_path, "r", encoding="utf-8") as f:
|
|
html_content = f.read()
|
|
|
|
return web.Response(
|
|
text=html_content, content_type="text/html", charset="utf-8"
|
|
)
|
|
else:
|
|
return web.Response(
|
|
text="<h1>Web UI not found</h1><p>HTML file not located at expected path.</p>",
|
|
content_type="text/html",
|
|
status=404,
|
|
)
|
|
|
|
except Exception as e:
|
|
return web.Response(
|
|
text=f"<h1>Error</h1><p>Failed to load web UI: {str(e)}</p>",
|
|
content_type="text/html",
|
|
status=500,
|
|
)
|
|
|
|
@routes.get("/prompt_manager/gallery.html")
|
|
async def serve_gallery_ui(request):
|
|
try:
|
|
html_path = os.path.join(
|
|
_get_project_root(), "web", "metadata.html",
|
|
)
|
|
|
|
if html_path not in self._html_cache:
|
|
if os.path.exists(html_path):
|
|
with open(html_path, "r", encoding="utf-8") as f:
|
|
self._html_cache[html_path] = f.read()
|
|
else:
|
|
return web.Response(
|
|
text="<h1>Gallery not found</h1><p>gallery.html file not located at expected path.</p>",
|
|
content_type="text/html",
|
|
status=404,
|
|
)
|
|
|
|
return web.Response(
|
|
text=self._html_cache[html_path], content_type="text/html", charset="utf-8"
|
|
)
|
|
|
|
except Exception as e:
|
|
return web.Response(
|
|
text=f"<h1>Error</h1><p>Failed to load gallery: {str(e)}</p>",
|
|
content_type="text/html",
|
|
status=500,
|
|
)
|
|
|
|
@routes.get("/prompt_manager/admin")
|
|
async def serve_admin_ui(request):
|
|
try:
|
|
html_path = os.path.join(
|
|
_get_project_root(), "web", "admin.html",
|
|
)
|
|
|
|
if html_path not in self._html_cache:
|
|
if os.path.exists(html_path):
|
|
with open(html_path, "r", encoding="utf-8") as f:
|
|
self._html_cache[html_path] = f.read()
|
|
else:
|
|
return web.Response(
|
|
text="<h1>Admin UI not found</h1>",
|
|
content_type="text/html",
|
|
status=404,
|
|
)
|
|
|
|
return web.Response(
|
|
text=self._html_cache[html_path], content_type="text/html", charset="utf-8"
|
|
)
|
|
|
|
except Exception as e:
|
|
return web.Response(
|
|
text=f"<h1>Error</h1><p>Failed to load admin UI: {str(e)}</p>",
|
|
content_type="text/html",
|
|
status=500,
|
|
)
|
|
|
|
@routes.get("/prompt_manager/gallery")
|
|
async def serve_gallery_admin_ui(request):
|
|
try:
|
|
html_path = os.path.join(
|
|
_get_project_root(), "web", "gallery.html",
|
|
)
|
|
|
|
if html_path not in self._html_cache:
|
|
if os.path.exists(html_path):
|
|
with open(html_path, "r", encoding="utf-8") as f:
|
|
self._html_cache[html_path] = f.read()
|
|
else:
|
|
return web.Response(
|
|
text="<h1>Gallery not found</h1><p>gallery.html file not located at expected path.</p>",
|
|
content_type="text/html",
|
|
status=404,
|
|
)
|
|
|
|
return web.Response(
|
|
text=self._html_cache[html_path], content_type="text/html", charset="utf-8"
|
|
)
|
|
|
|
except Exception as e:
|
|
return web.Response(
|
|
text=f"<h1>Error</h1><p>Failed to load gallery: {str(e)}</p>",
|
|
content_type="text/html",
|
|
status=500,
|
|
)
|
|
|
|
# ── Static file serving ───────────────────────────────────────
|
|
|
|
@routes.get("/prompt_manager/lib/{filepath:.*}")
|
|
async def serve_lib_static(request):
|
|
"""Serve static library files (JS, CSS) from web/lib directory."""
|
|
MIME_TYPES = {
|
|
".js": "application/javascript",
|
|
".css": "text/css",
|
|
".json": "application/json",
|
|
".map": "application/json",
|
|
}
|
|
|
|
filepath = request.match_info.get("filepath", "")
|
|
|
|
# Security: prevent directory traversal
|
|
if ".." in filepath or filepath.startswith("/"):
|
|
return web.Response(text="Forbidden", status=403)
|
|
|
|
file_path = os.path.join(_get_project_root(), "web", "lib", filepath)
|
|
|
|
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
return web.Response(text=f"Not Found: {filepath}", status=404)
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
content_type = MIME_TYPES.get(ext, "application/octet-stream")
|
|
|
|
with open(file_path, "rb") as f:
|
|
content = f.read()
|
|
|
|
return web.Response(body=content, content_type=content_type)
|
|
|
|
@routes.get("/prompt_manager/js/{filepath:.*}")
|
|
async def serve_js_static(request):
|
|
"""Serve static JavaScript files from web/js directory."""
|
|
MIME_TYPES = {
|
|
".js": "application/javascript",
|
|
".css": "text/css",
|
|
".json": "application/json",
|
|
".map": "application/json",
|
|
}
|
|
|
|
filepath = request.match_info.get("filepath", "")
|
|
|
|
if ".." in filepath or filepath.startswith("/"):
|
|
return web.Response(text="Forbidden", status=403)
|
|
|
|
file_path = os.path.join(_get_project_root(), "web", "js", filepath)
|
|
|
|
if not os.path.exists(file_path) or not os.path.isfile(file_path):
|
|
return web.Response(text=f"Not Found: {filepath}", status=404)
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
content_type = MIME_TYPES.get(ext, "application/octet-stream")
|
|
|
|
with open(file_path, "rb") as f:
|
|
content = f.read()
|
|
|
|
return web.Response(body=content, content_type=content_type)
|
|
|
|
# ── Register domain-specific routes from mixins ───────────────
|
|
|
|
self._register_prompt_routes(routes)
|
|
self._register_image_routes(routes)
|
|
self._register_admin_routes(routes)
|
|
self._register_logging_routes(routes)
|
|
self._register_autotag_routes(routes)
|
|
|
|
# Register gzip compression middleware (once)
|
|
global _gzip_registered
|
|
if not _gzip_registered:
|
|
try:
|
|
from ..config import server_instance
|
|
server_instance.app.middlewares.append(_gzip_middleware)
|
|
_gzip_registered = True
|
|
self.logger.info("Gzip compression middleware registered")
|
|
except Exception as e:
|
|
self.logger.warning(f"Could not register gzip middleware: {e}")
|
|
|
|
self.logger.info("All routes registered with decorator pattern")
|
|
|
|
# ── Shared utilities used by multiple mixins ──────────────────────
|
|
|
|
def _enrich_prompt_images(self, prompts):
|
|
"""Add url and thumbnail_url to each image in prompt results."""
|
|
from urllib.parse import quote as url_quote
|
|
|
|
output_dir = self._find_comfyui_output_dir()
|
|
output_path = Path(output_dir) if output_dir else None
|
|
|
|
for prompt in prompts:
|
|
for image in prompt.get('images', []):
|
|
image_path_str = image.get('image_path', '')
|
|
if not image_path_str:
|
|
continue
|
|
|
|
img_path = Path(image_path_str)
|
|
|
|
# Set fallback url via image ID
|
|
if image.get('id'):
|
|
image['url'] = f"/prompt_manager/images/{image['id']}/file"
|
|
|
|
# Try to compute relative path and thumbnail URL
|
|
if output_path:
|
|
try:
|
|
rel_path = img_path.resolve().relative_to(output_path.resolve())
|
|
image['relative_path'] = str(rel_path)
|
|
image['url'] = f"/prompt_manager/images/serve/{url_quote(rel_path.as_posix(), safe='/')}"
|
|
|
|
# Check for thumbnail
|
|
rel_no_ext = rel_path.with_suffix('')
|
|
thumb_rel = f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
|
thumb_abs = output_path / thumb_rel
|
|
if thumb_abs.exists():
|
|
image['thumbnail_url'] = f"/prompt_manager/images/serve/{url_quote(thumb_rel, safe='/')}"
|
|
except (ValueError, RuntimeError):
|
|
pass
|
|
|
|
return prompts
|
|
|
|
def _clean_nan_recursive(self, obj):
|
|
"""Recursively clean NaN values from nested data structures."""
|
|
if isinstance(obj, dict):
|
|
return {key: self._clean_nan_recursive(value) for key, value in obj.items()}
|
|
elif isinstance(obj, list):
|
|
return [self._clean_nan_recursive(item) for item in obj]
|
|
elif isinstance(obj, float) and str(obj) == 'nan':
|
|
return None
|
|
else:
|
|
return obj
|
|
|
|
def _find_comfyui_output_dir(self):
|
|
"""Locate the ComfyUI output directory using multiple detection strategies.
|
|
|
|
Results are cached after first successful lookup since the output
|
|
directory does not change during runtime.
|
|
|
|
Returns:
|
|
str or None: Absolute path to ComfyUI output directory, or None
|
|
if no valid directory is found
|
|
"""
|
|
if self._cached_output_dir is not None:
|
|
return self._cached_output_dir
|
|
|
|
current_file = Path(__file__).resolve()
|
|
self.logger.debug(f"Starting ComfyUI output search from: {current_file}")
|
|
|
|
# Method 1: Search upward from current file location
|
|
current_dir = current_file.parent
|
|
max_depth = 10 # Prevent infinite loops
|
|
|
|
for i in range(max_depth):
|
|
# Check if current directory contains ComfyUI markers
|
|
comfyui_markers = ['main.py', 'nodes.py', 'server.py']
|
|
if any((current_dir / marker).exists() for marker in comfyui_markers):
|
|
output_dir = current_dir / "output"
|
|
if output_dir.exists() and output_dir.is_dir():
|
|
self.logger.debug(f"Found ComfyUI output directory via upward search: {output_dir}")
|
|
self._cached_output_dir = str(output_dir)
|
|
return self._cached_output_dir
|
|
|
|
# Move up one directory
|
|
parent = current_dir.parent
|
|
if parent == current_dir: # Reached filesystem root
|
|
break
|
|
current_dir = parent
|
|
|
|
# Method 2: Try common installation patterns relative to this file
|
|
# File is at: custom_nodes/ComfyUI_PromptManager/py/api/__init__.py
|
|
base_dir = current_file.parent # .../py/api/
|
|
possible_paths = [
|
|
base_dir.parent.parent.parent.parent / "output", # ../../../../output
|
|
base_dir.parent.parent.parent / "output", # ../../../output
|
|
base_dir.parent.parent / "output", # ../../output
|
|
base_dir.parent / "output", # ../output
|
|
]
|
|
|
|
# Method 3: Add common ComfyUI installation locations
|
|
common_locations = [
|
|
Path.home() / "ComfyUI" / "output",
|
|
Path.cwd() / "output",
|
|
Path.cwd() / ".." / "output",
|
|
Path.cwd() / ".." / ".." / "output",
|
|
]
|
|
|
|
all_paths = possible_paths + common_locations
|
|
|
|
for path in all_paths:
|
|
try:
|
|
abs_path = path.resolve()
|
|
if abs_path.exists() and abs_path.is_dir():
|
|
self.logger.debug(f"Found ComfyUI output directory: {abs_path}")
|
|
self._cached_output_dir = str(abs_path)
|
|
return self._cached_output_dir
|
|
except (OSError, RuntimeError):
|
|
continue # Skip invalid paths
|
|
|
|
self.logger.warning("ComfyUI output directory not found. Searched paths:")
|
|
for path in all_paths:
|
|
try:
|
|
self.logger.warning(f" - {path.resolve()} (exists: {path.exists()})")
|
|
except (OSError, RuntimeError):
|
|
self.logger.warning(f" - {path} (invalid path)")
|
|
|
|
return None
|
|
|
|
def _extract_comfyui_metadata(self, image_path):
|
|
"""Extract ComfyUI workflow metadata from PNG image files."""
|
|
try:
|
|
with Image.open(image_path) as img:
|
|
metadata = {}
|
|
if hasattr(img, 'text'):
|
|
for key, value in img.text.items():
|
|
metadata[key] = value
|
|
return metadata
|
|
except Exception as e:
|
|
self.logger.error(f"Error reading {image_path}: {e}")
|
|
return {}
|
|
|
|
def _parse_comfyui_prompt(self, metadata):
|
|
"""Parse ComfyUI and A1111 prompt data from metadata."""
|
|
result = {
|
|
'prompt': None,
|
|
'workflow': None,
|
|
'parameters': {},
|
|
'positive_prompt': None,
|
|
'negative_prompt': None
|
|
}
|
|
|
|
# Check for A1111 style parameters first (like parse-metadata.py)
|
|
if "parameters" in metadata:
|
|
params = metadata["parameters"]
|
|
lines = params.splitlines()
|
|
if lines:
|
|
result['positive_prompt'] = lines[0].strip()
|
|
for line in lines:
|
|
if line.lower().startswith("negative prompt:"):
|
|
result['negative_prompt'] = line.split(":", 1)[1].strip()
|
|
break
|
|
# Store raw parameters too
|
|
result['parameters']['parameters'] = params
|
|
|
|
# If no A1111 format found, proceed with ComfyUI parsing
|
|
if result['positive_prompt'] is None:
|
|
# Check for direct prompt field
|
|
if 'prompt' in metadata:
|
|
try:
|
|
prompt_data = json.loads(metadata['prompt'])
|
|
result['prompt'] = prompt_data
|
|
except json.JSONDecodeError:
|
|
result['prompt'] = metadata['prompt']
|
|
|
|
# Check for workflow
|
|
if 'workflow' in metadata:
|
|
try:
|
|
workflow_data = json.loads(metadata['workflow'])
|
|
result['workflow'] = workflow_data
|
|
except json.JSONDecodeError:
|
|
result['workflow'] = metadata['workflow']
|
|
|
|
# Check for other common ComfyUI fields
|
|
common_fields = ['positive', 'negative', 'steps', 'cfg', 'sampler', 'scheduler', 'seed']
|
|
for field in common_fields:
|
|
if field in metadata:
|
|
try:
|
|
result['parameters'][field] = json.loads(metadata[field])
|
|
except json.JSONDecodeError:
|
|
result['parameters'][field] = metadata[field]
|
|
|
|
return result
|
|
|
|
def _extract_readable_prompt(self, parsed_data):
|
|
"""Extract human-readable prompt text from ComfyUI/A1111 data."""
|
|
|
|
def safe_to_string(value):
|
|
if isinstance(value, str):
|
|
return value
|
|
elif isinstance(value, list):
|
|
return ' '.join(str(item) for item in value if item)
|
|
elif value is not None:
|
|
return str(value)
|
|
return None
|
|
|
|
# First check if we already extracted a positive prompt (A1111 format)
|
|
if parsed_data.get('positive_prompt'):
|
|
return parsed_data['positive_prompt']
|
|
|
|
# Check if prompt is already a string
|
|
if isinstance(parsed_data.get('prompt'), str):
|
|
return parsed_data['prompt']
|
|
|
|
# Check if prompt is a simple value that can be converted
|
|
if parsed_data.get('prompt') and not isinstance(parsed_data.get('prompt'), dict):
|
|
return safe_to_string(parsed_data['prompt'])
|
|
|
|
prompt_data = parsed_data.get('prompt')
|
|
if isinstance(prompt_data, dict):
|
|
# Use the enhanced logic from parse-metadata.py
|
|
positive_prompt = self._extract_positive_prompt_from_comfyui_data(prompt_data)
|
|
if positive_prompt:
|
|
return positive_prompt
|
|
|
|
# Check workflow data if available
|
|
workflow_data = parsed_data.get('workflow')
|
|
if isinstance(workflow_data, dict):
|
|
positive_prompt = self._extract_positive_prompt_from_comfyui_data(workflow_data)
|
|
if positive_prompt:
|
|
return positive_prompt
|
|
|
|
# Check parameters for positive prompt
|
|
if parsed_data.get('parameters', {}).get('positive'):
|
|
return safe_to_string(parsed_data['parameters']['positive'])
|
|
|
|
return None
|
|
|
|
def _get_node_inputs(self, node):
|
|
"""Safely get inputs from a node, handling both dict and list formats.
|
|
|
|
Old format: inputs is a dict with direct key-value pairs
|
|
inputs = {"text": "my prompt", "seed": 123}
|
|
|
|
New format: inputs is a list of connection objects
|
|
inputs = [
|
|
{"name": "text", "type": "STRING", "link": null, "widget": {"name": "text"}},
|
|
{"name": "clip", "type": "CLIP", "link": 11}
|
|
]
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return {}
|
|
|
|
inputs = node.get("inputs", {})
|
|
|
|
# If inputs is already a dict, return it
|
|
if isinstance(inputs, dict):
|
|
return inputs
|
|
|
|
# If inputs is a list, convert to dict format
|
|
if isinstance(inputs, list):
|
|
inputs_dict = {}
|
|
for input_item in inputs:
|
|
if isinstance(input_item, dict) and "name" in input_item:
|
|
name = input_item["name"]
|
|
inputs_dict[name] = input_item
|
|
return inputs_dict
|
|
|
|
return {}
|
|
|
|
def _find_text_in_node(self, node):
|
|
"""Try to find text content in a node using various strategies.
|
|
|
|
Handles both old and new workflow formats.
|
|
"""
|
|
if not isinstance(node, dict):
|
|
return None
|
|
|
|
# Strategy 1: Check normalized inputs for 'text' field
|
|
inputs = self._get_node_inputs(node)
|
|
if "text" in inputs and isinstance(inputs["text"], str):
|
|
return inputs["text"]
|
|
|
|
# Strategy 2: For text encoder nodes, check widgets_values
|
|
class_type = node.get("class_type", node.get("type", ""))
|
|
text_encoder_types = [
|
|
'CLIPTextEncode', 'CLIPTextEncodeSDXL', 'CLIPTextEncodeSDXLRefiner',
|
|
'CLIPTextEncodeFlux', 'PromptManager', 'PromptManagerText',
|
|
'BNK_CLIPTextEncoder', 'Text Encoder', 'CLIP Text Encode'
|
|
]
|
|
|
|
if any(encoder_type.lower() in class_type.lower() for encoder_type in text_encoder_types):
|
|
widgets_values = node.get("widgets_values", [])
|
|
if widgets_values and len(widgets_values) > 0:
|
|
if isinstance(widgets_values[0], str) and widgets_values[0].strip():
|
|
return widgets_values[0]
|
|
|
|
return None
|
|
|
|
def _extract_positive_prompt_from_comfyui_data(self, data):
|
|
"""Extract positive prompt from ComfyUI data, handling both old and new formats."""
|
|
if not isinstance(data, dict):
|
|
return None
|
|
|
|
# Build nodes dictionary
|
|
nodes_by_id = {}
|
|
if "nodes" in data:
|
|
# Handle nodes array format
|
|
for node in data["nodes"]:
|
|
if isinstance(node, dict):
|
|
nid = node.get("id")
|
|
if nid is not None:
|
|
nodes_by_id[nid] = node
|
|
else:
|
|
# Handle flat dictionary format (node_id -> node_data)
|
|
for nid_str, node in data.items():
|
|
try:
|
|
nid = int(nid_str)
|
|
except (ValueError, TypeError):
|
|
nid = nid_str
|
|
if isinstance(node, dict):
|
|
if "id" in node:
|
|
nid = node["id"]
|
|
nodes_by_id[nid] = node
|
|
|
|
if not nodes_by_id:
|
|
return None
|
|
|
|
# First, try to find positive/negative connection pattern
|
|
pos_id = None
|
|
for node in nodes_by_id.values():
|
|
if isinstance(node, dict):
|
|
inputs = self._get_node_inputs(node)
|
|
if "positive" in inputs and "negative" in inputs:
|
|
try:
|
|
pos_input = inputs["positive"]
|
|
if isinstance(pos_input, list) and len(pos_input) > 0:
|
|
pos_id = int(pos_input[0])
|
|
break
|
|
except (ValueError, TypeError, IndexError):
|
|
continue
|
|
|
|
# Get text from the positive node
|
|
if pos_id is not None and pos_id in nodes_by_id:
|
|
text_val = self._find_text_in_node(nodes_by_id[pos_id])
|
|
if text_val:
|
|
return text_val
|
|
|
|
# Fallback: find any text encoder node with text content
|
|
text_nodes = []
|
|
for node in nodes_by_id.values():
|
|
if isinstance(node, dict):
|
|
text_val = self._find_text_in_node(node)
|
|
if text_val:
|
|
# Try to determine if this is positive or negative
|
|
node_title = node.get('title', '').lower()
|
|
if 'neg' not in node_title and 'negative' not in node_title:
|
|
# Prioritize non-negative prompts
|
|
text_nodes.insert(0, text_val)
|
|
else:
|
|
text_nodes.append(text_val)
|
|
|
|
# Return the first positive-looking prompt
|
|
if text_nodes:
|
|
return text_nodes[0]
|
|
|
|
return None
|