From aa768f57dd035c15caf59e179b4134c952b2a281 Mon Sep 17 00:00:00 2001 From: Vito Date: Wed, 7 Oct 2026 11:27:40 -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=20?= =?UTF-8?q?(#151)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: release 3.2.3 — metadata parsing, null-safe saves, auto-hide, tag search - 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 * fix: bound metadata graph traversal to prevent tab freeze on crafted PNGs Link resolution tried every linked input recursively with only a depth cap, so a PNG whose prompt graph has cycles and fan-out could force ~inputs^25 paths and hang the gallery/metadata page. Each lookup now shares a visited set (each node expanded once) with a hard cap of 500 visits. * fix: read the matching output of multi-output settings nodes KSampler settings linked to a node like SamplerCombo all resolved to its first value, so steps, cfg and sampler showed the sampler name. Scalar resolution now prefers the linked node's input with the same field name. * fix: address review — trust API text, restore workflow-only parsing - Workflow widget text now only replaces API text for metadata showing the pre-3.2.3 corruption signature (2+ PromptManager nodes with identical API text but different widgets). Otherwise the API graph wins, so expanded dynamic prompts and linked text display what actually ran. - Images with only a UI workflow are converted to the API graph shape and traced the same way: prompts, KSamplerAdvanced, UNET/checkpoint loaders, KJNodes Set/Get, Reroute, bypassed and muted nodes. --- .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 | 329 +++++++++++++++++++++++++++++ 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 | 338 ++++++++++++++++++++++++++++++ web/js/gallery.js | 179 +--------------- web/metadata.html | 185 +--------------- web/prompt_manager.js | 37 +++- 20 files changed, 929 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 0c07dbaf..0c27c74a 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 5f960f91..9e9fa91b 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 a1eceffe..ffbec23a 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 6e0736c4..73bc061b 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 7c0ddbd7..ae9b6a78 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 bc5632b3..4a47eeda 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 4fc861b4..a53431b7 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 00000000..b6340023 --- /dev/null +++ b/tests/js/comfy-metadata.test.js @@ -0,0 +1,329 @@ +// 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("current metadata: API text wins over a stale or unexpanded workflow widget", () => { + // Dynamic prompt expanded at queue time: API holds what ran, workflow holds the template + const prompt = { + 1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } }, + 20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, + 39: { class_type: "PromptManager", inputs: { text: "a red cat", prepend_text: "", append_text: "" } }, + 40: { class_type: "PromptManager", inputs: { text: "blurry", prepend_text: "", append_text: "" } }, + }; + const workflow = { + nodes: [ + { id: 39, type: "PromptManager", widgets_values: ["a {red|blue} cat", "", "", "", "", ""] }, + { id: 40, type: "PromptManager", widgets_values: ["blurry", "", "", "", "", ""] }, + ], + }; + const r = extractGenerationParams({ prompt, workflow }); + assert.equal(r.positivePrompt, "a red cat"); + assert.equal(r.negativePrompt, "blurry"); +}); + +test("current metadata: linked text is followed, not replaced by the widget's stale value", () => { + const prompt = { + 1: { class_type: "KSampler", inputs: { positive: ["39", 0], negative: ["40", 0], model: ["20", 0] } }, + 20: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, + 39: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "", append_text: "" } }, + 40: { class_type: "PromptManager", inputs: { text: ["50", 0], prepend_text: "bad,", append_text: "" } }, + 50: { class_type: "PrimitiveString", inputs: { value: "shared text" } }, + }; + const workflow = { + nodes: [ + { id: 39, type: "PromptManager", widgets_values: ["old value A", "", "", "", "", ""] }, + { id: 40, type: "PromptManager", widgets_values: ["old value B", "", "", "", "bad,", ""] }, + ], + }; + const r = extractGenerationParams({ prompt, workflow }); + assert.equal(r.positivePrompt, "shared text"); +}); + +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("multi-output settings nodes (SamplerCombo) map each field to its own value", () => { + const prompt = { + 5: { + class_type: "KSampler", + inputs: { + seed: ["162", 0], steps: ["165", 2], cfg: ["165", 3], sampler_name: ["165", 0], + positive: ["6", 0], negative: ["7", 0], model: ["4", 0], + }, + }, + 4: { class_type: "CheckpointLoaderSimple", inputs: { ckpt_name: "m.safetensors" } }, + 6: { class_type: "CLIPTextEncode", inputs: { text: "a cat" } }, + 7: { class_type: "CLIPTextEncode", inputs: { text: "blurry" } }, + 162: { class_type: "SeedHistory", inputs: { seed: 1495646836, seed_history_ui: "" } }, + 165: { class_type: "SamplerCombo", inputs: { sampler_name: "euler_ancestral", scheduler: "normal", steps: 50, cfg: 7 } }, + }; + const r = extractGenerationParams({ prompt }); + assert.deepEqual([r.seed, r.steps, r.cfgScale, r.sampler], [1495646836, 50, 7, "euler_ancestral"]); +}); + +test("workflow-only images account for the hidden control_after_generate widget", () => { + const workflow = { + nodes: [ + { id: 1, type: "CheckpointLoader|pysssss", widgets_values: ["model.safetensors", "[none]"] }, + { id: 2, 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("cyclic, fan-out graphs from crafted PNGs resolve quickly", () => { + // Every node links to itself and its neighbour through several inputs and has + // no scalars: without a visited set this explores ~inputs^MAX_DEPTH paths. + const loop = (id, next) => ({ + class_type: "Evil", + inputs: { + text_a: [id, 0], text_b: [next, 0], text_c: [id, 0], text_d: [next, 0], + value_a: [id, 0], value_b: [next, 0], model: [next, 0], + }, + }); + const prompt = { + 1: { class_type: "KSampler", inputs: { seed: ["2", 0], steps: ["3", 0], positive: ["2", 0], negative: ["3", 0], model: ["2", 0] } }, + 2: loop("2", "3"), + 3: loop("3", "2"), + }; + const started = Date.now(); + const r = extractGenerationParams({ prompt }); + assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`); + assert.equal(r.positivePrompt, "No prompt found"); + assert.equal(r.seed, "Unknown"); +}); + +test("chained encoders whose prepend and append both link onward stay linear", () => { + const prompt = { 1: { class_type: "KSampler", inputs: { positive: ["n0", 0], negative: ["n0", 0], model: ["n0", 0] } } }; + for (let i = 0; i < 60; i++) { + prompt[`n${i}`] = { + class_type: "PromptManager", + inputs: { text: `t${i}`, prepend_text: [`n${i + 1}`, 0], append_text: [`n${i + 1}`, 0] }, + }; + } + const started = Date.now(); + extractGenerationParams({ prompt }); + assert.ok(Date.now() - started < 500, `took ${Date.now() - started}ms`); +}); + +// UI-format link: [id, from_node, from_slot, to_node, to_slot, type] +test("workflow-only: traces prompts, model and KSamplerAdvanced settings through Set/Get and Reroute", () => { + const workflow = { + nodes: [ + { id: 1, type: "UNETLoader", widgets_values: ["flux.safetensors", "default"], outputs: [{ links: [1] }] }, + { id: 2, type: "SetNode", widgets_values: ["model"], inputs: [{ name: "MODEL", link: 1 }] }, + { id: 3, type: "GetNode", widgets_values: ["model"], outputs: [{ links: [2] }] }, + { id: 4, type: "PromptManager", widgets_values: ["a castle", "", "", "", "masterpiece,", ""], inputs: [], outputs: [{ links: [3] }] }, + { id: 5, type: "Reroute", inputs: [{ name: "", link: 3 }], outputs: [{ links: [4] }] }, + { id: 6, type: "CLIPTextEncode", widgets_values: ["lowres"], outputs: [{ links: [5] }] }, + { + id: 7, type: "KSamplerAdvanced", + inputs: [{ name: "model", link: 2 }, { name: "positive", link: 4 }, { name: "negative", link: 5 }], + widgets_values: ["enable", 77, "randomize", 30, 6.5, "dpmpp_2m", "karras", 0, 10000, "disable"], + }, + ], + links: [ + [1, 1, 0, 2, 0, "MODEL"], [2, 3, 0, 7, 0, "MODEL"], [3, 4, 0, 5, 0, "CONDITIONING"], + [4, 5, 0, 7, 1, "CONDITIONING"], [5, 6, 0, 7, 2, "CONDITIONING"], + ], + }; + const r = extractGenerationParams({ workflow }); + assert.deepEqual( + [r.positivePrompt, r.negativePrompt, r.checkpoint, r.seed, r.steps, r.cfgScale, r.sampler], + ["masterpiece, a castle", "lowres", "flux.safetensors", 77, 30, 6.5, "dpmpp_2m"], + ); +}); + +test("workflow-only: widgets_values_named and widget-backed inputs take precedence over positions", () => { + const workflow = { + nodes: [ + { id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] }, + { id: 2, type: "CLIPTextEncode", widgets_values_named: { text: "named text" }, widgets_values: ["ignored"], outputs: [{ links: [2] }] }, + { id: 3, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [3] }] }, + { + id: 4, type: "KSampler", + inputs: [ + { name: "model", link: 1 }, { name: "positive", link: 2 }, { name: "negative", link: 3 }, + { name: "seed", widget: { name: "seed" }, link: null }, + { name: "steps", widget: { name: "steps" }, link: null }, + { name: "cfg", widget: { name: "cfg" }, link: null }, + { name: "sampler_name", widget: { name: "sampler_name" }, link: null }, + ], + widgets_values: [5, "fixed", 12, 4, "euler", "normal", 1], + }, + ], + links: [[1, 1, 0, 4, 0, "MODEL"], [2, 2, 0, 4, 1, "CONDITIONING"], [3, 3, 0, 4, 2, "CONDITIONING"]], + }; + const r = extractGenerationParams({ workflow }); + assert.deepEqual([r.positivePrompt, r.seed, r.steps, r.cfgScale, r.sampler], ["named text", 5, 12, 4, "euler"]); +}); + +test("workflow-only: bypassed nodes pass through, muted nodes are ignored", () => { + const workflow = { + nodes: [ + { id: 1, type: "CheckpointLoaderSimple", widgets_values: ["m.safetensors"], outputs: [{ links: [1] }] }, + { id: 2, type: "LoraLoader", mode: 4, inputs: [{ name: "model", type: "MODEL", link: 1 }], outputs: [{ type: "MODEL", links: [2] }] }, + { id: 3, type: "CLIPTextEncode", widgets_values: ["pos"], outputs: [{ links: [3] }] }, + { id: 4, type: "CLIPTextEncode", widgets_values: ["neg"], outputs: [{ links: [4] }] }, + { id: 5, type: "KSampler", mode: 2, inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [9, "fixed", 99, 9, "ddim"] }, + { id: 6, type: "KSampler", inputs: [{ name: "model", link: 2 }, { name: "positive", link: 3 }, { name: "negative", link: 4 }], widgets_values: [1, "fixed", 20, 7, "euler"] }, + ], + links: [[1, 1, 0, 2, 0, "MODEL"], [2, 2, 0, 6, 0, "MODEL"], [3, 3, 0, 6, 1, "CONDITIONING"], [4, 4, 0, 6, 2, "CONDITIONING"]], + }; + const r = extractGenerationParams({ workflow }); + assert.deepEqual([r.checkpoint, r.positivePrompt, r.steps], ["m.safetensors", "pos", 20]); +}); + +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 54ba8f79..f9853537 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 00000000..454e2407 --- /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 68261192..8f1707e3 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 2468161b..95a4e7dc 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 edf3fcc9..cd6aa1c1 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 ecccce00..6bb850aa 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 be39c7ab..78eb7629 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 0a7d2ae3..8be9f068 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 00000000..6c748a0b --- /dev/null +++ b/web/js/comfy-metadata.js @@ -0,0 +1,338 @@ +/** + * 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", + }); + + // Upper bound on nodes expanded per lookup; crafted PNGs can contain any graph + const MAX_VISITS = 500; + 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 recovered text for PromptManager nodes + * whose saved `text` was corrupted. + * + * Before 3.2.3 a SaveImage patch overwrote every PromptManager node's `text` + * with the last-run node's prompt. The signature is two or more PromptManager + * nodes with identical API text whose workflow widgets differ; only those get + * their workflow widget text back. Otherwise the API graph is what actually ran + * (expanded dynamic prompts, linked text) and always wins. + */ + function createContext(graph, workflow) { + const widgets = new Map(); + for (const n of workflowNodes(workflow)) { + const text = widgetsOf(n)[0]; + if (/promptmanager/i.test(n.type || "") && typeof text === "string") { + widgets.set(String(n.id), text); + } + } + + const byApiText = new Map(); + for (const id of widgets.keys()) { + const apiText = graph[id] && graph[id].inputs && graph[id].inputs.text; + if (typeof apiText !== "string") continue; + byApiText.set(apiText, [...(byApiText.get(apiText) || []), id]); + } + + const widgetText = new Map(); + for (const ids of byApiText.values()) { + const distinctWidgets = new Set(ids.map((id) => widgets.get(id))); + if (ids.length > 1 && distinctWidgets.size > 1) { + ids.forEach((id) => widgetText.set(id, widgets.get(id))); + } + } + return { graph, widgetText }; + } + + /** + * Returns [nodeId, node] for a link, or [] when it does not resolve or the node + * was already expanded in this lookup. The shared `seen` set keeps every lookup + * linear in graph size, even for cyclic or heavily fanned-out graphs. + */ + function enter(ctx, link, seen) { + if (!isLink(link) || seen.size >= MAX_VISITS) return []; + const id = String(link[0]); + const node = ctx.graph[id]; + if (!node || !node.inputs || seen.has(id)) return []; + seen.add(id); + return [id, node]; + } + + /** + * Resolve a scalar input, following a link to a primitive node if needed. + * Multi-output settings nodes (e.g. SamplerCombo) expose several values, so an + * input with the same `name` as the requested field wins over the first scalar. + */ + function resolveScalar(ctx, value, name, seen = new Set()) { + if (!isLink(value)) return value; + const [, node] = enter(ctx, value, seen); + if (!node) return undefined; + if (name && Object.prototype.hasOwnProperty.call(node.inputs, name)) { + return resolveScalar(ctx, node.inputs[name], name, seen); + } + for (const v of Object.values(node.inputs)) { + const resolved = resolveScalar(ctx, v, name, seen); + 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 seen = new Set(); + const [, node] = enter(ctx, link, seen); + if (!node) return undefined; + const key = keys.find((k) => k in node.inputs); + return key === undefined ? undefined : resolveScalar(ctx, node.inputs[key], key, seen); + } + + /** Resolve a string input; text-producing nodes may be chained (concat, primitives). */ + function resolveString(ctx, value, seen = new Set()) { + if (typeof value === "string") return value; + const [id, node] = enter(ctx, value, seen); + if (!node) return ""; + if ("text" in node.inputs) return composeNodeText(ctx, id, node, seen); + 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, seen)) + .filter((s) => s.trim()) + .join(delimiter); + } + + /** Text of an encoder node, matching PromptManager's prepend + text + append join. */ + function composeNodeText(ctx, id, node, seen) { + const { inputs } = node; + const raw = ctx.widgetText.has(id) ? ctx.widgetText.get(id) : resolveString(ctx, inputs.text, seen); + const text = raw.trim(); + if (!text) return ""; + // One shared set even across siblings: copying it per branch reintroduces + // exponential work on crafted chains + const prepend = resolveString(ctx, inputs.prepend_text, seen).trim(); + const append = resolveString(ctx, inputs.append_text, seen).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, seen = new Set()) { + const [id, node] = enter(ctx, link, seen); + if (!node) return ""; + if ("text" in node.inputs) return composeNodeText(ctx, id, node, seen); + // 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, seen); + } + + /** Follow the sampler's model link upstream (through LoRA loaders etc.) to a loader. */ + function traceModelName(ctx, link, seen = new Set()) { + const [, node] = enter(ctx, link, seen); + if (!node) return undefined; + const key = MODEL_NAME_KEYS.find((k) => typeof node.inputs[k] === "string"); + return key ? node.inputs[key] : traceModelName(ctx, node.inputs.model, seen); + } + + 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 ? enter(ctx, custom.inputs.guider, new Set()) : []; + const sampler = isSampler(guider) ? guider : nodes.find(isSampler); + if (!sampler) { + const loose = nodes.find((n) => n && n.inputs && /ksampler/i.test(n.class_type || "")); + const inputs = (loose && loose.inputs) || {}; + return { + checkpoint: findAnyModelName(nodes), + seed: resolveScalar(ctx, inputs.seed ?? inputs.noise_seed, "seed"), + steps: resolveScalar(ctx, inputs.steps, "steps"), + cfgScale: resolveScalar(ctx, inputs.cfg, "cfg"), + sampler: resolveScalar(ctx, inputs.sampler_name, "sampler_name"), + }; + } + + 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, "seed" in inputs ? "seed" : "noise_seed") ?? + fromCustom(custom && custom.inputs.noise, ["noise_seed", "seed"]), + steps: resolveScalar(ctx, inputs.steps, "steps") ?? fromCustom(custom && custom.inputs.sigmas, ["steps"]), + cfgScale: resolveScalar(ctx, inputs.cfg, "cfg"), + sampler: + resolveScalar(ctx, inputs.sampler_name, "sampler_name") ?? + fromCustom(custom && custom.inputs.sampler, ["sampler_name"]), + }; + } + + // Widget order for core nodes, used when a workflow carries no widget names + const PROMPT_MANAGER_WIDGETS = ["text", "category", "tags", "search_text", "prepend_text", "append_text"]; + const WIDGET_POSITIONS = { + KSampler: ["seed", "steps", "cfg", "sampler_name", "scheduler", "denoise"], + KSamplerAdvanced: [ + "add_noise", "noise_seed", "steps", "cfg", "sampler_name", "scheduler", + "start_at_step", "end_at_step", "return_with_leftover_noise", + ], + CLIPTextEncode: ["text"], + PromptManager: PROMPT_MANAGER_WIDGETS, + PromptManagerText: PROMPT_MANAGER_WIDGETS, + CheckpointLoader: ["config_name", "ckpt_name"], + UNETLoader: ["unet_name", "weight_dtype"], + }; + const PASS_THROUGH_TYPES = new Set(["Reroute", "GetNode", "SetNode"]); + const MODE_MUTED = 2; + const MODE_BYPASSED = 4; + + function widgetNames(n) { + const fromInputs = (n.inputs || []).filter((i) => i && i.widget && i.widget.name).map((i) => i.widget.name); + if (fromInputs.length) return fromInputs; + if (WIDGET_POSITIONS[n.type]) return WIDGET_POSITIONS[n.type]; + if (/checkpointloader/i.test(n.type || "")) return ["ckpt_name"]; + if (/unet/i.test(n.type || "")) return ["unet_name"]; + return []; + } + + /** Map a workflow node's widget values to input names, skipping control_after_generate. */ + function namedWidgets(n) { + const named = n.widgets_values_named; + if (named && typeof named === "object" && !Array.isArray(named)) return { ...named }; + const values = widgetsOf(n); + const out = {}; + let v = 0; + for (const name of widgetNames(n)) { + if (v >= values.length) break; + out[name] = values[v++]; + if (/seed$/.test(name) && SEED_CONTROL_VALUES.has(values[v])) v++; + } + return out; + } + + /** + * Convert a UI workflow ({nodes, links}) into the API graph shape so images + * without a `prompt` chunk are traced the same way. Set/Get pairs, Reroutes and + * bypassed nodes are resolved to their real source; muted nodes are dropped. + */ + function workflowToGraph(workflow) { + const nodes = workflowNodes(workflow).filter((n) => n && n.id !== undefined && n.id !== null); + const byId = new Map(nodes.map((n) => [String(n.id), n])); + const links = new Map(); + for (const l of Array.isArray(workflow && workflow.links) ? workflow.links : []) { + if (Array.isArray(l)) links.set(l[0], { from: l[1], slot: l[2], type: l[5] }); + else if (l && typeof l === "object") links.set(l.id, { from: l.origin_id, slot: l.origin_slot, type: l.type }); + } + const setters = new Map(); + for (const n of nodes) { + if (n.type === "SetNode") setters.set(widgetsOf(n)[0], n.inputs && n.inputs[0] && n.inputs[0].link); + } + + const source = (linkId, hops = 0) => { + const link = links.get(linkId); + const node = link && byId.get(String(link.from)); + if (!node || node.mode === MODE_MUTED || hops > MAX_VISITS) return undefined; + const next = (id) => source(id, hops + 1); + if (node.type === "Reroute") return next(node.inputs && node.inputs[0] && node.inputs[0].link); + if (node.type === "GetNode") return next(setters.get(widgetsOf(node)[0])); + if (node.mode === MODE_BYPASSED) { + const same = (node.inputs || []).find( + (i) => i && i.link !== null && i.link !== undefined && (!link.type || link.type === "*" || i.type === link.type), + ); + return same ? next(same.link) : undefined; + } + return [String(node.id), link.slot]; + }; + + const graph = {}; + for (const n of nodes) { + if (n.mode === MODE_MUTED || n.mode === MODE_BYPASSED || PASS_THROUGH_TYPES.has(n.type)) continue; + const inputs = namedWidgets(n); + for (const i of n.inputs || []) { + const src = i && i.link !== null && i.link !== undefined ? source(i.link) : undefined; + if (src) inputs[i.name] = src; + } + graph[String(n.id)] = { class_type: n.type, inputs }; + } + return graph; + } + + 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 = data.workflow ? fromPromptGraph(workflowToGraph(data.workflow), null) : {}; + 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 77041be8..9966df8d 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 153b59bd..ed33a212 100644 --- a/web/metadata.html +++ b/web/metadata.html @@ -5,6 +5,7 @@ Image Gallery - Generation Data +