Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cab776af6e | ||
|
|
093a343b9c | ||
|
|
63ac68c580 | ||
|
|
c5a708a0f5 |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||

|
||||
|
||||
@@ -430,7 +430,7 @@ Organize and filter your prompt library by output subdirectory. The dashboard se
|
||||
|
||||

|
||||
|
||||
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
@@ -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": (
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"]
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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}")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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()}`;
|
||||
|
||||
Reference in New Issue
Block a user