fix: link images and count runs by the positive prompt, not node execution

With a negative-prompt PromptManager node in the workflow, every executed
PromptManager node pushed onto the image-linking queue, so images could be
linked to the negative prompt or to a previous run. ComfyUI also skips
unchanged nodes, so re-runs of the same prompt were never counted.

- utils/prompt_graph.py finds the PromptManager nodes feeding a sampler's
  positive input (KSampler, CFG/Basic guiders, ControlNet and combine
  pass-throughs, PromptManagerText into encoders), falling back to all
  PromptManager nodes when none can be traced.
- Runs are counted by a ComfyUI on-prompt hook when a workflow is queued;
  prompts not yet saved are counted when the node first saves them.
- Nodes receive the queued graph (hidden PROMPT/UNIQUE_ID). Only the run's
  positive prompt becomes the current prompt; only batch items with linked
  text are pushed onto the FIFO queue.
- The image monitor links from the image's own metadata first, using the
  positive node, and falls back to the queue for batch items.

Refs #90
This commit is contained in:
Vito Sansevero
2026-10-07 12:35:08 -07:00
parent 94a9260f89
commit 0e8108ae9b
13 changed files with 1033 additions and 73 deletions
+15
View File
@@ -111,6 +111,21 @@ except Exception as e:
except Exception:
print(f"[ComfyUI-PromptManager] Warning: Failed to start image monitoring: {e}")
# Count prompt runs when workflows are queued (ComfyUI skips unchanged nodes, so
# node execution alone misses re-runs). Separate from monitoring so either can fail.
try:
from .py.config import server_instance
from .utils.usage_tracking import register_queue_hook
register_queue_hook(server_instance, lambda: _global_db)
except Exception as e:
try:
from .utils.logging_config import get_logger
get_logger("prompt_manager.init").error(f"Failed to register usage hook: {e}")
except Exception:
print(f"[ComfyUI-PromptManager] Warning: Failed to register usage hook: {e}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# Print startup message with loaded tools
+23 -21
View File
@@ -4,7 +4,7 @@ with persistent prompt storage and search capabilities.
"""
import time
from typing import Any, Tuple
from typing import Any, Dict, Optional, Tuple
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
@@ -94,6 +94,12 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
},
),
},
"hidden": {
# The queued graph and this node's id: used to tell the positive
# prompt of a run from negative-prompt PromptManager nodes
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = (IO.CONDITIONING, IO.STRING)
@@ -119,6 +125,8 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
prompt: Optional[Dict[str, Any]] = None,
unique_id: Optional[str] = None,
) -> Tuple[Any]:
"""
Encode the text prompt and save it to the database.
@@ -181,30 +189,24 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
extended_tags.append(f"append:{append_text.strip()[:50]}")
try:
prompt_id = self._save_prompt_to_database(
prompt_id = self._track_prompt_execution(
text=storage_text.strip(),
encoding_text=encoding_text.strip(),
category=category.strip() if category else None,
tags=extended_tags if extended_tags else None,
additional_data={
"category": category.strip() if category else None,
"tags": extended_tags,
"prepend_text": (
prepend_text.strip() if prepend_text else None
),
"append_text": append_text.strip() if append_text else None,
"final_text": encoding_text.strip(),
},
prompt_graph=prompt,
unique_id=unique_id,
)
# Set current prompt for image tracking
if prompt_id:
execution_id = self.prompt_tracker.set_current_prompt(
prompt_text=encoding_text.strip(),
additional_data={
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": (
prepend_text.strip() if prepend_text else None
),
"append_text": append_text.strip() if append_text else None,
"final_text": encoding_text.strip(),
},
)
self.logger.debug(
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
)
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
except Exception as e:
# Log error but don't fail the encoding
+66 -3
View File
@@ -24,6 +24,8 @@ try:
from .utils.comfyui_integration import get_comfyui_integration
from .utils.image_monitor import get_image_monitor
from .utils.prompt_tracker import get_prompt_tracker
from .utils.prompt_graph import is_text_linked, run_prompt_nodes
from .utils.usage_tracking import PENDING_FIRST_USE
except ImportError:
import sys
@@ -32,6 +34,8 @@ except ImportError:
from utils.comfyui_integration import get_comfyui_integration
from utils.image_monitor import get_image_monitor
from utils.prompt_tracker import get_prompt_tracker
from utils.prompt_graph import is_text_linked, run_prompt_nodes
from utils.usage_tracking import PENDING_FIRST_USE
class PromptManagerBase:
@@ -56,14 +60,23 @@ class PromptManagerBase:
self.logger.debug(f"{self.__class__.__name__} node initialization completed")
def _save_prompt_to_database(
self, text: str, category: Optional[str] = None, tags: Optional[list] = None
self,
text: str,
category: Optional[str] = None,
tags: Optional[list] = None,
count_run: bool = False,
) -> Optional[int]:
"""Save the prompt to the SQLite database.
Runs are normally counted by the queue hook (utils/usage_tracking.py), so a
save only counts one when asked to (batch items whose text the hook could not
see) or when the hook queued this prompt before it existed.
Args:
text: The prompt text
category: Optional category
tags: List of tags
count_run: Count this execution as a run of the prompt
Returns:
The prompt ID if saved successfully, None otherwise
@@ -82,7 +95,8 @@ class PromptManagerBase:
prompt_id=existing["id"], category=category, tags=tags
)
self.logger.debug("Updated metadata for existing prompt")
self._record_use(existing["id"])
if count_run:
self._record_use(existing["id"])
return existing["id"]
self.logger.debug(
@@ -94,7 +108,9 @@ class PromptManagerBase:
if prompt_id:
self.logger.debug(f"Successfully saved new prompt with ID: {prompt_id}")
self._record_use(prompt_id)
pending = getattr(self, "pending_first_use", PENDING_FIRST_USE)
if count_run or pending.consume(prompt_hash):
self._record_use(prompt_id)
else:
self.logger.warning("Failed to save prompt - no ID returned")
@@ -104,6 +120,53 @@ class PromptManagerBase:
self.logger.error(f"Error saving prompt to database: {e}")
return None
def _prompt_role(self, prompt_graph: Any, unique_id: Any) -> Dict[str, bool]:
"""How this node's execution relates to the run, from the queued graph.
Returns flags:
is_run_prompt: feeds a sampler's positive input (images belong to it)
text_linked: text comes from another node (e.g. PromptSearchList)
known: the graph and node id were available
"""
if not isinstance(prompt_graph, dict) or unique_id is None:
return {"is_run_prompt": True, "text_linked": False, "known": False}
node_id = str(unique_id)
return {
"is_run_prompt": node_id in run_prompt_nodes(prompt_graph),
"text_linked": is_text_linked(prompt_graph, node_id),
"known": True,
}
def _track_prompt_execution(
self,
text: str,
encoding_text: str,
category: Optional[str],
tags: Optional[list],
additional_data: Dict[str, Any],
prompt_graph: Any = None,
unique_id: Any = None,
) -> Optional[int]:
"""Save the prompt and, for the run's positive prompt, register it for linking.
Negative-prompt nodes are saved but never become the prompt their images are
linked to. Typed prompts are linked from the image's own metadata, so only
batch items (linked text) are pushed onto the FIFO queue. Without graph
context the pre-3.2.4 behaviour is kept.
"""
role = self._prompt_role(prompt_graph, unique_id)
batch_item = role["is_run_prompt"] and role["text_linked"]
prompt_id = self._save_prompt_to_database(
text=text, category=category, tags=tags, count_run=batch_item
)
if prompt_id and role["is_run_prompt"]:
self.prompt_tracker.set_current_prompt(
prompt_text=encoding_text,
additional_data={**additional_data, "prompt_id": prompt_id},
push_to_queue=batch_item or not role["known"],
)
return prompt_id
def _record_use(self, prompt_id: int) -> None:
"""Count this execution; a tracking failure must never block generation."""
try:
+22 -20
View File
@@ -4,7 +4,7 @@ without CLIP encoding, while maintaining all database and search features.
"""
import time
from typing import Tuple
from typing import Any, Dict, Optional, Tuple
try:
from comfy.comfy_types import IO, ComfyNodeABC, InputTypeDict
@@ -94,6 +94,12 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
},
),
},
"hidden": {
# The queued graph and this node's id: used to tell the positive
# prompt of a run from negative-prompt PromptManager nodes
"prompt": "PROMPT",
"unique_id": "UNIQUE_ID",
},
}
RETURN_TYPES = (IO.STRING,)
@@ -117,6 +123,8 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
search_text: str = "",
prepend_text: str = "",
append_text: str = "",
prompt: Optional[Dict[str, Any]] = None,
unique_id: Optional[str] = None,
) -> Tuple[str]:
"""
Process the text prompt and save it to the database.
@@ -162,29 +170,23 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
extended_tags.append(f"append:{append_text.strip()[:50]}")
try:
prompt_id = self._save_prompt_to_database(
prompt_id = self._track_prompt_execution(
text=storage_text.strip(),
encoding_text=final_text.strip(),
category=category.strip() if category else None,
tags=extended_tags if extended_tags else None,
additional_data={
"category": category.strip() if category else None,
"tags": extended_tags,
"prepend_text": (
prepend_text.strip() if prepend_text else None
),
"append_text": append_text.strip() if append_text else None,
},
prompt_graph=prompt,
unique_id=unique_id,
)
# Set current prompt for image tracking
if prompt_id:
execution_id = self.prompt_tracker.set_current_prompt(
prompt_text=final_text.strip(),
additional_data={
"category": category.strip() if category else None,
"tags": extended_tags,
"prompt_id": prompt_id,
"prepend_text": (
prepend_text.strip() if prepend_text else None
),
"append_text": append_text.strip() if append_text else None,
},
)
self.logger.debug(
f"Set execution context: {execution_id} for prompt ID: {prompt_id}"
)
self.logger.debug(f"Tracked prompt ID: {prompt_id}")
except Exception as e:
# Log error but don't fail the processing
+131
View File
@@ -0,0 +1,131 @@
"""Image linking picks the positive prompt, preferring the image's own metadata."""
import os
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.operations import PromptDatabase
from utils.hashing import generate_prompt_hash
from utils.image_monitor import ImageGenerationHandler
from utils.prompt_tracker import PromptTracker
LOADER = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "m.safetensors"},
}
def graph(positive_text="a cat", negative_text="blurry"):
# Negative listed first: dict order must not decide which prompt an image gets
return {
"176": {"class_type": "PromptManager", "inputs": {"text": negative_text}},
"134": {"class_type": "PromptManager", "inputs": {"text": positive_text}},
"5": {
"class_type": "KSampler",
"inputs": {
"positive": ["134", 0],
"negative": ["176", 0],
"model": ["20", 0],
},
},
"20": LOADER,
}
class LinkingTestCase(unittest.TestCase):
def setUp(self):
fd, self.db_path = tempfile.mkstemp(suffix=".db")
os.close(fd)
fd, self.image = tempfile.mkstemp(suffix=".png")
os.close(fd)
self.db = PromptDatabase(self.db_path)
self.tracker = PromptTracker(self.db)
self.handler = ImageGenerationHandler(self.db, self.tracker)
self.linked = []
self.handler.link_image_to_prompt = (
lambda path, prompt, meta: self.linked.append(prompt["id"])
)
def tearDown(self):
for path in (
self.image,
self.db_path,
self.db_path + "-wal",
self.db_path + "-shm",
):
if os.path.exists(path):
os.unlink(path)
def _save(self, text):
return self.db.save_prompt(text=text, prompt_hash=generate_prompt_hash(text))
def _process(self, metadata):
with mock.patch.object(
self.handler.metadata_extractor, "extract_metadata", return_value=metadata
):
self.handler.process_new_image(self.image)
return self.linked[-1] if self.linked else None
class TestMetadataLinking(LinkingTestCase):
def test_links_the_positive_prompt_not_the_first_prompt_manager_node(self):
positive = self._save("a cat")
self._save("blurry")
self.assertEqual(self._process({"prompt": graph()}), positive)
def test_image_metadata_wins_over_a_stale_queue_entry(self):
positive = self._save("a cat")
stale = self._save("previous run")
self.tracker.set_current_prompt("previous run", {"prompt_id": stale})
self.assertEqual(self._process({"prompt": graph()}), positive)
self.assertEqual(
self.tracker.pop_next_prompt()["id"], stale, "queue left intact"
)
def test_batch_items_with_linked_text_use_the_queue(self):
first, second = self._save("item one"), self._save("item two")
batch_graph = graph()
batch_graph["134"]["inputs"]["text"] = ["7", 0]
batch_graph["7"] = {"class_type": "PromptSearchList", "inputs": {}}
self.tracker.set_current_prompt("item one", {"prompt_id": first})
self.tracker.set_current_prompt("item two", {"prompt_id": second})
self.assertEqual(self._process({"prompt": batch_graph}), first)
self.assertEqual(self._process({"prompt": batch_graph}), second)
def test_api_graph_present_skips_the_role_unaware_workflow_fallback(self):
negative = self._save("blurry")
batch_graph = graph()
batch_graph["134"]["inputs"]["text"] = ["7", 0]
metadata = {
"prompt": batch_graph,
"text_encoder_nodes": [
{"type": "PromptManager", "inputs": [], "widgets_values": ["blurry"]}
],
}
self.assertIsNone(self.handler._find_prompt_from_metadata(metadata))
self.assertNotEqual(self._process(metadata), negative)
def test_workflow_fallback_still_used_without_an_api_graph(self):
positive = self._save("from workflow only")
metadata = {
"text_encoder_nodes": [
{
"type": "PromptManager",
"inputs": [],
"widgets_values": ["from workflow only"],
}
]
}
self.assertEqual(
self.handler._find_prompt_from_metadata(metadata)["id"], positive
)
if __name__ == "__main__":
unittest.main()
+82
View File
@@ -0,0 +1,82 @@
"""Nodes pass ComfyUI's queued graph to role-aware tracking; the hook registers once."""
import os
import sys
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from prompt_manager import PromptManager
from prompt_manager_text import PromptManagerText
from utils import usage_tracking
GRAPH = {"134": {"class_type": "PromptManager", "inputs": {"text": "a cat"}}}
def bare(node_cls):
"""A node without its database, tracker or gallery side effects."""
node = node_cls.__new__(node_cls)
node.logger = __import__("logging").getLogger("test.wiring")
node.comfyui_integration = mock.Mock()
node._inject_lora_trigger_words = lambda text: text
node._track_prompt_execution = mock.Mock(return_value=42)
return node
class TestHiddenInputs(unittest.TestCase):
def test_both_nodes_request_the_queued_graph_and_their_id(self):
for node_cls in (PromptManager, PromptManagerText):
hidden = node_cls.INPUT_TYPES().get("hidden", {})
self.assertEqual(hidden.get("prompt"), "PROMPT", node_cls.__name__)
self.assertEqual(hidden.get("unique_id"), "UNIQUE_ID", node_cls.__name__)
class TestNodesForwardGraph(unittest.TestCase):
def test_prompt_manager_forwards_graph_and_texts(self):
node = bare(PromptManager)
clip = mock.Mock()
node.encode_prompt(
clip, "a cat", prepend_text="best,", prompt=GRAPH, unique_id="134"
)
kwargs = node._track_prompt_execution.call_args.kwargs
self.assertEqual(kwargs["prompt_graph"], GRAPH)
self.assertEqual(kwargs["unique_id"], "134")
self.assertEqual(kwargs["text"], "a cat")
self.assertEqual(kwargs["encoding_text"], "best, a cat")
self.assertEqual(kwargs["additional_data"]["final_text"], "best, a cat")
def test_prompt_manager_text_forwards_graph(self):
node = bare(PromptManagerText)
node.process_text("a castle", prompt=GRAPH, unique_id="7")
kwargs = node._track_prompt_execution.call_args.kwargs
self.assertEqual((kwargs["prompt_graph"], kwargs["unique_id"]), (GRAPH, "7"))
self.assertEqual(kwargs["text"], "a castle")
def test_nodes_still_work_without_graph_context(self):
node = bare(PromptManager)
node.encode_prompt(mock.Mock(), "a cat")
kwargs = node._track_prompt_execution.call_args.kwargs
self.assertIsNone(kwargs["prompt_graph"])
self.assertIsNone(kwargs["unique_id"])
class TestQueueHookRegistration(unittest.TestCase):
def test_registers_once_and_handler_passes_requests_through(self):
server = mock.Mock()
db = mock.Mock()
db.get_prompt_by_hash.return_value = None
with mock.patch.object(usage_tracking, "_registered", False):
self.assertTrue(usage_tracking.register_queue_hook(server, lambda: db))
self.assertFalse(usage_tracking.register_queue_hook(server, lambda: db))
server.add_on_prompt_handler.assert_called_once()
handler = server.add_on_prompt_handler.call_args.args[0]
request = {"prompt": GRAPH}
self.assertIs(handler(request), request)
if __name__ == "__main__":
unittest.main()
+191
View File
@@ -0,0 +1,191 @@
"""Tests for utils.prompt_graph: which PromptManager nodes feed a positive input."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.prompt_graph import (
is_text_linked,
literal_text,
positive_prompt_nodes,
run_prompt_nodes,
)
def pm(text="a cat", **inputs):
return {"class_type": "PromptManager", "inputs": {"text": text, **inputs}}
def ksampler(positive, negative, model="20"):
return {
"class_type": "KSampler",
"inputs": {
"positive": [positive, 0],
"negative": [negative, 0],
"model": [model, 0],
},
}
LOADER = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "m.safetensors"},
}
class TestPositivePromptNodes(unittest.TestCase):
def test_positive_and_negative_prompt_manager_nodes(self):
# Negative listed first, as in the #75 / pmtest workflow
graph = {
"176": pm("blurry"),
"134": pm("a cat"),
"5": ksampler("134", "176"),
"20": LOADER,
}
self.assertEqual(positive_prompt_nodes(graph), ["134"])
def test_negative_only_prompt_manager_is_never_positive(self):
graph = {
"176": pm("blurry"),
"6": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}},
"5": ksampler("6", "176"),
"20": LOADER,
}
self.assertEqual(positive_prompt_nodes(graph), [])
def test_through_controlnet_and_combine_pass_throughs(self):
graph = {
"1": pm("subject"),
"2": pm("style"),
"3": pm("blurry"),
"4": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["1", 0], "conditioning_2": ["2", 0]},
},
"5": {
"class_type": "ControlNetApplyAdvanced",
"inputs": {"positive": ["4", 0], "negative": ["3", 0], "strength": 1.0},
},
"6": {
"class_type": "KSampler",
"inputs": {
"positive": ["5", 0],
"negative": ["5", 1],
"model": ["20", 0],
},
},
"20": LOADER,
}
self.assertEqual(sorted(positive_prompt_nodes(graph)), ["1", "2"])
def test_cfg_guider_and_basic_guider(self):
graph = {
"1": pm("fox"),
"2": pm("watermark"),
"3": {
"class_type": "CFGGuider",
"inputs": {
"positive": ["1", 0],
"negative": ["2", 0],
"model": ["20", 0],
"cfg": 5,
},
},
"4": pm("owl"),
"5": {
"class_type": "BasicGuider",
"inputs": {"conditioning": ["4", 0], "model": ["20", 0]},
},
"20": LOADER,
}
self.assertEqual(sorted(positive_prompt_nodes(graph)), ["1", "4"])
def test_prompt_manager_text_wired_into_an_encoder(self):
graph = {
"1": {"class_type": "PromptManagerText", "inputs": {"text": "a castle"}},
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": ["1", 0]}},
"3": {"class_type": "PromptManagerText", "inputs": {"text": "lowres"}},
"4": {"class_type": "CLIPTextEncode", "inputs": {"text": ["3", 0]}},
"5": ksampler("2", "4"),
"20": LOADER,
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
def test_node_used_as_both_positive_and_negative_counts_as_positive(self):
graph = {"1": pm("same"), "5": ksampler("1", "1"), "20": LOADER}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
def test_two_samplers_sharing_a_prompt_list_it_once(self):
graph = {
"1": pm("hero"),
"2": pm("blurry"),
"5": ksampler("1", "2"),
"14": ksampler("1", "2"),
"20": LOADER,
}
self.assertEqual(positive_prompt_nodes(graph), ["1"])
def test_graph_without_sampler_or_malformed_input(self):
self.assertEqual(positive_prompt_nodes({"1": pm()}), [])
for bad in (None, [], "x", {"1": None}, {"1": {"inputs": None}}):
self.assertEqual(positive_prompt_nodes(bad), [], repr(bad))
def test_cycles_terminate(self):
loop = {
"class_type": "Weird",
"inputs": {
"conditioning": ["9", 0],
"conditioning_1": ["9", 0],
"positive": ["9", 0],
},
}
graph = {"9": loop, "5": ksampler("9", "9"), "20": LOADER}
self.assertEqual(positive_prompt_nodes(graph), [])
class TestRunPromptNodes(unittest.TestCase):
"""The prompt nodes that represent a run, with a safe fallback."""
def test_uses_positive_nodes_when_found(self):
graph = {
"176": pm("blurry"),
"134": pm("a cat"),
"5": ksampler("134", "176"),
"20": LOADER,
}
self.assertEqual(run_prompt_nodes(graph), ["134"])
def test_unrecognised_sampler_falls_back_to_every_prompt_manager_node(self):
graph = {
"1": pm("a cat"),
"9": {"class_type": "ExoticSampler", "inputs": {"cond": ["1", 0]}},
"2": {"class_type": "PromptManagerText", "inputs": {"text": "x"}},
}
self.assertEqual(sorted(run_prompt_nodes(graph)), ["1", "2"])
def test_malformed_graph(self):
self.assertEqual(run_prompt_nodes(None), [])
class TestNodeText(unittest.TestCase):
def test_literal_and_linked_text(self):
graph = {
"1": pm(" a cat "),
"2": pm(["7", 0]),
"7": {"class_type": "PromptSearchList", "inputs": {}},
}
self.assertEqual(literal_text(graph, "1"), "a cat")
self.assertIsNone(literal_text(graph, "2"))
self.assertFalse(is_text_linked(graph, "1"))
self.assertTrue(is_text_linked(graph, "2"))
def test_missing_nodes(self):
self.assertIsNone(literal_text({}, "1"))
self.assertFalse(is_text_linked({}, "1"))
self.assertIsNone(literal_text({"1": pm(" ")}, "1"))
if __name__ == "__main__":
unittest.main()
+13 -3
View File
@@ -194,7 +194,11 @@ class TestUsageMigration(unittest.TestCase):
class TestNodeRecordsUse(UsageTestCase):
"""Every node execution counts, including re-runs of an existing prompt."""
"""Node saves count a run only when asked; re-runs are counted by the queue hook.
ComfyUI skips unchanged nodes, so counting at execution misses re-runs; see
tests/test_usage_tracking.py for the hook that counts them.
"""
def _node(self):
node = PromptManagerBase.__new__(PromptManagerBase)
@@ -202,13 +206,19 @@ class TestNodeRecordsUse(UsageTestCase):
node.logger = __import__("logging").getLogger("test.prompt_usage")
return node
def test_new_and_repeated_runs_are_counted(self):
def test_save_without_count_run_leaves_counting_to_the_queue_hook(self):
node = self._node()
first = node._save_prompt_to_database("a lighthouse at dusk")
second = node._save_prompt_to_database("a lighthouse at dusk")
self.assertEqual(first, second)
self.assertEqual(self.db.get_prompt_by_id(first)["run_count"], 2)
self.assertEqual(self.db.get_prompt_by_id(first)["run_count"], 0)
def test_count_run_counts_new_and_existing_prompts(self):
node = self._node()
pid = node._save_prompt_to_database("batch item", count_run=True)
node._save_prompt_to_database("batch item", count_run=True)
self.assertEqual(self.db.get_prompt_by_id(pid)["run_count"], 2)
if __name__ == "__main__":
+228
View File
@@ -0,0 +1,228 @@
"""Run counting and node roles: only positive PromptManager nodes count and link.
Runs are counted when a prompt is queued (ComfyUI's on-prompt hook), because
ComfyUI skips unchanged nodes, so node execution can't be relied on to count.
"""
import os
import sys
import tempfile
import unittest
from unittest import mock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from database.operations import PromptDatabase
from prompt_manager_base import PromptManagerBase
from utils.hashing import generate_prompt_hash
from utils.prompt_tracker import PromptTracker
from utils.usage_tracking import PendingFirstUse, handle_queued_prompt
LOADER = {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "m.safetensors"},
}
def workflow(positive_text="a cat", negative_text="blurry"):
"""API graph with a positive (#134) and a negative (#176) PromptManager node."""
return {
"176": {"class_type": "PromptManager", "inputs": {"text": negative_text}},
"134": {"class_type": "PromptManager", "inputs": {"text": positive_text}},
"5": {
"class_type": "KSampler",
"inputs": {
"positive": ["134", 0],
"negative": ["176", 0],
"model": ["20", 0],
},
},
"20": LOADER,
}
class DbTestCase(unittest.TestCase):
def setUp(self):
fd, self.path = tempfile.mkstemp(suffix=".db")
os.close(fd)
self.db = PromptDatabase(self.path)
self.pending = PendingFirstUse()
def tearDown(self):
for suffix in ("", "-wal", "-shm"):
if os.path.exists(self.path + suffix):
os.unlink(self.path + suffix)
def _save(self, text):
return self.db.save_prompt(text=text, prompt_hash=generate_prompt_hash(text))
def _runs(self, prompt_id):
return self.db.get_prompt_by_id(prompt_id)["run_count"]
def _queue(self, graph):
return handle_queued_prompt(
{"prompt": graph, "client_id": "x"}, self.db, self.pending
)
class TestQueueHook(DbTestCase):
def test_counts_the_positive_prompt_only(self):
positive, negative = self._save("a cat"), self._save("blurry")
self._queue(workflow())
self.assertEqual(self._runs(positive), 1)
self.assertEqual(self._runs(negative), 0)
def test_requeue_counts_again_even_when_comfyui_skips_the_cached_node(self):
positive = self._save("a cat")
self._queue(workflow())
self._queue(workflow()) # no node execution in between
self.assertEqual(self._runs(positive), 2)
def test_new_prompt_is_counted_when_the_node_first_saves_it(self):
self._queue(workflow(positive_text="a brand new prompt"))
self.assertTrue(
self.pending.consume(generate_prompt_hash("a brand new prompt"))
)
self.assertFalse(
self.pending.consume(generate_prompt_hash("a brand new prompt"))
)
def test_same_text_in_two_positive_nodes_counts_once(self):
positive = self._save("a cat")
graph = workflow()
graph["135"] = {"class_type": "PromptManager", "inputs": {"text": "a cat"}}
graph["4"] = {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["134", 0], "conditioning_2": ["135", 0]},
}
graph["5"]["inputs"]["positive"] = ["4", 0]
self._queue(graph)
self.assertEqual(self._runs(positive), 1)
def test_linked_text_is_left_to_node_execution(self):
positive = self._save("from a batch")
graph = workflow()
graph["134"]["inputs"]["text"] = ["7", 0]
graph["7"] = {"class_type": "PromptSearchList", "inputs": {}}
self._queue(graph)
self.assertEqual(self._runs(positive), 0)
def test_returns_the_request_unchanged_and_never_raises(self):
request = {"prompt": workflow(), "client_id": "x", "extra_data": {}}
self.assertIs(handle_queued_prompt(request, self.db, self.pending), request)
for bad in (None, {}, {"prompt": "x"}, {"prompt": None}, []):
self.assertIs(handle_queued_prompt(bad, self.db, self.pending), bad)
broken_db = mock.Mock()
broken_db.get_prompt_by_hash.side_effect = RuntimeError("db locked")
self.assertIs(handle_queued_prompt(request, broken_db, self.pending), request)
class TestPendingFirstUse(unittest.TestCase):
def test_entries_expire(self):
pending = PendingFirstUse(ttl_seconds=10)
with mock.patch("utils.usage_tracking.time.monotonic", return_value=100.0):
pending.add("h")
with mock.patch("utils.usage_tracking.time.monotonic", return_value=111.0):
self.assertFalse(pending.consume("h"))
def test_size_is_capped(self):
pending = PendingFirstUse(max_entries=3)
for h in ("a", "b", "c", "d"):
pending.add(h)
self.assertFalse(pending.consume("a")) # oldest evicted
self.assertTrue(pending.consume("d"))
class FakeTracker:
def __init__(self):
self.calls = []
def set_current_prompt(self, prompt_text, additional_data=None, push_to_queue=True):
self.calls.append({"text": prompt_text, "push_to_queue": push_to_queue})
return "exec-1"
class TestNodeRoles(DbTestCase):
def _node(self):
node = PromptManagerBase.__new__(PromptManagerBase)
node.db = self.db
node.prompt_tracker = FakeTracker()
node.logger = __import__("logging").getLogger("test.usage_tracking")
node.pending_first_use = self.pending
return node
def _run(self, node, graph, unique_id, text):
return node._track_prompt_execution(
text=text,
encoding_text=text,
category=None,
tags=None,
additional_data={},
prompt_graph=graph,
unique_id=unique_id,
)
def test_negative_node_saves_but_never_becomes_the_current_prompt(self):
node = self._node()
prompt_id = self._run(node, workflow(), "176", "blurry")
self.assertIsNotNone(self.db.get_prompt_by_id(prompt_id))
self.assertEqual(node.prompt_tracker.calls, [])
self.assertEqual(self._runs(prompt_id), 0)
def test_positive_typed_text_is_current_without_queue_push(self):
node = self._node()
self._run(node, workflow(), "134", "a cat")
self.assertEqual(
node.prompt_tracker.calls, [{"text": "a cat", "push_to_queue": False}]
)
def test_first_save_after_queue_hook_counts_one_run(self):
self._queue(workflow(positive_text="never seen before"))
prompt_id = self._run(
self._node(),
workflow(positive_text="never seen before"),
"134",
"never seen before",
)
self.assertEqual(self._runs(prompt_id), 1)
def test_existing_prompt_is_not_counted_twice_by_hook_and_node(self):
positive = self._save("a cat")
self._queue(workflow())
self._run(self._node(), workflow(), "134", "a cat")
self.assertEqual(self._runs(positive), 1)
def test_positive_linked_text_pushes_queue_and_counts_each_item(self):
graph = workflow()
graph["134"]["inputs"]["text"] = ["7", 0]
graph["7"] = {"class_type": "PromptSearchList", "inputs": {}}
node = self._node()
first = self._run(node, graph, "134", "batch item one")
self._run(node, graph, "134", "batch item one")
self.assertEqual(self._runs(first), 2)
self.assertTrue(all(c["push_to_queue"] for c in node.prompt_tracker.calls))
def test_without_graph_context_keeps_legacy_behaviour(self):
node = self._node()
self._run(node, None, None, "standalone")
self.assertEqual(
node.prompt_tracker.calls, [{"text": "standalone", "push_to_queue": True}]
)
class TestTrackerQueueFlag(unittest.TestCase):
def setUp(self):
self.tracker = PromptTracker(mock.Mock())
def test_push_to_queue_false_keeps_the_batch_queue_empty(self):
self.tracker.set_current_prompt("a cat", {"prompt_id": 1}, push_to_queue=False)
self.assertIsNone(self.tracker.pop_next_prompt())
self.assertEqual(self.tracker.get_current_prompt()["id"], 1)
def test_default_still_pushes(self):
self.tracker.set_current_prompt("a cat", {"prompt_id": 1})
self.assertEqual(self.tracker.pop_next_prompt()["id"], 1)
if __name__ == "__main__":
unittest.main()
+24 -20
View File
@@ -34,6 +34,7 @@ from watchdog.events import FileSystemEventHandler
from .metadata_extractor import ComfyUIMetadataExtractor
from .logging_config import get_logger
from .prompt_graph import literal_text, run_prompt_nodes
class ImageGenerationHandler(FileSystemEventHandler):
@@ -146,21 +147,26 @@ class ImageGenerationHandler(FileSystemEventHandler):
except Exception as meta_error:
self.logger.warning(f"Metadata extraction failed: {meta_error}")
# Strategy 1: Pop from batch queue (most reliable for batch workflows).
# Prompts are queued during CLIP encoding in order; images save in
# the same order, so FIFO pop gives the correct prompt per image.
current_prompt = self.prompt_tracker.pop_next_prompt()
# Strategy 1: The image's own metadata. It records the positive prompt
# that produced this image, independent of node caching and of any
# negative-prompt PromptManager nodes in the workflow.
current_prompt = self._find_prompt_from_metadata(metadata)
if current_prompt:
self.logger.info(
f"Queue match: prompt {current_prompt['id']} for "
f"Metadata match: prompt {current_prompt['id']} for "
f"{os.path.basename(image_path)}"
)
# Strategy 2: Find prompt from image metadata
# Strategy 2: Pop from batch queue. Batch items (text supplied by
# PromptSearchList) share one graph, so metadata can't tell them apart;
# they are queued in encoding order and images save in the same order.
if not current_prompt:
current_prompt = self._find_prompt_from_metadata(metadata)
current_prompt = self.prompt_tracker.pop_next_prompt()
if current_prompt:
self.logger.info(f"Metadata match: prompt {current_prompt['id']}")
self.logger.info(
f"Queue match: prompt {current_prompt['id']} for "
f"{os.path.basename(image_path)}"
)
# Strategy 3: Use snapshot captured at file-creation time
if not current_prompt and prompt_snapshot:
@@ -231,21 +237,19 @@ class ImageGenerationHandler(FileSystemEventHandler):
prompt_text = None
# Try to find PromptManager node in the prompt execution data
# The executed graph: use the PromptManager node feeding the positive input
prompt_data = metadata.get("prompt")
if isinstance(prompt_data, dict):
for node_id, node_info in prompt_data.items():
if not isinstance(node_info, dict):
continue
class_type = node_info.get("class_type", "")
if class_type in ("PromptManager", "PromptManagerText"):
inputs = node_info.get("inputs", {})
text = inputs.get("text", "")
if text and isinstance(text, str) and text.strip():
prompt_text = text.strip()
break
for node_id in run_prompt_nodes(prompt_data):
prompt_text = literal_text(prompt_data, node_id)
if prompt_text:
break
if not prompt_text:
# Positive text came from another node (batch item): leave it to the
# queue rather than guess from workflow widgets, which don't know roles
return None
# Fallback: check text_encoder_nodes from workflow, but only if
# Fallback (no executed graph): check text_encoder_nodes from workflow, but only if
# the text input is NOT connected (connected inputs override widget values,
# so the widget value would be stale in batch workflows).
if not prompt_text:
+121
View File
@@ -0,0 +1,121 @@
"""Find which PromptManager nodes feed a sampler's positive input in a ComfyUI graph.
Works on the API-format prompt graph ({node_id: {"class_type", "inputs"}}) that
ComfyUI queues and embeds in saved images. Usage counting and image linking use it
so that negative-prompt PromptManager nodes never count as "the prompt" of a run.
Pure functions with no ComfyUI imports, so they are unit-testable. Mirrors the
link-tracing rules of web/js/comfy-metadata.js.
"""
import re
from typing import Any, Dict, List, Optional
PROMPT_MANAGER_TYPES = frozenset({"PromptManager", "PromptManagerText"})
# Upper bound on nodes expanded per graph; queued and embedded graphs are untrusted
MAX_VISITS = 500
# Inputs that carry positive conditioning or prompt text towards a sampler
_CONDITIONING_KEY = re.compile(r"^conditioning(_\w+)?$")
_TEXT_KEY = re.compile(r"^(text|string|prompt)(_?[a-z0-9]+)?$", re.IGNORECASE)
def _is_link(value: Any) -> bool:
return (
isinstance(value, (list, tuple))
and len(value) == 2
and isinstance(value[0], (str, int))
and isinstance(value[1], int)
)
def _inputs(node: Any) -> Dict[str, Any]:
inputs = node.get("inputs") if isinstance(node, dict) else None
return inputs if isinstance(inputs, dict) else {}
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...)."""
roots = []
for node in graph.values():
inputs = _inputs(node)
if not _is_link(inputs.get("model")):
continue
if _is_link(inputs.get("positive")) and _is_link(inputs.get("negative")):
roots.append(inputs["positive"])
elif (
_is_link(inputs.get("conditioning"))
and "guider" in str(node.get("class_type", "")).lower()
):
roots.append(inputs["conditioning"])
return roots
def _upstream_links(inputs: Dict[str, Any]) -> List[Any]:
"""Inputs to follow from a pass-through or encoder node, never `negative`."""
links = []
for key, value in inputs.items():
if not _is_link(value):
continue
if key == "positive" or _CONDITIONING_KEY.match(key) or _TEXT_KEY.match(key):
links.append(value)
return links
def positive_prompt_nodes(graph: Any) -> List[str]:
"""Ids of PromptManager/PromptManagerText nodes that feed a positive input.
Ordered by discovery, each id once. Malformed graphs yield an empty list.
"""
if not isinstance(graph, dict):
return []
found: List[str] = []
seen = set()
stack = list(reversed(_positive_roots(graph)))
while stack and len(seen) < MAX_VISITS:
node_id = str(stack.pop()[0])
if node_id in seen:
continue
seen.add(node_id)
node = graph.get(node_id)
if not isinstance(node, dict):
continue
if node.get("class_type") in PROMPT_MANAGER_TYPES:
found.append(node_id)
continue
stack.extend(reversed(_upstream_links(_inputs(node))))
return found
def run_prompt_nodes(graph: Any) -> List[str]:
"""Prompt nodes that represent a run: the positive ones, if any can be found.
When no PromptManager node can be traced to a positive input (an unrecognised
custom sampler, say), every PromptManager node counts, as before 3.2.4, so an
exotic workflow never silently loses usage counting or image linking.
"""
positive = positive_prompt_nodes(graph)
if positive or not isinstance(graph, dict):
return positive
return [
str(node_id)
for node_id, node in graph.items()
if isinstance(node, dict) and node.get("class_type") in PROMPT_MANAGER_TYPES
]
def literal_text(graph: Any, node_id: str) -> Optional[str]:
"""A node's `text` input when typed in (not linked), stripped; else None."""
node = graph.get(str(node_id)) if isinstance(graph, dict) else None
text = _inputs(node).get("text")
if isinstance(text, str) and text.strip():
return text.strip()
return None
def is_text_linked(graph: Any, node_id: str) -> bool:
"""True when the node's `text` comes from another node (e.g. PromptSearchList)."""
node = graph.get(str(node_id)) if isinstance(graph, dict) else None
return _is_link(_inputs(node).get("text"))
+13 -6
View File
@@ -99,7 +99,10 @@ class PromptTracker:
self.logger.debug("PromptTracker initialization completed")
def set_current_prompt(
self, prompt_text: str, additional_data: Optional[Dict[str, Any]] = None
self,
prompt_text: str,
additional_data: Optional[Dict[str, Any]] = None,
push_to_queue: bool = True,
) -> str:
"""
Set the current prompt for this thread for image tracking.
@@ -113,6 +116,9 @@ class PromptTracker:
Args:
prompt_text: The prompt text being executed
additional_data: Additional prompt metadata, should include prompt_id from PromptManager
push_to_queue: Also push onto the FIFO batch queue. Only batch items whose
text comes from another node need it; typed prompts are linked from
the image's own metadata instead.
Returns:
Unique execution ID for this prompt execution
@@ -164,11 +170,12 @@ class PromptTracker:
self.active_prompts[execution_id] = execution_context
# Push to batch queue for ordered image linking
with self._queue_lock:
self._prompt_queue.append(execution_context)
self.logger.debug(
f"Queue size: {len(self._prompt_queue)} after push for prompt {prompt_id}"
)
if push_to_queue:
with self._queue_lock:
self._prompt_queue.append(execution_context)
self.logger.debug(
f"Queue size: {len(self._prompt_queue)} after push for prompt {prompt_id}"
)
self.logger.debug(
f"Set current prompt: {execution_id} -> {prompt_text[:50]}... (thread: {threading.current_thread().ident})"
+104
View File
@@ -0,0 +1,104 @@
"""Count prompt runs when a workflow is queued, not when nodes execute.
ComfyUI skips nodes whose inputs are unchanged, so a PromptManager node does not
execute when the same prompt is re-run. The on-prompt hook sees every queued
workflow instead: it counts a run for each PromptManager node feeding a sampler's
positive input. Prompts not yet in the database are remembered as pending and
counted by the node when it first saves them.
Text supplied by another node (PromptSearchList batches) is unknown at queue time;
those runs are counted by the node as each item executes.
"""
import threading
import time
from collections import OrderedDict
from typing import Any
try:
from .hashing import generate_prompt_hash
from .logging_config import get_logger
from .prompt_graph import literal_text, run_prompt_nodes
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.logging_config import get_logger
from utils.prompt_graph import literal_text, run_prompt_nodes
logger = get_logger("prompt_manager.usage_tracking")
PENDING_TTL_SECONDS = 3600 # a queued prompt that never executes is forgotten
PENDING_MAX_ENTRIES = 1000
class PendingFirstUse:
"""Hashes of queued positive prompts that were not in the database yet."""
def __init__(
self,
ttl_seconds: float = PENDING_TTL_SECONDS,
max_entries: int = PENDING_MAX_ENTRIES,
):
self._ttl = ttl_seconds
self._max = max_entries
self._entries: "OrderedDict[str, float]" = OrderedDict()
self._lock = threading.Lock()
def add(self, prompt_hash: str) -> None:
with self._lock:
self._entries.pop(prompt_hash, None)
self._entries[prompt_hash] = time.monotonic()
while len(self._entries) > self._max:
self._entries.popitem(last=False)
def consume(self, prompt_hash: str) -> bool:
"""True (once) if this hash was queued recently and not yet counted."""
with self._lock:
added = self._entries.pop(prompt_hash, None)
return added is not None and time.monotonic() - added <= self._ttl
# Shared between the queue hook and the nodes
PENDING_FIRST_USE = PendingFirstUse()
def handle_queued_prompt(json_data: Any, db: Any, pending: PendingFirstUse) -> Any:
"""ComfyUI on-prompt handler body: count runs, always return the request as-is."""
try:
graph = json_data.get("prompt") if isinstance(json_data, dict) else None
counted = set()
for node_id in run_prompt_nodes(graph):
text = literal_text(graph, node_id)
if not text:
continue
prompt_hash = generate_prompt_hash(text)
if prompt_hash in counted:
continue
counted.add(prompt_hash)
existing = db.get_prompt_by_hash(prompt_hash)
if existing:
db.record_prompt_use(existing["id"])
else:
pending.add(prompt_hash)
except Exception as e:
logger.warning(f"Could not count queued prompt runs: {e}")
return json_data
_registered = False
_register_lock = threading.Lock()
def register_queue_hook(
server: Any, db_factory: Any, pending: PendingFirstUse = PENDING_FIRST_USE
) -> bool:
"""Register the on-prompt handler once; db_factory is called lazily per request."""
global _registered
with _register_lock:
if _registered:
return False
server.add_on_prompt_handler(
lambda json_data: handle_queued_prompt(json_data, db_factory(), pending)
)
_registered = True
logger.info("Registered prompt usage hook")
return True