diff --git a/database/operations.py b/database/operations.py index 28f6004..f98a3b4 100644 --- a/database/operations.py +++ b/database/operations.py @@ -552,6 +552,7 @@ class PromptDatabase: old_name = old_name.strip() new_name = new_name.strip() affected = 0 + skipped = 0 with self.model.get_connection() as conn: cursor = conn.execute( @@ -571,12 +572,14 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag rename: {e}") continue conn.commit() - self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts") - return {'success': True, 'affected_count': affected} + self.logger.info(f"Renamed tag '{old_name}' -> '{new_name}' in {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'skipped_count': skipped} def delete_tag_all_prompts(self, tag_name: str) -> Dict[str, Any]: """ @@ -593,6 +596,7 @@ class PromptDatabase: tag_name = tag_name.strip() affected = 0 + skipped = 0 with self.model.get_connection() as conn: cursor = conn.execute( @@ -610,12 +614,14 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag delete: {e}") continue conn.commit() - self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts") - return {'success': True, 'affected_count': affected} + self.logger.info(f"Deleted tag '{tag_name}' from {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'skipped_count': skipped} def merge_tags(self, source_tags: List[str], target_tag: str) -> Dict[str, Any]: """ @@ -637,6 +643,7 @@ class PromptDatabase: source_tags = [t.strip() for t in source_tags if t.strip()] affected = 0 tags_merged = 0 + skipped = 0 with self.model.get_connection() as conn: for src_tag in source_tags: @@ -658,15 +665,17 @@ class PromptDatabase: (json.dumps(tags), datetime.datetime.now(datetime.timezone.utc).isoformat(), row['id']) ) src_affected += 1 - except (json.JSONDecodeError, TypeError): + except (json.JSONDecodeError, TypeError) as e: + skipped += 1 + self.logger.warning(f"Skipped prompt {row['id']} during tag merge: {e}") continue if src_affected > 0: tags_merged += 1 affected += src_affected conn.commit() - self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts") - return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged} + self.logger.info(f"Merged {tags_merged} tags into '{target_tag}', affected {affected} prompts (skipped {skipped})") + return {'success': True, 'affected_count': affected, 'tags_merged': tags_merged, 'skipped_count': skipped} def get_untagged_prompts_count(self) -> int: """ @@ -1085,7 +1094,8 @@ class PromptDatabase: """ SELECT gi.*, p.text as prompt_text, p.tags as prompt_tags FROM generated_images gi - LEFT JOIN prompts p ON gi.prompt_id = p.id + INNER JOIN prompts p ON gi.prompt_id = p.id + WHERE gi.image_path IS NOT NULL AND gi.image_path != '' ORDER BY gi.generation_time DESC """ ) diff --git a/py/api.py b/py/api.py index 5fdb69d..87e09c2 100644 --- a/py/api.py +++ b/py/api.py @@ -820,8 +820,13 @@ class PromptManagerAPI: async def get_tags_stats(self, request): """Get tags with usage counts, search, sort, and pagination.""" try: - limit = int(request.query.get("limit", 50)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 50)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) search = request.query.get("search", "").strip() or None sort = request.query.get("sort", "alpha_asc") @@ -895,8 +900,13 @@ class PromptManagerAPI: status=400, ) - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_prompts_by_tags([tag_name], 'and', limit, offset) self._enrich_prompt_images(result['prompts']) @@ -925,8 +935,13 @@ class PromptManagerAPI: untagged = request.query.get("untagged", "").lower() == "true" if untagged: - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_untagged_prompts(limit, offset) self._enrich_prompt_images(result['prompts']) return web.json_response({ @@ -954,8 +969,13 @@ class PromptManagerAPI: if mode not in ("and", "or"): mode = "and" - limit = int(request.query.get("limit", 20)) - offset = int(request.query.get("offset", 0)) + try: + limit = int(request.query.get("limit", 20)) + offset = int(request.query.get("offset", 0)) + except (ValueError, TypeError): + return web.json_response( + {"success": False, "error": "Invalid limit or offset parameter"}, status=400 + ) result = self.db.get_prompts_by_tags(tags_list, mode, limit, offset) self._enrich_prompt_images(result['prompts']) @@ -989,7 +1009,12 @@ class PromptManagerAPI: {"success": False, "error": "Tag name required"}, status=400 ) - body = await request.json() + try: + body = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) new_name = body.get("new_name", "").strip() if not new_name: return web.json_response( @@ -997,12 +1022,16 @@ class PromptManagerAPI: ) result = self.db.rename_tag_all_prompts(tag_name, new_name) - return web.json_response({ + resp = { "success": True, "old_name": tag_name, "new_name": new_name, "affected_count": result['affected_count'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Rename tag error: {e}", exc_info=True) return web.json_response( @@ -1020,11 +1049,15 @@ class PromptManagerAPI: ) result = self.db.delete_tag_all_prompts(tag_name) - return web.json_response({ + resp = { "success": True, "tag_name": tag_name, "affected_count": result['affected_count'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Delete tag error: {e}", exc_info=True) return web.json_response( @@ -1034,7 +1067,12 @@ class PromptManagerAPI: async def merge_tags_endpoint(self, request): """Merge source tags into a target tag.""" try: - body = await request.json() + try: + body = await request.json() + except Exception: + return web.json_response( + {"success": False, "error": "Invalid JSON body"}, status=400 + ) source_tags = body.get("source_tags", []) target_tag = body.get("target_tag", "").strip() @@ -1048,12 +1086,16 @@ class PromptManagerAPI: ) result = self.db.merge_tags(source_tags, target_tag) - return web.json_response({ + resp = { "success": True, "target_tag": target_tag, "affected_count": result['affected_count'], "tags_merged": result['tags_merged'] - }) + } + if result.get('skipped_count', 0) > 0: + resp['skipped_count'] = result['skipped_count'] + resp['warning'] = f"{result['skipped_count']} prompt(s) had corrupted tag data and were skipped" + return web.json_response(resp) except Exception as e: self.logger.error(f"Merge tags error: {e}", exc_info=True) return web.json_response( @@ -4878,6 +4920,10 @@ class PromptManagerAPI: tagged = 0 skipped = 0 errors = 0 + tagged_prompt_ids = set() # Track prompts already tagged this run + + import time as _time + last_update_time = _time.monotonic() for i, image_data in enumerate(images): image_path = image_data.get('image_path') @@ -4887,9 +4933,26 @@ class PromptManagerAPI: skipped += 1 continue + # Skip if we already tagged this prompt during this run + if prompt_id in tagged_prompt_ids: + skipped += 1 + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (prompt already processed)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = now + continue + # Check if file exists if not Path(image_path).exists(): skipped += 1 + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (file missing)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = now continue # Check if image already has real tags (skip_tagged option) @@ -4900,11 +4963,14 @@ class PromptManagerAPI: # Filter out "auto-scanned" - it's not a real tag real_tags = [t for t in prompt_tags if t != 'auto-scanned'] if real_tags: + tagged_prompt_ids.add(prompt_id) # Don't re-check other images for this prompt skipped += 1 - if i % 5 == 0 or i == total_files - 1: + now = _time.monotonic() + if (now - last_update_time) >= 0.5 or i == total_files - 1: progress = 10 + int((i + 1) / total_files * 85) yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Skipping {i+1}/{total_files} (already tagged)...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" await asyncio.sleep(0.01) + last_update_time = now continue try: @@ -4917,7 +4983,7 @@ class PromptManagerAPI: processed += 1 if tags: - # Get existing prompt + # Get existing prompt (live read, not snapshot) existing_prompt = self.db.get_prompt_by_id(prompt_id) if existing_prompt: existing_tags = existing_prompt.get('tags', []) @@ -4939,10 +5005,14 @@ class PromptManagerAPI: else: skipped += 1 - if i % 5 == 0 or i == total_files - 1: - progress = 10 + int((i + 1) / total_files * 85) - yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" - await asyncio.sleep(0.01) + # Mark this prompt as done so other images for it are skipped + tagged_prompt_ids.add(prompt_id) + + # Always send progress after LLM inference (each call is slow) + progress = 10 + int((i + 1) / total_files * 85) + yield f"data: {json.dumps({'type': 'progress', 'progress': progress, 'status': f'Processing {i+1}/{total_files}...', 'processed': processed, 'tagged': tagged, 'skipped': skipped})}\n\n" + await asyncio.sleep(0.01) + last_update_time = _time.monotonic() except Exception as img_err: self.logger.error(f"Error processing {image_path}: {img_err}") diff --git a/pyproject.toml b/pyproject.toml index a5aa150..5e151f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "promptmanager" description = "A powerful ComfyUI custom node that extends the standard text encoder with persistent prompt storage, advanced search capabilities, and an automatic image gallery system using SQLite." -version = "3.0.31" +version = "3.0.32" license = {file = "LICENSE"} dependencies = ["# Core dependencies for PromptManager", "# Note: Most dependencies are already included with ComfyUI", "# Already included with Python standard library:", "# - sqlite3", "# - hashlib", "# - json", "# - datetime", "# - os", "# - typing", "# - threading", "# - uuid", "# Required for gallery functionality:", "watchdog>=2.1.0 # For file system monitoring", "Pillow>=8.0.0 # For image metadata extraction (usually included with ComfyUI)", "# Optional dependencies for enhanced search functionality:", "# fuzzywuzzy[speedup]>=0.18.0 # For fuzzy string matching (optional)", "# sqlalchemy>=1.4.0 # For advanced ORM features (optional)", "# Development dependencies (optional):", "# pytest>=6.0.0 # For running tests", "# black>=22.0.0 # For code formatting", "# flake8>=4.0.0 # For linting", "# mypy>=0.910 # For type checking"] diff --git a/utils/comfyui_integration.py b/utils/comfyui_integration.py index 5c76ea1..5aa334a 100644 --- a/utils/comfyui_integration.py +++ b/utils/comfyui_integration.py @@ -15,8 +15,7 @@ Key features: The integration works by: 1. PromptManager nodes register their prompts during execution 2. SaveImage node is patched to include registered prompts in metadata -3. PromptManager class_type is changed to CLIPTextEncode for parser compatibility -4. Standard tools can then extract prompts from the generated images +3. Standard tools can then extract prompts from the generated images Typical usage: from utils.comfyui_integration import get_comfyui_integration @@ -61,7 +60,6 @@ class ComfyUIMetadataIntegration: Key responsibilities: - Register prompts from PromptManager nodes during execution - Patch SaveImage to include PromptManager prompts in metadata - - Convert PromptManager class_type to CLIPTextEncode for compatibility - Manage prompt lifecycle and cleanup """ @@ -184,15 +182,16 @@ class ComfyUIMetadataIntegration: This method modifies ComfyUI's SaveImage.save_images method to automatically include PromptManager prompts in the image metadata. The patching: - + 1. Wraps the original save_images method 2. Retrieves current PromptManager prompt text - 3. Modifies the workflow data to include the prompt - 4. Changes PromptManager class_type to CLIPTextEncode for compatibility - 5. Calls the original method with modified data - - The patching is designed to be minimally invasive and maintain full - compatibility with existing ComfyUI functionality. + 3. Updates the text input in PromptManager nodes to reflect actual prompt + 4. Calls the original method with the updated data + + NOTE: We intentionally do NOT change class_type to CLIPTextEncode anymore. + That approach was corrupting saved workflows - when users saved and reloaded + workflows, ComfyUI would instantiate CLIPTextEncode instead of PromptManager, + causing errors with prepend_text/append_text inputs. """ try: import nodes @@ -222,41 +221,18 @@ class ComfyUIMetadataIntegration: if not isinstance(prompt, dict): prompt = {} - # Find PromptManager nodes and fix them for standard parser compatibility - prompt_updated = False + # Find PromptManager nodes and ensure prompt text is captured + # NOTE: We do NOT change class_type anymore - that was corrupting saved workflows + # when users reload them. PromptManager stays as PromptManager. for node_id, node_data in prompt.items(): if isinstance(node_data, dict): class_type = node_data.get('class_type', '') if 'promptmanager' in class_type.lower(): - # Update the inputs to include our actual prompt text + # Ensure the text input reflects the actual prompt used if 'inputs' not in node_data: node_data['inputs'] = {} node_data['inputs']['text'] = current_prompt_text - - # SIMPLE FIX: Change class_type to CLIPTextEncode for standard parser compatibility - # Keep original class_type in metadata for reference - if '_meta' not in node_data: - node_data['_meta'] = {} - node_data['_meta']['original_class_type'] = class_type - node_data['class_type'] = 'CLIPTextEncode' - - prompt_updated = True - integration.logger.debug(f"Fixed PromptManager node {node_id} - changed class_type to CLIPTextEncode for compatibility") - - # If no PromptManager nodes found, add a standalone one - if not prompt_updated: - virtual_node_id = "promptmanager_text" - prompt[virtual_node_id] = { - "class_type": "CLIPTextEncode", # Use CLIPTextEncode for compatibility - "inputs": { - "text": current_prompt_text - }, - "_meta": { - "original_class_type": "PromptManager", - "virtual": True - } - } - integration.logger.debug("Added standalone CLIPTextEncode node with PromptManager text") + integration.logger.debug(f"Updated PromptManager node {node_id} with prompt text") # Call original method with potentially modified prompt return original_save_images(self_node, images, filename_prefix, prompt, extra_pnginfo) diff --git a/web/admin.html b/web/admin.html index 0e42694..ab5614f 100644 --- a/web/admin.html +++ b/web/admin.html @@ -461,8 +461,10 @@
@@ -480,8 +482,13 @@