From c5a708a0f58b46d1d6992277db0cca8bbea2dd69 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Wed, 7 Oct 2026 10:25:02 -0700 Subject: [PATCH] =?UTF-8?q?fix:=20release=203.2.3=20=E2=80=94=20metadata?= =?UTF-8?q?=20parsing,=20null-safe=20saves,=20auto-hide,=20tag=20search?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(#75): stop SaveImage patch from writing the last-run prompt into every PromptManager node (negative prompts were saved as copies of the positive) - fix(#75): replace four duplicated metadata parsers with a shared web/js/comfy-metadata.js that traces sampler links (KSampler, CFGGuider / SamplerCustomAdvanced, LoRA chains, ControlNet pass-through, any checkpoint loader) and recovers per-node text for images saved by older versions - fix(#143): treat null JSON body fields as empty in prompt API handlers - fix(#149): keep auto-hide timeout 0 (disabled) instead of falling back to the default; validate result_timeout on save - feat(#150): support tag:name filters in the node search box - docs(#142): scan dialog mentions A1111/Forge metadata support - docs(#144): fix ComfyUI-Lora-Manager links - ci: run JS tests with node --test --- .github/workflows/test.yml | 9 + README.md | 6 +- prompt_manager.py | 2 +- prompt_manager_text.py | 2 +- py/api/admin.py | 14 +- py/api/prompts.py | 18 +- pyproject.toml | 2 +- tests/js/comfy-metadata.test.js | 170 ++++++++++++++++ tests/test_api.py | 17 ++ tests/test_comfyui_integration.py | 69 +++++++ tests/test_validators.py | 42 ++++ utils/comfyui_integration.py | 119 ++--------- utils/validators.py | 36 ++++ web/admin.html | 3 +- web/gallery.html | 1 + web/js/admin.js | 317 +----------------------------- web/js/comfy-metadata.js | 225 +++++++++++++++++++++ web/js/gallery.js | 179 +---------------- web/metadata.html | 185 +---------------- web/prompt_manager.js | 37 +++- 20 files changed, 657 insertions(+), 796 deletions(-) create mode 100644 tests/js/comfy-metadata.test.js create mode 100644 tests/test_comfyui_integration.py create mode 100644 web/js/comfy-metadata.js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c07dba..0c27c74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,3 +39,12 @@ jobs: - name: Run tests run: python -m unittest discover tests/ -v + + js-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + + # Pure-JS frontend modules, tested with the runner's preinstalled Node + - name: Run JS tests + run: node --test tests/js/*.test.js diff --git a/README.md b/README.md index 5f960f9..9e9fa91 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Both nodes include the complete PromptManager feature set: - **📋 Metadata Viewer**: Standalone tool for analyzing ComfyUI-generated images - **🛠️ System Management**: Built-in diagnostics, backup/restore, and maintenance tools - **🏷️ AI AutoTag**: Automatically tag your image collection using WD14 or JoyCaption vision models -- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager) +- **🔗 LoRA Manager Integration**: Import LoRA metadata and preview images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) ![Image Gallery](images/pm-02.png) @@ -430,7 +430,7 @@ Organize and filter your prompt library by output subdirectory. The dashboard se ![LoRA Settings](images/pm-settings-integrations.png) -If you use [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager), PromptManager can import your LoRA metadata, trigger words, and example images directly into your prompt database. This lets you search, tag, and browse your LoRA collection alongside your regular prompts. +If you use [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager), PromptManager can import your LoRA metadata, trigger words, and example images directly into your prompt database. This lets you search, tag, and browse your LoRA collection alongside your regular prompts. > **WIP:** LoRA Manager support is a work in progress — this was a highly requested feature. Please [open issues](https://github.com/ComfyAssets/ComfyUI_PromptManager/issues) for any bugs or feature requests. @@ -895,7 +895,7 @@ MIT License - see LICENSE file for details. ### v3.2.1 (LoRA Manager Integration) -- **🔗 LoRA Manager Integration**: Import LoRA metadata, trigger words, and CivitAI example images from [ComfyUI-Lora-Manager](https://github.com/willchil/ComfyUI-Lora-Manager) into your prompt database +- **🔗 LoRA Manager Integration**: Import LoRA metadata, trigger words, and CivitAI example images from [ComfyUI-Lora-Manager](https://github.com/willmiao/ComfyUI-Lora-Manager) into your prompt database - **💉 Auto-Inject Trigger Words**: Optionally append LoRA trigger words when `` is detected in prompts during encoding - **🔑 CivitAI API Key Support**: Authenticate with CivitAI to download NSFW preview images - **📥 Import Progress Modal**: Real-time SSE streaming progress during LoRA import with per-model status diff --git a/prompt_manager.py b/prompt_manager.py index a1eceff..ffbec23 100644 --- a/prompt_manager.py +++ b/prompt_manager.py @@ -78,7 +78,7 @@ class PromptManager(PromptManagerBase, ComfyNodeABC): IO.STRING, { "default": "", - "tooltip": "Search for past prompts containing this text", + "tooltip": 'Search past prompts by text; add tag:name (or tag:"two words") to filter by tag', }, ), "prepend_text": ( diff --git a/prompt_manager_text.py b/prompt_manager_text.py index 6e0736c..73bc061 100644 --- a/prompt_manager_text.py +++ b/prompt_manager_text.py @@ -76,7 +76,7 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC): IO.STRING, { "default": "", - "tooltip": "Search for past prompts containing this text", + "tooltip": 'Search past prompts by text; add tag:name (or tag:"two words") to filter by tag', }, ), "prepend_text": ( diff --git a/py/api/admin.py b/py/api/admin.py index 7c0ddbd..ae9b6a7 100644 --- a/py/api/admin.py +++ b/py/api/admin.py @@ -13,6 +13,11 @@ from pathlib import Path from aiohttp import web +try: + from ...utils.validators import validate_result_timeout +except ImportError: + from utils.validators import validate_result_timeout + class AdminRoutesMixin: """Mixin providing admin, diagnostics, and maintenance API endpoints.""" @@ -399,7 +404,14 @@ class AdminRoutesMixin: # Update in-memory config if "result_timeout" in data: - PromptManagerConfig.RESULT_TIMEOUT = data["result_timeout"] + try: + PromptManagerConfig.RESULT_TIMEOUT = validate_result_timeout( + data["result_timeout"] + ) + except ValueError as ve: + return web.json_response( + {"success": False, "error": str(ve)}, status=400 + ) if "webui_display_mode" in data: PromptManagerConfig.WEBUI_DISPLAY_MODE = data["webui_display_mode"] diff --git a/py/api/prompts.py b/py/api/prompts.py index bc5632b..4a47eed 100644 --- a/py/api/prompts.py +++ b/py/api/prompts.py @@ -437,7 +437,7 @@ class PromptRoutesMixin: return web.json_response( {"success": False, "error": "Invalid JSON body"}, status=400 ) - new_name = body.get("new_name", "").strip() + new_name = (body.get("new_name") or "").strip() if not new_name: return web.json_response( {"success": False, "error": "New tag name required"}, status=400 @@ -501,7 +501,7 @@ class PromptRoutesMixin: {"success": False, "error": "Invalid JSON body"}, status=400 ) source_tags = body.get("source_tags", []) - target_tag = body.get("target_tag", "").strip() + target_tag = (body.get("target_tag") or "").strip() if not source_tags: return web.json_response( @@ -536,16 +536,16 @@ class PromptRoutesMixin: try: data = await request.json() - text = data.get("text", "").strip() + text = (data.get("text") or "").strip() if not text: return web.json_response( {"success": False, "error": "Text is required"}, status=400 ) - category = data.get("category", "").strip() or None + category = (data.get("category") or "").strip() or None tags = data.get("tags", []) rating = data.get("rating") or None - notes = data.get("notes", "").strip() or None + notes = (data.get("notes") or "").strip() or None try: validate_prompt_text(text) @@ -643,7 +643,7 @@ class PromptRoutesMixin: try: prompt_id = int(request.match_info["prompt_id"]) data = await request.json() - new_text = data.get("text", "").strip() + new_text = (data.get("text") or "").strip() if not new_text: return web.json_response( @@ -724,7 +724,7 @@ class PromptRoutesMixin: try: prompt_id = int(request.match_info["prompt_id"]) data = await request.json() - new_tag = data.get("tag", "").strip() + new_tag = (data.get("tag") or "").strip() if not new_tag: return web.json_response( @@ -824,7 +824,7 @@ class PromptRoutesMixin: try: prompt_id = int(request.match_info["prompt_id"]) data = await request.json() - tag_to_remove = data.get("tag", "").strip() + tag_to_remove = (data.get("tag") or "").strip() prompt = await self._run_in_executor(self.db.get_prompt_by_id, prompt_id) if not prompt: @@ -923,7 +923,7 @@ class PromptRoutesMixin: try: data = await request.json() prompt_ids = data.get("prompt_ids", []) - category = data.get("category", "").strip() + category = (data.get("category") or "").strip() if not prompt_ids: return web.json_response( diff --git a/pyproject.toml b/pyproject.toml index 4fc861b..a53431b 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.2.2" +version = "3.2.3" 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/tests/js/comfy-metadata.test.js b/tests/js/comfy-metadata.test.js new file mode 100644 index 0000000..8f4693d --- /dev/null +++ b/tests/js/comfy-metadata.test.js @@ -0,0 +1,170 @@ +// Run with: node --test tests/js/ +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { extractGenerationParams } = require("../../web/js/comfy-metadata.js"); + +// Same shape as the workflow attached to #75: PromptManager nodes are saved as +// CLIPTextEncode with extra inputs, and the checkpoint loader is pysssss'. +const issue75Prompt = { + 1: { + class_type: "KSampler", + inputs: { + seed: 480655185510613, steps: 25, cfg: 5.5, + sampler_name: "euler_ancestral", scheduler: "normal", denoise: 0.8, + model: ["20", 0], positive: ["39", 0], negative: ["40", 0], latent_image: ["36", 0], + }, + }, + 8: { class_type: "CLIPSetLastLayer", inputs: { stop_at_clip_layer: -2, clip: ["20", 1] } }, + 20: { + class_type: "CheckpointLoader|pysssss", + inputs: { ckpt_name: "Anime\\Model-XL.safetensors", prompt: "[none]", example: "[none]" }, + }, + 39: { + class_type: "CLIPTextEncode", + inputs: { + text: "1girl, cat_ears, smile", category: "", tags: "", search_text: "", + prepend_text: "masterpiece, best quality, ", append_text: "", clip: ["8", 0], + }, + }, + 40: { + class_type: "CLIPTextEncode", + inputs: { + text: "3d, crowd", category: "", tags: "", search_text: "", + prepend_text: "bad quality, worst quality, ", append_text: "", clip: ["8", 0], + }, + }, +}; + +test("#75: traces PromptManager prompts and pysssss checkpoint from the sampler", () => { + const r = extractGenerationParams({ prompt: issue75Prompt }); + assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, cat_ears, smile"); + assert.equal(r.negativePrompt, "bad quality, worst quality, 3d, crowd"); + assert.equal(r.checkpoint, "Anime\\Model-XL.safetensors"); + assert.deepEqual( + [r.seed, r.steps, r.cfgScale, r.sampler], + [480655185510613, 25, 5.5, "euler_ancestral"], + ); +}); + +test("#75: workflow blob does not override values from the prompt graph", () => { + const workflow = { nodes: [{ type: "KSampler", widgets_values: [1, "randomize", 99, 7, "ddim"] }] }; + const r = extractGenerationParams({ prompt: issue75Prompt, workflow }); + assert.equal(r.steps, 25); + assert.equal(r.cfgScale, 5.5); +}); + +test("#75: recovers per-node text when SaveImage patch overwrote every PromptManager node", () => { + // Pre-fix images: both nodes carry the final positive text in the prompt graph, + // but the workflow graph still has each node's real widget values. + const positiveFinal = "masterpiece, best quality, 1girl, smile"; + const prompt = { + 1: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } }, + 20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, + 39: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "masterpiece, best quality,", append_text: "" } }, + 40: { class_type: "PromptManager", inputs: { text: positiveFinal, prepend_text: "bad quality,", append_text: "" } }, + }; + const workflow = { + nodes: [ + { id: 39, type: "PromptManager", widgets_values: ["1girl, smile", "", "", "", "masterpiece, best quality,", ""] }, + { id: 40, type: "PromptManager", widgets_values: ["3d, crowd", "", "", "", "bad quality,", ""] }, + ], + }; + const r = extractGenerationParams({ prompt, workflow }); + assert.equal(r.positivePrompt, "masterpiece, best quality, 1girl, smile"); + assert.equal(r.negativePrompt, "bad quality, 3d, crowd"); +}); + +test("prepend/append are not doubled when text is already the combined prompt", () => { + const prompt = { + 1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } }, + 2: { class_type: "PromptManager", inputs: { text: "best quality, a cat, 8k", prepend_text: "best quality,", append_text: "8k" } }, + 3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } }, + }; + assert.equal(extractGenerationParams({ prompt }).positivePrompt, "best quality, a cat, 8k"); +}); + +test("linked prepend/append inputs are resolved (no longer baked into text since 3.2.3)", () => { + const prompt = { + 1: { class_type: "KSampler", inputs: { positive: ["2", 0], negative: ["3", 0], model: ["4", 0] } }, + 2: { class_type: "PromptManager", inputs: { text: "a fox", prepend_text: ["5", 0], append_text: ["6", 0] } }, + 3: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } }, + 5: { class_type: "PrimitiveString", inputs: { value: "masterpiece," } }, + 6: { class_type: "PrimitiveString", inputs: { value: "8k" } }, + }; + assert.equal(extractGenerationParams({ prompt }).positivePrompt, "masterpiece, a fox 8k"); +}); + +test("negative prompt is identified by link, not by keywords", () => { + const prompt = { + 3: { class_type: "KSampler", inputs: { seed: 1, steps: 20, cfg: 7, sampler_name: "euler", positive: ["6", 0], negative: ["7", 0], model: ["4", 0] } }, + 4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "sd15.safetensors" } }, + 6: { class_type: "CLIPTextEncode", inputs: { text: "embedding:goodstyle, a lighthouse" } }, + 7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } }, + }; + const r = extractGenerationParams({ prompt }); + assert.equal(r.positivePrompt, "embedding:goodstyle, a lighthouse"); + assert.equal(r.negativePrompt, "blurry"); +}); + +test("follows LoRA chains, ControlNet pass-through, concatenated text and primitive inputs", () => { + const prompt = { + 1: { class_type: "KSamplerAdvanced", inputs: { noise_seed: 42, steps: ["9", 0], cfg: 6, sampler_name: "dpmpp_2m", positive: ["5", 0], negative: ["5", 1], model: ["3", 0] } }, + 2: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "base.safetensors" } }, + 3: { class_type: "LoraLoader", inputs: { lora_name: "style.safetensors", model: ["2", 0], clip: ["2", 1] } }, + 5: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["6", 0], negative: ["7", 0], strength: 1 } }, + 6: { class_type: "CLIPTextEncode", inputs: { text: ["8", 0] } }, + 7: { class_type: "CLIPTextEncode", inputs: { text: "lowres" } }, + 8: { class_type: "Text Concatenate", inputs: { delimiter: ", ", text_a: "a castle", text_b: "at dusk" } }, + 9: { class_type: "PrimitiveInt", inputs: { value: 30 } }, + }; + const r = extractGenerationParams({ prompt }); + assert.equal(r.positivePrompt, "a castle, at dusk"); + assert.equal(r.negativePrompt, "lowres"); + assert.equal(r.checkpoint, "base.safetensors"); + assert.equal(r.seed, 42); + assert.equal(r.steps, 30); +}); + +test("custom sampler graphs (Flux: SamplerCustomAdvanced + CFGGuider) resolve every field", () => { + const prompt = { + 85: { class_type: "UNETLoader", inputs: { unet_name: "flux2-klein.safetensors" } }, + 135: { class_type: "Lora Loader (LoraManager)", inputs: { model: ["85", 0], clip: ["90", 0] } }, + 149: { class_type: "PromptManager", inputs: { text: "a red fox", prepend_text: ["133", 0], append_text: "" } }, + 152: { class_type: "SeedHistory", inputs: { seed: 1725922648 } }, + // ControlNet-style node with positive/negative but no model: must not be taken as the sampler + 200: { class_type: "ControlNetApplyAdvanced", inputs: { positive: ["149", 0], negative: ["274", 0], strength: 1 } }, + 265: { class_type: "SamplerCustomAdvanced", inputs: { noise: ["267", 0], guider: ["268", 0], sampler: ["269", 0], sigmas: ["266", 0] } }, + 266: { class_type: "Flux2Scheduler", inputs: { steps: 50, width: ["153", 1] } }, + 267: { class_type: "RandomNoise", inputs: { noise_seed: ["152", 0] } }, + 268: { class_type: "CFGGuider", inputs: { cfg: 5.0, model: ["135", 0], positive: ["149", 0], negative: ["274", 0] } }, + 269: { class_type: "KSamplerSelect", inputs: { sampler_name: "uni_pc" } }, + 274: { class_type: "CLIPTextEncode", inputs: { text: "watermark" } }, + }; + const r = extractGenerationParams({ prompt }); + assert.deepEqual( + [r.positivePrompt, r.negativePrompt, r.checkpoint, r.cfgScale, r.seed, r.steps, r.sampler], + ["a red fox", "watermark", "flux2-klein.safetensors", 5.0, 1725922648, 50, "uni_pc"], + ); +}); + +test("workflow-only images account for the hidden control_after_generate widget", () => { + const workflow = { + nodes: [ + { type: "CheckpointLoader|pysssss", widgets_values: ["model.safetensors", "[none]"] }, + { type: "KSampler", widgets_values: [123, "randomize", 25, 5.5, "euler", "normal", 1] }, + ], + }; + const r = extractGenerationParams({ workflow }); + assert.deepEqual( + [r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler], + ["model.safetensors", 123, 25, 5.5, "euler"], + ); +}); + +test("missing or empty metadata yields placeholders", () => { + const r = extractGenerationParams({}); + assert.equal(r.positivePrompt, "No prompt found"); + assert.equal(r.negativePrompt, "No negative prompt found"); + assert.equal(r.checkpoint, "Unknown"); + assert.equal(extractGenerationParams(null).seed, "Unknown"); +}); diff --git a/tests/test_api.py b/tests/test_api.py index 54ba8f7..f985353 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -171,6 +171,23 @@ class TestSaveAndDelete(APITestCase): data = await resp.json() self.assertFalse(data["success"]) + async def test_save_with_null_optional_fields(self): + """Regression for #143: UI sends null for empty optional fields.""" + resp = await self.client.request( + "POST", + "/prompt_manager/save", + json={"text": "Null notes prompt", "category": None, "notes": None}, + ) + self.assertEqual(resp.status, 200) + data = await resp.json() + self.assertTrue(data["success"]) + + async def test_save_with_null_text(self): + resp = await self.client.request( + "POST", "/prompt_manager/save", json={"text": None} + ) + self.assertEqual(resp.status, 400) + async def test_delete_prompt(self): pid = self._save_prompt("To delete") resp = await self.client.request("DELETE", f"/prompt_manager/delete/{pid}") diff --git a/tests/test_comfyui_integration.py b/tests/test_comfyui_integration.py new file mode 100644 index 0000000..454e240 --- /dev/null +++ b/tests/test_comfyui_integration.py @@ -0,0 +1,69 @@ +"""Tests for utils.comfyui_integration.""" + +import os +import sys +import types +import unittest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from utils.comfyui_integration import ComfyUIMetadataIntegration + + +class TestSaveImageNotPatched(unittest.TestCase): + """Regression for #75: SaveImage must save each node's own prompt inputs. + + The old SaveImage patch wrote the last-run PromptManager prompt into every + PromptManager node, so negative prompts were saved as copies of the positive. + """ + + def setUp(self): + def save_images( + self_node, + images, + filename_prefix="ComfyUI", + prompt=None, + extra_pnginfo=None, + ): + return {"prompt": prompt} + + self.original_save_images = save_images + fake_nodes = types.ModuleType("nodes") + fake_nodes.SaveImage = type("SaveImage", (), {"save_images": save_images}) + self._saved_nodes = sys.modules.get("nodes") + sys.modules["nodes"] = fake_nodes + self.fake_nodes = fake_nodes + ComfyUIMetadataIntegration._instance = None + + def tearDown(self): + ComfyUIMetadataIntegration._instance = None + if self._saved_nodes is None: + sys.modules.pop("nodes", None) + else: + sys.modules["nodes"] = self._saved_nodes + + def test_save_images_is_left_untouched(self): + ComfyUIMetadataIntegration() + self.assertIs(self.fake_nodes.SaveImage.save_images, self.original_save_images) + + def test_registered_prompt_does_not_rewrite_saved_metadata(self): + integration = ComfyUIMetadataIntegration() + integration.register_prompt("pm_1", "positive final text", {}) + prompt = { + "39": {"class_type": "PromptManager", "inputs": {"text": "a cat"}}, + "40": {"class_type": "PromptManager", "inputs": {"text": "blurry"}}, + } + + result = self.fake_nodes.SaveImage().save_images([], prompt=prompt) + + self.assertEqual(result["prompt"]["39"]["inputs"]["text"], "a cat") + self.assertEqual(result["prompt"]["40"]["inputs"]["text"], "blurry") + + def test_prompt_registry_still_available_to_nodes(self): + integration = ComfyUIMetadataIntegration() + integration.register_prompt("pm_1", "hello", {}) + self.assertEqual(integration.get_current_prompt_text("pm_1"), "hello") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_validators.py b/tests/test_validators.py index 6826119..8f1707e 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -19,6 +19,7 @@ from utils.validators import ( validate_workflow_name, sanitize_input, parse_tags_string, + validate_result_timeout, ) @@ -287,5 +288,46 @@ class TestValidateTagsEdgeCases(unittest.TestCase): validate_tags([" "]) +class TestValidateResultTimeout(unittest.TestCase): + """Test validate_result_timeout function (#149).""" + + def test_zero_disables_auto_hide(self): + self.assertEqual(validate_result_timeout(0), 0) + + def test_typical_value(self): + self.assertEqual(validate_result_timeout(30), 30) + + def test_max_boundary(self): + self.assertEqual(validate_result_timeout(300), 300) + + def test_returns_int(self): + self.assertIsInstance(validate_result_timeout(5), int) + + def test_none_rejected(self): + with self.assertRaises(ValueError): + validate_result_timeout(None) + + def test_garbage_string_rejected(self): + with self.assertRaises(ValueError): + validate_result_timeout("soon") + + def test_numeric_string_coerced(self): + self.assertEqual(validate_result_timeout(" 10 "), 10) + + def test_fractional_values_rejected(self): + for value in (1.5, "1.5"): + with self.assertRaises(ValueError): + validate_result_timeout(value) + + def test_out_of_range_rejected_not_clamped(self): + for value in (-5, 301, 9999): + with self.assertRaises(ValueError): + validate_result_timeout(value) + + def test_bool_rejected(self): + with self.assertRaises(ValueError): + validate_result_timeout(True) + + if __name__ == "__main__": unittest.main() diff --git a/utils/comfyui_integration.py b/utils/comfyui_integration.py index 2468161..95a4e7d 100644 --- a/utils/comfyui_integration.py +++ b/utils/comfyui_integration.py @@ -1,12 +1,14 @@ """ComfyUI integration utilities for PromptManager. -This module provides deep integration with ComfyUI's metadata system to ensure that -PromptManager-generated prompts appear correctly in standard ComfyUI image metadata. -It patches core ComfyUI components to bridge the gap between PromptManager's custom -nodes and ComfyUI's standard metadata extraction. +This module tracks the prompts PromptManager nodes encode during execution so +other components (e.g. image/prompt linking) can look them up. + +ComfyUI already writes every node's own inputs into saved image metadata, so +SaveImage is deliberately NOT patched. An earlier patch wrote the last-run prompt +into every PromptManager node, saving negative prompts as copies of the +positive (#75). Key features: -- Automatic patching of ComfyUI's SaveImage node - Thread-safe prompt registration and retrieval - Cross-thread prompt context sharing - Standard metadata format compatibility @@ -14,26 +16,18 @@ 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. Standard tools can then extract prompts from the generated images +2. Callers retrieve the current prompt text by node id or recency Typical usage: from utils.comfyui_integration import get_comfyui_integration integration = get_comfyui_integration() integration.register_prompt(node_id, prompt_text, metadata) - # Generated images will now include this prompt in their metadata - -This integration is essential for: -- Third-party tool compatibility -- Standard metadata parsers -- Workflow sharing and reproduction -- Integration with existing ComfyUI ecosystems + text = integration.get_current_prompt_text(node_id) """ import threading import time -import json from typing import Dict, Any, Optional try: @@ -60,7 +54,6 @@ class ComfyUIMetadataIntegration: Key responsibilities: - Register prompts from PromptManager nodes during execution - - Patch SaveImage to include PromptManager prompts in metadata - Manage prompt lifecycle and cleanup """ @@ -82,8 +75,8 @@ class ComfyUIMetadataIntegration: def __init__(self): """Initialize the ComfyUI integration system. - Sets up prompt tracking data structures and attempts to patch the - SaveImage node. Uses _initialized flag to prevent duplicate initialization. + Sets up prompt tracking data structures. Uses _initialized flag to + prevent duplicate initialization. """ if hasattr(self, "_initialized"): return @@ -91,12 +84,8 @@ class ComfyUIMetadataIntegration: self.logger = get_logger("prompt_manager.comfyui_integration") self._current_prompts = {} self._thread_local = threading.local() - self._saveimage_patched = False self._initialized = True - # Try to patch SaveImage node on initialization - self._patch_saveimage_node() - def register_prompt(self, node_id: str, prompt_text: str, metadata: Dict[str, Any]): """ Register a prompt from PromptManager for inclusion in ComfyUI metadata. @@ -186,92 +175,6 @@ class ComfyUIMetadataIntegration: return None - def _patch_saveimage_node(self): - """ - Patch ComfyUI's SaveImage node to include PromptManager prompts in metadata. - - 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. 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 - - if not hasattr(nodes, "SaveImage"): - self.logger.warning("SaveImage node not found in ComfyUI nodes") - return - - # Store original save_images method - original_save_images = nodes.SaveImage.save_images - integration = self # Capture self reference - - def patched_save_images( - self_node, - images, - filename_prefix="ComfyUI", - prompt=None, - extra_pnginfo=None, - ): - """Patched save_images method that includes PromptManager prompts.""" - - # Get current prompt text from PromptManager - current_prompt_text = integration.get_current_prompt_text() - - if current_prompt_text: - integration.logger.debug( - f"Including PromptManager prompt in SaveImage metadata: {current_prompt_text[:50]}..." - ) - - # If no prompt provided, create one with our text - if prompt is None: - prompt = {} - - # Ensure prompt has the standard structure ComfyUI expects - if not isinstance(prompt, dict): - prompt = {} - - # 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(): - # 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 - 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 - ) - - # Apply the patch - nodes.SaveImage.save_images = patched_save_images - self._saveimage_patched = True - self.logger.info( - "Successfully patched SaveImage node for PromptManager integration" - ) - - except Exception as e: - self.logger.error(f"Failed to patch SaveImage node: {e}") - self.logger.warning( - "PromptManager prompts may not appear in standard ComfyUI metadata" - ) - def cleanup_old_prompts(self, max_age_seconds: int = 600): """ Clean up old prompt registrations. diff --git a/utils/validators.py b/utils/validators.py index edf3fcc..cd6aa1c 100644 --- a/utils/validators.py +++ b/utils/validators.py @@ -94,6 +94,42 @@ def validate_rating(rating: Optional[int]) -> bool: return True +MAX_RESULT_TIMEOUT = 300 # Matches max="300" on the admin settings input + + +def validate_result_timeout(timeout) -> int: + """ + Validate and normalize the search-results auto-hide timeout. + + A value of 0 means "never auto-hide" and must be preserved (see #149). + + Args: + timeout: Raw value from the settings request body + + Returns: + The timeout in whole seconds, safe to store in RESULT_TIMEOUT + + Raises: + ValueError: If the value cannot be used as a timeout + """ + # bool is an int subclass; True must not become a 1-second timeout + if isinstance(timeout, bool): + raise ValueError("Auto-hide timeout must be a whole number of seconds") + + if isinstance(timeout, str) and timeout.strip().isdigit(): + timeout = int(timeout.strip()) + + if not isinstance(timeout, int): + raise ValueError("Auto-hide timeout must be a whole number of seconds") + + if timeout < 0 or timeout > MAX_RESULT_TIMEOUT: + raise ValueError( + f"Auto-hide timeout must be between 0 and {MAX_RESULT_TIMEOUT} seconds" + ) + + return timeout + + def validate_tags(tags: Union[str, List[str], None]) -> bool: """ Validate tags input. diff --git a/web/admin.html b/web/admin.html index ecccce0..6bb850a 100644 --- a/web/admin.html +++ b/web/admin.html @@ -770,7 +770,7 @@
  • This will scan all PNG files in your ComfyUI output directory
  • Depending on size, this could take several minutes
  • This is experimental — we recommend backing up first
  • -
  • Only images with ComfyUI metadata will be processed
  • +
  • Only images with ComfyUI or A1111/Forge-style metadata will be processed
  • Duplicate prompts will be automatically detected and skipped
  • @@ -1390,6 +1390,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr + diff --git a/web/gallery.html b/web/gallery.html index be39c7a..78eb762 100644 --- a/web/gallery.html +++ b/web/gallery.html @@ -803,6 +803,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr + \ No newline at end of file diff --git a/web/js/admin.js b/web/js/admin.js index 0a7d2ae..8be9f06 100644 --- a/web/js/admin.js +++ b/web/js/admin.js @@ -232,7 +232,7 @@ if (response.ok) { const data = await response.json(); if (data.success && data.settings) { - this.settings.resultTimeout = data.settings.result_timeout || 5; + this.settings.resultTimeout = data.settings.result_timeout ?? 5; this.settings.webuiDisplayMode = data.settings.webui_display_mode || 'popup'; this.settings.monitoredDirectories = data.settings.monitored_directories || []; this.settings.galleryRootPaths = data.settings.gallery_root_paths || []; @@ -2582,67 +2582,10 @@ filePath = currentImage.image_path || currentImage.filename || imageSrc; } - // Extract information from ComfyUI workflow - let checkpoint = 'Unknown'; - let positivePrompt = 'No prompt found'; - let negativePrompt = 'No negative prompt found'; - let steps = 'Unknown'; - let cfgScale = 'Unknown'; - let sampler = 'Unknown'; - let seed = 'Unknown'; - - // Parse prompt data first (more reliable for actual generation parameters) - if (comfyData.prompt) { - console.log('Parsing prompt data...', Object.keys(comfyData.prompt).length, 'nodes'); - const promptNodes = comfyData.prompt; - - for (const nodeId in promptNodes) { - const node = promptNodes[nodeId]; - console.log(`Node ${nodeId}:`, node.class_type, node); - - // Checkpoint - if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) { - checkpoint = node.inputs.ckpt_name || checkpoint; - console.log('Found checkpoint:', checkpoint); - } - - // Prompts - need to identify which is positive vs negative - if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) { - // PromptManager typically contains the positive prompt - const promptValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text)); - positivePrompt = promptValue; - console.log('Found PromptManager with text:', promptValue.substring(0, 100)); - } - - if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) { - // Check if this looks like a negative prompt - const textValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text)); - console.log('Found CLIPTextEncode:', textValue.substring(0, 50)); - const text = textValue.toLowerCase(); - if (text.includes('bad anatomy') || text.includes('unfinished') || - text.includes('censored') || text.includes('weird anatomy') || - text.includes('negative') || text.includes('embedding:')) { - negativePrompt = textValue; - console.log('Found negative prompt:', textValue.substring(0, 100)); - } else if (positivePrompt === 'No prompt found') { - // If we haven't found a positive prompt yet, this might be it - positivePrompt = textValue; - console.log('Found potential positive prompt:', textValue.substring(0, 100)); - } - } - - // Sampling parameters - if (node.class_type === 'KSampler' && node.inputs) { - seed = node.inputs.seed || seed; - steps = node.inputs.steps || steps; - cfgScale = node.inputs.cfg || cfgScale; - sampler = node.inputs.sampler_name || sampler; - console.log('Found sampler params:', {seed, steps, cfgScale, sampler}); - } - } - } else { - console.log('No prompt data found, will try workflow fallback'); - } + // Trace prompts, model and sampler settings through the node graph (#75) + const { + checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed, + } = window.ComfyMetadata.extractGenerationParams(comfyData); // Store the current metadata for copying this.currentMetadata = { @@ -3011,252 +2954,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } parseWorkflowData(comfyData, imagePath) { - // Extract information from ComfyUI workflow - let checkpoint = 'Unknown'; - let positivePrompt = 'No prompt found'; - let negativePrompt = 'No negative prompt found'; - let steps = 'Unknown'; - let cfgScale = 'Unknown'; - let sampler = 'Unknown'; - let seed = 'Unknown'; - - // Parse prompt data first (more reliable for actual generation parameters) - if (comfyData.prompt) { - const promptNodes = comfyData.prompt; - - for (const nodeId in promptNodes) { - const node = promptNodes[nodeId]; - - // Checkpoint - if (node.class_type === 'CheckpointLoaderSimple' && node.inputs) { - checkpoint = node.inputs.ckpt_name || checkpoint; - } - - // Prompts - need to identify which is positive vs negative - if (node.class_type === 'PromptManager' && node.inputs && node.inputs.text) { - // PromptManager typically contains the positive prompt - const promptValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text)); - positivePrompt = promptValue; - } - - if (node.class_type === 'CLIPTextEncode' && node.inputs && node.inputs.text) { - // Check if this looks like a negative prompt - const textValue = typeof node.inputs.text === 'string' ? node.inputs.text : (Array.isArray(node.inputs.text) ? node.inputs.text[0] : String(node.inputs.text)); - const text = textValue.toLowerCase(); - if (text.includes('bad anatomy') || text.includes('unfinished') || - text.includes('censored') || text.includes('weird anatomy') || - text.includes('negative') || text.includes('embedding:')) { - negativePrompt = textValue; - } else if (positivePrompt === 'No prompt found') { - // If we haven't found a positive prompt yet, this might be it - positivePrompt = textValue; - } - } - - // Sampling parameters - if (node.class_type === 'KSampler' && node.inputs) { - seed = node.inputs.seed || seed; - steps = node.inputs.steps || steps; - cfgScale = node.inputs.cfg || cfgScale; - sampler = node.inputs.sampler_name || sampler; - } - } - } - - // Parse workflow data (this is what we actually have in your case) - if (comfyData.workflow && comfyData.workflow.nodes) { - const nodes = comfyData.workflow.nodes; - console.log('DEBUG: Total nodes found:', nodes.length); - - // Debug: Log all nodes to understand the structure - nodes.forEach((node, index) => { - console.log(`Node ${index} (ID: ${node.id}):`, { - type: node.type, - title: node.title, - widgets_values: node.widgets_values - }); - }); - - // Look for checkpoint loader - const checkpointNode = nodes.find(node => - node.type === 'CheckpointLoaderSimple' || - node.type === 'CheckpointLoader' - ); - if (checkpointNode && checkpointNode.widgets_values && checkpointNode.widgets_values[0]) { - checkpoint = checkpointNode.widgets_values[0]; - } - - // Look for prompts in workflow nodes - const textEncodeNodes = nodes.filter(node => node.type === 'CLIPTextEncode'); - const promptManagerNodes = nodes.filter(node => node.type === 'PromptManager'); - - // Check PromptManager first for positive prompt - if (promptManagerNodes.length > 0 && promptManagerNodes[0].widgets_values && promptManagerNodes[0].widgets_values[0]) { - positivePrompt = promptManagerNodes[0].widgets_values[0]; - } - - // For negative prompt, look for CLIPTextEncode that contains negative keywords - for (const node of textEncodeNodes) { - if (node.widgets_values && node.widgets_values[0]) { - const text = node.widgets_values[0].toLowerCase(); - if (text.includes('bad anatomy') || text.includes('unfinished') || - text.includes('censored') || text.includes('weird anatomy') || - text.includes('negative') || text.includes('embedding:')) { - negativePrompt = node.widgets_values[0]; - break; - } - } - } - - // Extract generation parameters from their source nodes - // Look for specific node types with better fallback logic - - // CFG Scale - prioritize Float nodes with title 'CFG', then other sources - console.log('DEBUG: Looking for CFG scale...'); - - // First priority: Float node with title 'CFG' - const cfgFloatNode = nodes.find(node => node.type === 'Float' && node.title === 'CFG'); - if (cfgFloatNode && cfgFloatNode.widgets_values && cfgFloatNode.widgets_values[0]) { - cfgScale = cfgFloatNode.widgets_values[0]; - console.log(`DEBUG: Found CFG in Float node: ${cfgScale}`); - } - - // If not found, look for specific value of 7 (your target CFG) - if (cfgScale === 'Unknown') { - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (value === 7) { // Target the specific value you mentioned - cfgScale = value; - console.log(`DEBUG: Found target CFG value 7 in node ${node.id} (${node.type}) at index ${i}`); - break; - } - } - if (cfgScale !== 'Unknown') break; - } - } - } - - // Fallback: any reasonable CFG value - if (cfgScale === 'Unknown') { - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (typeof value === 'number' && value > 1 && value <= 30) { - console.log(`DEBUG: Found potential CFG ${value} in node ${node.id} (${node.type}) at index ${i}`); - cfgScale = value; - console.log(`DEBUG: Set CFG scale to ${value}`); - break; - } - } - if (cfgScale !== 'Unknown') break; - } - } - } - - // Steps - prioritize Int nodes with title 'Steps', then target value 30 - console.log('DEBUG: Looking for steps...'); - - // First priority: Int node with title 'Steps' - const stepsIntNode = nodes.find(node => node.type === 'Int' && node.title === 'Steps'); - if (stepsIntNode && stepsIntNode.widgets_values && stepsIntNode.widgets_values[0]) { - steps = stepsIntNode.widgets_values[0]; - console.log(`DEBUG: Found steps in Int node: ${steps}`); - } - - // If not found, look for specific value of 30 (your target steps) - if (steps === 'Unknown') { - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (value === 30) { // Target the specific value you mentioned - steps = value; - console.log(`DEBUG: Found target steps value 30 in node ${node.id} (${node.type}) at index ${i}`); - break; - } - } - if (steps !== 'Unknown') break; - } - } - } - - // Fallback: reasonable step values, prefer 10-150 range - if (steps === 'Unknown') { - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (typeof value === 'number' && value >= 10 && value <= 150) { - console.log(`DEBUG: Found good steps value ${value} in node ${node.id} (${node.type}) at index ${i}`); - steps = value; - console.log(`DEBUG: Set steps to ${value}`); - break; - } - } - if (steps !== 'Unknown') break; - } - } - } - - // Sampler - look for valid ComfyUI samplers - console.log('DEBUG: Looking for sampler...'); - const validSamplers = [ - 'euler', 'euler_ancestral', 'heun', 'dpm_2', 'dpm_2_ancestral', 'lms', - 'dpm_fast', 'dpm_adaptive', 'dpmpp_2s_ancestral', 'dpmpp_sde', 'dpmpp_sde_gpu', - 'dpmpp_2m', 'dpmpp_2m_sde', 'dpmpp_2m_sde_gpu', 'dpmpp_3m_sde', 'dpmpp_3m_sde_gpu', - 'ddim', 'uni_pc', 'uni_pc_bh2' - ]; - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (typeof value === 'string') { - const samplerValue = value.toLowerCase(); - if (validSamplers.some(validSampler => samplerValue.includes(validSampler))) { - console.log(`DEBUG: Found sampler ${value} in node ${node.id} (${node.type}) at index ${i}`); - if (sampler === 'Unknown') { - sampler = value; - console.log(`DEBUG: Set sampler to ${value}`); - } - } - } - } - } - } - - // Seed - look for large numbers that could be seeds - console.log('DEBUG: Looking for seed...'); - for (const node of nodes) { - if (node.widgets_values && Array.isArray(node.widgets_values)) { - for (let i = 0; i < node.widgets_values.length; i++) { - const value = node.widgets_values[i]; - if (typeof value === 'number' && value > 100000) { - console.log(`DEBUG: Found potential seed ${value} in node ${node.id} (${node.type}) at index ${i}`); - if (seed === 'Unknown') { - seed = value; - console.log(`DEBUG: Set seed to ${value}`); - } - } - } - } - } - - // If we still don't have sampling params, look at KSampler widgets_values directly - const ksamplerNode = nodes.find(node => - node.type === 'KSampler' || node.type === 'KSamplerAdvanced' - ); - if (ksamplerNode && ksamplerNode.widgets_values) { - const widgets = ksamplerNode.widgets_values; - // KSampler widgets_values order: [seed, control_mode, steps, cfg, sampler_name, scheduler, denoise, ...] - if (seed === 'Unknown' && widgets[0]) seed = widgets[0]; - if (steps === 'Unknown' && widgets[2]) steps = widgets[2]; - if (cfgScale === 'Unknown' && widgets[3]) cfgScale = widgets[3]; - if (sampler === 'Unknown' && widgets[4]) sampler = widgets[4]; - } - } + // Trace prompts, model and sampler settings through the node graph (#75) + const { + checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed, + } = window.ComfyMetadata.extractGenerationParams(comfyData); return { positivePrompt, diff --git a/web/js/comfy-metadata.js b/web/js/comfy-metadata.js new file mode 100644 index 0000000..3670e6c --- /dev/null +++ b/web/js/comfy-metadata.js @@ -0,0 +1,225 @@ +/** + * Generation-parameter extraction from ComfyUI PNG metadata (#75). + * + * Works on the API-format `prompt` graph ({id: {class_type, inputs}}) by + * tracing links from the sampler, instead of guessing by node type or by + * keywords in the text. Falls back to the UI `workflow` graph only for + * fields the API graph could not provide. + * + * Shared by admin.js, gallery.js and metadata.html; also loadable in Node + * for tests. + */ +(function (root) { + "use strict"; + + const PLACEHOLDERS = Object.freeze({ + positivePrompt: "No prompt found", + negativePrompt: "No negative prompt found", + checkpoint: "Unknown", + steps: "Unknown", + cfgScale: "Unknown", + sampler: "Unknown", + seed: "Unknown", + }); + + const MAX_DEPTH = 25; + const MODEL_NAME_KEYS = ["ckpt_name", "unet_name", "model_name", "gguf_name"]; + const TEXT_KEYS = /^(text|string|prompt|value)(_?[a-z0-9]+)?$/i; + const SEED_CONTROL_VALUES = new Set(["fixed", "increment", "decrement", "randomize"]); + + const isLink = (v) => + Array.isArray(v) && + v.length === 2 && + (typeof v[0] === "string" || typeof v[0] === "number") && + typeof v[1] === "number"; + + // KSampler or a guider (CFGGuider); the model link excludes ControlNet-style pass-throughs + const isSampler = (node) => + node && + node.inputs && + isLink(node.inputs.positive) && + isLink(node.inputs.negative) && + isLink(node.inputs.model); + + // SamplerCustomAdvanced-style nodes split settings across noise/sampler/sigmas nodes + const isCustomSampler = (node) => + node && node.inputs && isLink(node.inputs.guider) && isLink(node.inputs.sigmas); + + const workflowNodes = (workflow) => (Array.isArray(workflow && workflow.nodes) ? workflow.nodes : []); + const widgetsOf = (n) => (Array.isArray(n.widgets_values) ? n.widgets_values : []); + + /** + * Tracing context: the API graph plus the text widget of each PromptManager + * node in the UI workflow, keyed by node id. + * + * Images saved before 3.2.3 had every PromptManager node's `text` overwritten + * with the last-run node's combined prompt; the workflow keeps the real value. + */ + function createContext(graph, workflow) { + const widgetText = new Map(); + for (const n of workflowNodes(workflow)) { + const text = widgetsOf(n)[0]; + if (/promptmanager/i.test(n.type || "") && typeof text === "string") { + widgetText.set(String(n.id), text); + } + } + return { graph, widgetText }; + } + + /** Returns [nodeId, node] for a link, or [] when it does not resolve. */ + function follow(ctx, link) { + if (!isLink(link)) return []; + const id = String(link[0]); + const node = ctx.graph[id]; + return node && node.inputs ? [id, node] : []; + } + + /** Resolve a scalar input, following a link to a primitive node if needed. */ + function resolveScalar(ctx, value, depth = 0) { + if (!isLink(value)) return value; + const [, node] = follow(ctx, value); + if (!node || depth > MAX_DEPTH) return undefined; + for (const v of Object.values(node.inputs)) { + const resolved = resolveScalar(ctx, v, depth + 1); + if (resolved !== undefined && typeof resolved !== "object") return resolved; + } + return undefined; + } + + /** Read the first of `keys` from the node a link points to, resolving further links. */ + function resolveLinkedInput(ctx, link, keys) { + const [, node] = follow(ctx, link); + if (!node) return undefined; + const key = keys.find((k) => k in node.inputs); + return key === undefined ? undefined : resolveScalar(ctx, node.inputs[key]); + } + + /** Resolve a string input; text-producing nodes may be chained (concat, primitives). */ + function resolveString(ctx, value, depth = 0) { + if (typeof value === "string") return value; + const [id, node] = follow(ctx, value); + if (!node || depth > MAX_DEPTH) return ""; + if ("text" in node.inputs) return composeNodeText(ctx, id, node, depth + 1); + const delimiter = typeof node.inputs.delimiter === "string" ? node.inputs.delimiter : " "; + return Object.entries(node.inputs) + .filter(([key]) => TEXT_KEYS.test(key)) + .map(([, v]) => resolveString(ctx, v, depth + 1)) + .filter((s) => s.trim()) + .join(delimiter); + } + + /** Text of an encoder node, matching PromptManager's prepend + text + append join. */ + function composeNodeText(ctx, id, node, depth) { + const { inputs } = node; + const raw = ctx.widgetText.has(id) ? ctx.widgetText.get(id) : resolveString(ctx, inputs.text, depth); + const text = raw.trim(); + if (!text) return ""; + const prepend = resolveString(ctx, inputs.prepend_text, depth).trim(); + const append = resolveString(ctx, inputs.append_text, depth).trim(); + // Older saves stored the already-combined prompt in `text` + return [ + prepend && !text.startsWith(prepend) ? prepend : "", + text, + append && !text.endsWith(append) ? append : "", + ] + .filter(Boolean) + .join(" "); + } + + /** Follow a conditioning link upstream to the encoder that produced it. */ + function traceConditioning(ctx, link, role, depth = 0) { + const [id, node] = follow(ctx, link); + if (!node || depth > MAX_DEPTH) return ""; + if ("text" in node.inputs) return composeNodeText(ctx, id, node, depth + 1); + // Pass-through nodes (ControlNet apply, conditioning combine/set area, ...) + const { inputs } = node; + const next = inputs[role] || inputs.conditioning || inputs.conditioning_to || inputs.conditioning_1; + return traceConditioning(ctx, next, role, depth + 1); + } + + /** Follow the sampler's model link upstream (through LoRA loaders etc.) to a loader. */ + function traceModelName(ctx, link, depth = 0) { + const [, node] = follow(ctx, link); + if (!node || depth > MAX_DEPTH) return undefined; + const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string"); + return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, depth + 1); + } + + function findAnyModelName(nodes) { + for (const node of nodes) { + const inputs = (node && node.inputs) || {}; + const key = MODEL_NAME_KEYS.find((k) => typeof inputs[k] === "string"); + if (key) return inputs[key]; + } + return undefined; + } + + function fromPromptGraph(graph, workflow) { + const ctx = createContext(graph, workflow); + const nodes = Object.values(graph); + const custom = nodes.find(isCustomSampler); + const [, guider] = custom ? follow(ctx, custom.inputs.guider) : []; + const sampler = isSampler(guider) ? guider : nodes.find(isSampler); + if (!sampler) return { checkpoint: findAnyModelName(nodes) }; + + const { inputs } = sampler; + const fromCustom = (link, keys) => (custom ? resolveLinkedInput(ctx, link, keys) : undefined); + return { + positivePrompt: traceConditioning(ctx, inputs.positive, "positive"), + negativePrompt: traceConditioning(ctx, inputs.negative, "negative"), + checkpoint: traceModelName(ctx, inputs.model) || findAnyModelName(nodes), + seed: + resolveScalar(ctx, inputs.seed ?? inputs.noise_seed) ?? + fromCustom(custom && custom.inputs.noise, ["noise_seed", "seed"]), + steps: resolveScalar(ctx, inputs.steps) ?? fromCustom(custom && custom.inputs.sigmas, ["steps"]), + cfgScale: resolveScalar(ctx, inputs.cfg), + sampler: + resolveScalar(ctx, inputs.sampler_name) ?? + fromCustom(custom && custom.inputs.sampler, ["sampler_name"]), + }; + } + + /** Best effort for images that only carry the UI workflow (no API graph). */ + function fromWorkflow(workflow) { + const nodes = workflowNodes(workflow); + const result = {}; + + const loader = nodes.find((n) => /checkpointloader/i.test(n.type || "")); + if (loader && typeof widgetsOf(loader)[0] === "string") result.checkpoint = widgetsOf(loader)[0]; + + const sampler = nodes.find((n) => n.type === "KSampler"); + if (sampler) { + const w = widgetsOf(sampler); + // The seed widget is followed by a hidden control_after_generate value + const shift = SEED_CONTROL_VALUES.has(w[1]) ? 1 : 0; + result.seed = w[0]; + result.steps = w[1 + shift]; + result.cfgScale = w[2 + shift]; + result.sampler = w[3 + shift]; + } + return result; + } + + const hasValue = (v) => v !== undefined && v !== null && v !== ""; + + /** + * @param {{prompt?: Object, workflow?: Object}} comfyData - Parsed PNG metadata + * @returns {{positivePrompt, negativePrompt, checkpoint, steps, cfgScale, sampler, seed}} + */ + function extractGenerationParams(comfyData) { + const data = comfyData || {}; + const primary = + data.prompt && typeof data.prompt === "object" ? fromPromptGraph(data.prompt, data.workflow) : {}; + const fallback = fromWorkflow(data.workflow); + const merged = {}; + for (const key of Object.keys(PLACEHOLDERS)) { + const value = hasValue(primary[key]) ? primary[key] : fallback[key]; + merged[key] = hasValue(value) ? value : PLACEHOLDERS[key]; + } + return merged; + } + + const api = { extractGenerationParams, PLACEHOLDERS }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.ComfyMetadata = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/gallery.js b/web/js/gallery.js index 77041be..9966df8 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -1638,181 +1638,10 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } parseWorkflowData(comfyData, imagePath) { - // Extract information from ComfyUI workflow - let checkpoint = 'Unknown'; - let positivePrompt = 'No prompt found'; - let negativePrompt = 'No negative prompt found'; - let steps = 'Unknown'; - let cfgScale = 'Unknown'; - let sampler = 'Unknown'; - let seed = 'Unknown'; - - // Parse prompt data first (more reliable for actual generation parameters) - if (comfyData.prompt) { - const promptNodes = comfyData.prompt; - - // Track all text nodes and try to identify positive/negative - const textNodes = []; - - for (const nodeId in promptNodes) { - const node = promptNodes[nodeId]; - - // Checkpoint - check multiple types - if ((node.class_type === 'CheckpointLoaderSimple' || - node.class_type === 'UNETLoader' || - node.class_type === 'DualCLIPLoader') && node.inputs) { - checkpoint = node.inputs.ckpt_name || node.inputs.unet_name || checkpoint; - } - - // Collect all text nodes for analysis - if (node.inputs && node.inputs.text) { - textNodes.push({ - nodeId: nodeId, - classType: node.class_type, - text: node.inputs.text, - inputs: node.inputs - }); - } - - // Special handling for PromptManager nodes - if (node.class_type === 'PromptManager' && node.inputs) { - // PromptManager always contains the positive prompt - if (node.inputs.text) { - positivePrompt = node.inputs.text; - } - // Also check for loaded prompt - if (node.inputs.selected_prompt) { - positivePrompt = node.inputs.selected_prompt; - } - } - - // PromptManagerText node - if (node.class_type === 'PromptManagerText' && node.inputs) { - if (node.inputs.text) { - positivePrompt = node.inputs.text; - } - if (node.inputs.selected_prompt) { - positivePrompt = node.inputs.selected_prompt; - } - } - - // Sampling parameters - check multiple sampler types - if ((node.class_type === 'KSampler' || - node.class_type === 'KSamplerAdvanced' || - node.class_type === 'SamplerCustom' || - node.class_type === 'SamplerCustomAdvanced') && node.inputs) { - steps = node.inputs.steps || steps; - cfgScale = node.inputs.cfg || cfgScale; - sampler = node.inputs.sampler_name || sampler; - seed = node.inputs.seed || node.inputs.noise_seed || seed; - } - - // New ComfyUI node types for modern workflows - if (node.class_type === 'CFGGuider' && node.inputs && node.inputs.cfg) { - cfgScale = node.inputs.cfg; - } - - if (node.class_type === 'BasicScheduler' && node.inputs && node.inputs.steps) { - steps = node.inputs.steps; - } - - if (node.class_type === 'KSamplerSelect' && node.inputs && node.inputs.sampler_name) { - sampler = node.inputs.sampler_name; - } - - if ((node.class_type === 'RandomNoise' || node.class_type === 'SeedHistory') && node.inputs) { - if (node.inputs.noise_seed) { - // RandomNoise nodes might reference other nodes, resolve if needed - seed = Array.isArray(node.inputs.noise_seed) ? node.inputs.noise_seed[0] : node.inputs.noise_seed; - } else if (node.inputs.seed) { - seed = node.inputs.seed; - } - } - } - - // Process collected text nodes to identify positive/negative prompts - // if we haven't found them from PromptManager - for (const textNode of textNodes) { - const text = textNode.text; - // Ensure text is a string before calling toLowerCase - if (!text || typeof text !== 'string') { - continue; - } - const textLower = text.toLowerCase(); - - // Skip if this is the PromptManager text we already have - if (textNode.classType === 'PromptManager' || textNode.classType === 'PromptManagerText') { - continue; - } - - // Identify negative prompts by common patterns - if (textNode.classType === 'CLIPTextEncode') { - if (textLower.includes('bad anatomy') || textLower.includes('unfinished') || - textLower.includes('censored') || textLower.includes('weird anatomy') || - textLower.includes('negative') || textLower.includes('embedding:') || - textLower.includes('worst quality') || textLower.includes('low quality')) { - negativePrompt = text; - } else if (positivePrompt === 'No prompt found') { - // If we haven't found a positive prompt yet, this might be it - positivePrompt = text; - } - } - } - } - - // Also try to parse from workflow data if we didn't find everything in prompt - if (comfyData.workflow && comfyData.workflow.nodes) { - for (const node of comfyData.workflow.nodes) { - // Checkpoint loaders - if ((node.type === 'CheckpointLoaderSimple' || - node.type === 'UNETLoader' || - node.type === 'DualCLIPLoader') && node.widgets_values) { - checkpoint = node.widgets_values[0] || checkpoint; - } - - // PromptManager nodes in workflow - if (node.type === 'PromptManager' && node.widgets_values) { - // The first widget value is usually the text - if (node.widgets_values[0] && positivePrompt === 'No prompt found') { - positivePrompt = node.widgets_values[0]; - } - } - - if (node.type === 'PromptManagerText' && node.widgets_values) { - if (node.widgets_values[0] && positivePrompt === 'No prompt found') { - positivePrompt = node.widgets_values[0]; - } - } - - // CLIPTextEncode nodes - if (node.type === 'CLIPTextEncode' && node.widgets_values && node.widgets_values[0]) { - const text = node.widgets_values[0]; - // Ensure text is a string before calling toLowerCase - if (text && typeof text === 'string') { - const textLower = text.toLowerCase(); - - if (textLower.includes('bad anatomy') || textLower.includes('unfinished') || - textLower.includes('censored') || textLower.includes('negative') || - textLower.includes('worst quality') || textLower.includes('low quality')) { - negativePrompt = text; - } else if (positivePrompt === 'No prompt found') { - positivePrompt = text; - } - } - } - - // Samplers - if ((node.type === 'KSampler' || node.type === 'KSamplerAdvanced' || - node.type === 'SamplerCustom' || node.type === 'SamplerCustomAdvanced') && node.widgets_values) { - if (node.widgets_values.length >= 4) { - seed = node.widgets_values[0] || seed; - steps = node.widgets_values[1] || steps; - cfgScale = node.widgets_values[2] || cfgScale; - sampler = node.widgets_values[3] || sampler; - } - } - } - } + // Trace prompts, model and sampler settings through the node graph (#75) + const { + checkpoint, positivePrompt, negativePrompt, steps, cfgScale, sampler, seed, + } = window.ComfyMetadata.extractGenerationParams(comfyData); return { checkpoint, diff --git a/web/metadata.html b/web/metadata.html index 153b59b..ed33a21 100644 --- a/web/metadata.html +++ b/web/metadata.html @@ -5,6 +5,7 @@ Image Gallery - Generation Data +