Compare commits

...
Author SHA1 Message Date
Vito Sansevero cab776af6e 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.
2026-10-07 11:25:36 -07:00
Vito Sansevero 093a343b9c 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.
2026-10-07 11:12:25 -07:00
Vito Sansevero 63ac68c580 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.
2026-10-07 10:27:38 -07:00
Vito Sansevero c5a708a0f5 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
2026-10-07 10:25:02 -07:00
20 changed files with 929 additions and 796 deletions
+9
View File
@@ -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
+3 -3
View File
@@ -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 `<lora:name:weight>` 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
+1 -1
View File
@@ -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": (
+1 -1
View File
@@ -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": (
+13 -1
View File
@@ -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"]
+9 -9
View File
@@ -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(
+1 -1
View File
@@ -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"]
+329
View File
@@ -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");
});
+17
View File
@@ -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}")
+69
View File
@@ -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()
+42
View File
@@ -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()
+11 -108
View File
@@ -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.
+36
View File
@@ -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.
+2 -1
View File
@@ -770,7 +770,7 @@
<li>This will scan all PNG files in your ComfyUI output directory</li>
<li>Depending on size, this could take several minutes</li>
<li>This is experimental — we recommend backing up first</li>
<li>Only images with ComfyUI metadata will be processed</li>
<li>Only images with ComfyUI or A1111/Forge-style metadata will be processed</li>
<li>Duplicate prompts will be automatically detected and skipped</li>
</ul>
</div>
@@ -1390,6 +1390,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
</div>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<script src="/prompt_manager/js/admin.js"></script>
<script src="/prompt_manager/js/tags-page.js"></script>
</body>
+1
View File
@@ -803,6 +803,7 @@ No sentences, no commentary, no captions. Only tags. Keep length short but descr
</div>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<script src="/prompt_manager/js/gallery.js"></script>
</body>
</html>
+9 -308
View File
@@ -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,
+338
View File
@@ -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);
+4 -175
View File
@@ -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,
+5 -180
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Gallery - Generation Data</title>
<script src="/prompt_manager/js/theme-bridge.js"></script>
<script src="/prompt_manager/js/comfy-metadata.js"></script>
<link rel="stylesheet" href="lib/tailwind/comfyui-theme.css">
<link rel="stylesheet" href="lib/tailwind/styles.css">
<style>
@@ -202,186 +203,10 @@ Seed: ${currentWorkflowData.seed || 'Unknown'}`;
const dataPanel = document.getElementById('data-panel-content');
if (!dataPanel) return;
// 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...');
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];
console.log(`Node ${nodeId}:`, node.class_type, node);
// 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;
console.log('Found PromptManager with text:', positivePrompt.substring(0, 100));
}
// 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;
console.log('Found negative prompt:', negativePrompt.substring(0, 100));
} else if (positivePrompt === 'No prompt found') {
// If we haven't found a positive prompt yet, this might be it
positivePrompt = text;
console.log('Found potential positive prompt:', positivePrompt.substring(0, 100));
}
}
}
}
// 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);
// Update the HTML
dataPanel.innerHTML = `
+29 -8
View File
@@ -19,6 +19,24 @@ function escapeHtml(text) {
return div.innerHTML;
}
/**
* Split node search input into free text and tag filters (#150).
* `tag:portrait tag:"film grain" sunset` -> { text: "sunset", tags: ["portrait", "film grain"] }
* @param {string} input - Raw search_text widget value
* @returns {{text: string, tags: string[]}}
*/
function parseSearchQuery(input) {
const tags = [];
const text = (input || "")
.replace(/\btag:(?:"([^"]+)"|(\S+))/gi, (_, quoted, bare) => {
tags.push((quoted || bare).trim());
return " ";
})
.replace(/\s+/g, " ")
.trim();
return { text, tags: tags.filter(Boolean) };
}
/**
* Create a DOM element with optional attributes and children
* @param {string} tag - HTML tag name
@@ -601,20 +619,22 @@ app.registerExtension({
this.resultsSection.innerHTML =
'<div style="color: #999; text-align: center;">🔍 Searching database...</div>';
// Get search criteria from node widgets (only search_text available now)
const searchText =
this.widgets?.find((w) => w.name === "search_text")?.value || "";
// search_text supports free text plus tag:name filters
const { text, tags } = parseSearchQuery(
this.widgets?.find((w) => w.name === "search_text")?.value || "",
);
// Validate search criteria
if (!searchText.trim()) {
if (!text && tags.length === 0) {
this.resultsSection.innerHTML =
'<div style="color: #f9a825; text-align: center;">⚠️ Please enter search text</div>';
'<div style="color: #f9a825; text-align: center;">⚠️ Enter search text or tag:name</div>';
return;
}
// Call the backend search function
const response = await this.callNodeMethod("search_prompts", {
search_text: searchText,
search_text: text,
tags,
});
this.displayResults(response.results || []);
@@ -670,7 +690,7 @@ app.registerExtension({
if (response.ok) {
const data = await response.json();
if (data.success && data.settings) {
this.properties.resultTimeout = data.settings.result_timeout || 3;
this.properties.resultTimeout = data.settings.result_timeout ?? 3;
this.properties.showTestButton =
data.settings.show_test_button || false;
this.properties.webuiDisplayMode =
@@ -948,10 +968,11 @@ app.registerExtension({
options = { method: "GET" };
if (method === "search_prompts") {
// Build query string for search (simplified - only text search)
const queryParams = new URLSearchParams();
if (params.search_text)
queryParams.append("text", params.search_text);
if (params.tags?.length)
queryParams.append("tags", params.tags.join(","));
queryParams.append("limit", "50");
url = `/prompt_manager/search?${queryParams.toString()}`;