Files
00akii 347c9d90df Fix: use synchronous get_local() instead of un-awaited async get()
Recent ComfyUI builds (dynamic VRAM/RAM-pressure caching,
comfy_execution/caching.py RAMPressureCache) made the node outputs
cache's get() a coroutine. _ExecutionListProxy in capture.py called
it synchronously (no await), so every call silently returned an
un-awaited coroutine instead of the cached CacheEntry:

  RuntimeWarning: coroutine 'RAMPressureCache.get' was never awaited

Downstream code then failed to read .outputs on the coroutine,
raising an exception that get_inputs() swallows per-node
(except Exception: continue), so metadata capture silently breaks
for essentially every node in the graph on affected ComfyUI
versions (embedded prompt/seed/sampler/model metadata ends up
missing or incomplete).

ComfyUI's own synchronous execution engine
(comfy_execution/graph.py ExecutionList.get_cache/cache_link)
never awaits this cache directly either -- it reads already
-computed outputs via the cache's synchronous get_local()
accessor. This change makes _ExecutionListProxy do the same,
falling back to the plain .get() for _FallbackOutputsCache
(a dict shim with no get_local).

Verified locally: warning disappears and Eagle-sent images
regain full metadata on ComfyUI v0.31.0.
2026-08-12 13:05:37 +09:00

441 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
import os
from collections import defaultdict
from . import hook
from .defs.captures import CAPTURE_FIELD_LIST
from .defs.meta import MetaField
from .defs.textencodes import TEXT_ENCODE_CLASSES
from nodes import NODE_CLASS_MAPPINGS
from execution import get_input_data
from comfy_execution.graph import DynamicPrompt
class _FallbackOutputsCache(dict):
"""Dictionary shim that mimics ExecutionList cache updates."""
def set(self, key, value):
self[key] = value
class _ExecutionListProxy:
"""ComfyUI uses either get, get_cache, or get_output_cache depending on the version, so create a proxy class that handles all methods."""
def __init__(self, outputs_cache):
self._outputs_cache = outputs_cache
def _read(self, node_id):
# Newer ComfyUI builds (dynamic VRAM/RAM-pressure caching) made the
# outputs cache's `get()` a coroutine (see comfy_execution/caching.py
# RAMPressureCache.get). Calling it without awaiting silently returns
# an un-awaited coroutine and breaks metadata capture for every node
# (RuntimeWarning: coroutine 'RAMPressureCache.get' was never awaited).
#
# ComfyUI's own synchronous execution engine (comfy_execution/graph.py
# ExecutionList.get_cache/cache_link) reads already-computed outputs
# via the cache's synchronous `get_local()` accessor instead of the
# async `get()`, so mirror that here. `_FallbackOutputsCache` (a plain
# dict) has no `get_local`, so fall back to its regular sync `.get()`.
get_local = getattr(self._outputs_cache, "get_local", None)
if get_local is not None:
return get_local(node_id)
return self._outputs_cache.get(node_id)
def get(self, node_id):
return self._read(node_id)
def get_cache(self, node_id, _to_node_id):
return self._read(node_id)
def get_output_cache(self, node_id, _to_node_id):
return self._read(node_id)
class Capture:
@staticmethod
def _select_latest_value(value):
"""Return the most recent meaningful entry from a potentially nested list/tuple."""
if isinstance(value, (list, tuple)):
for item in reversed(value):
resolved = Capture._select_latest_value(item)
if resolved is not None:
return resolved
return None
return value
@staticmethod
def _value_from_entries(entries):
for entry in entries:
if entry is None:
continue
if isinstance(entry, (list, tuple)) and len(entry) >= 2:
value = entry[1]
else:
value = entry
if value is not None:
return value
return None
@classmethod
def get_inputs(cls, calc_model_hash, index, include_prompts=True):
inputs = defaultdict(list)
prompt = hook.current_prompt
extra_data = hook.current_extra_data
prompt_executor = getattr(hook, "prompt_executer", None)
caches = getattr(prompt_executor, "caches", None) if prompt_executor else None
raw_outputs = getattr(caches, "outputs", None) if caches else None
using_real_outputs = raw_outputs is not None
outputs = raw_outputs if using_real_outputs else _FallbackOutputsCache()
execution_list = _ExecutionListProxy(outputs)
dynprompt = getattr(outputs, "dynprompt", None)
if dynprompt is None and prompt_executor is not None:
dynprompt = getattr(prompt_executor, "dynprompt", None)
if dynprompt is None:
dynprompt = DynamicPrompt(prompt)
prompt_lookup = prompt
if dynprompt is not None:
try:
ephemeral = getattr(dynprompt, "ephemeral_prompt", {})
if ephemeral:
combined_prompt = dict(prompt)
combined_prompt.update(ephemeral)
prompt_lookup = combined_prompt
except Exception:
prompt_lookup = prompt
node_ids = list(dynprompt.all_node_ids()) if dynprompt is not None else list(prompt.keys())
for node_id in sorted(node_ids, key=str):
node_obj = None
if dynprompt is not None and dynprompt.has_node(node_id):
try:
node_obj = dynprompt.get_node(node_id)
except Exception:
node_obj = None
if node_obj is None:
node_obj = prompt.get(node_id)
if node_obj is None:
continue
class_type = node_obj.get("class_type")
if not include_prompts and class_type in TEXT_ENCODE_CLASSES:
continue
if class_type not in CAPTURE_FIELD_LIST:
continue
obj_class = NODE_CLASS_MAPPINGS[class_type]
node_inputs = node_obj.get("inputs", {})
if execution_list is None:
# Without an execution list cache the inputs are unavailable (e.g. when
# custom hooks lost the PromptExecutor reference). Skip gracefully to
# avoid crashing the whole workflow – metadata will just be partial.
continue
try:
input_data = get_input_data(
node_inputs,
obj_class,
node_id,
execution_list,
dynprompt,
extra_data,
)
except Exception:
# The upstream cache might still be rebuilding; skip capturing this
# node and continue harvesting what is available.
continue
# if class_type not in ["KSampler"]:
# print("Capturing inputs for node:", node_id, "Class type:", class_type, "Input data:", input_data)
display_node_id = dynprompt.get_display_node_id(node_id)
metas = CAPTURE_FIELD_LIST[class_type]
for meta, field_data in metas.items():
validate = field_data.get("validate")
if validate is not None and not validate(
display_node_id, node_obj, prompt_lookup, extra_data, outputs, input_data
):
continue
value = field_data.get("value")
if value is not None:
inputs[meta].append((node_id, value))
continue
selector = field_data.get("selector")
if selector is not None:
v = selector(
node_id, node_obj, prompt_lookup, extra_data, outputs, input_data
)
if isinstance(v, list):
for x in v:
inputs[meta].append((node_id, x))
elif v is not None:
inputs[meta].append((node_id, v))
continue
field_name = field_data["field_name"]
value = input_data[0].get(field_name)
if value is not None:
# print("Capturing value for field:", field_name, "NodeID:", node_id, "Value:", value)
format = field_data.get("format")
if isinstance(value, list) and len(value) > 0:
if len(value) > index:
v = value[index]
else:
v = value[0]
else:
v = value
if format is not None:
# formatのメソッド名が「_hash」で終わる場合、かつ、calc_model_hashがFalseの場合はメソッドを呼び出さずNoneにする
if format.__name__.endswith("_hash") and not calc_model_hash:
v = None
else:
v = format(v, input_data)
if isinstance(v, list):
for x in v:
inputs[meta].append((node_id, x))
else:
inputs[meta].append((node_id, v))
# print("Final captured inputs:", dict(inputs))
return inputs
@classmethod
def gen_pnginfo_dict(cls, inputs_before_sampler_node, inputs_before_this_node, save_civitai_sampler, calc_model_hash):
pnginfo_dict = {}
def update_pnginfo_dict(inputs, metafield, key):
entries = inputs.get(metafield, [])
entry_value = cls._value_from_entries(entries)
if entry_value is not None:
pnginfo_dict[key] = entry_value
update_pnginfo_dict(
inputs_before_sampler_node, MetaField.POSITIVE_PROMPT, "Positive prompt"
)
update_pnginfo_dict(
inputs_before_sampler_node, MetaField.NEGATIVE_PROMPT, "Negative prompt"
)
update_pnginfo_dict(inputs_before_sampler_node, MetaField.STEPS, "Steps")
sampler_names = inputs_before_sampler_node.get(MetaField.SAMPLER_NAME, [])
schedulers = inputs_before_sampler_node.get(MetaField.SCHEDULER, [])
if (save_civitai_sampler):
pnginfo_dict["Sampler"] = cls.get_sampler_for_civitai(sampler_names, schedulers)
else:
sampler_name_value = cls._value_from_entries(sampler_names)
scheduler_value = cls._value_from_entries(schedulers)
if sampler_name_value is not None:
pnginfo_dict["Sampler"] = sampler_name_value
if scheduler_value and scheduler_value != "normal":
pnginfo_dict["Sampler"] += "_" + scheduler_value
update_pnginfo_dict(inputs_before_sampler_node, MetaField.CFG, "CFG scale")
update_pnginfo_dict(inputs_before_sampler_node, MetaField.SEED, "Seed")
update_pnginfo_dict(
inputs_before_sampler_node, MetaField.CLIP_SKIP, "Clip skip"
)
image_widths = inputs_before_sampler_node.get(MetaField.IMAGE_WIDTH, [])
image_heights = inputs_before_sampler_node.get(MetaField.IMAGE_HEIGHT, [])
width_value = cls._value_from_entries(image_widths)
height_value = cls._value_from_entries(image_heights)
if width_value is not None and height_value is not None:
pnginfo_dict["Size"] = f"{width_value}x{height_value}"
update_pnginfo_dict(inputs_before_sampler_node, MetaField.MODEL_NAME, "Model")
if calc_model_hash:
update_pnginfo_dict(
inputs_before_sampler_node, MetaField.MODEL_HASH, "Model hash"
)
update_pnginfo_dict(inputs_before_this_node, MetaField.VAE_NAME, "VAE")
if calc_model_hash:
update_pnginfo_dict(inputs_before_this_node, MetaField.VAE_HASH, "VAE hash")
pnginfo_dict.update(cls.gen_loras(inputs_before_sampler_node, calc_model_hash))
pnginfo_dict.update(cls.gen_embeddings(inputs_before_sampler_node, calc_model_hash))
if calc_model_hash:
hashes_for_civitai = cls.get_hashes_for_civitai(
inputs_before_sampler_node, inputs_before_this_node
)
if len(hashes_for_civitai) > 0:
pnginfo_dict["Hashes"] = json.dumps(hashes_for_civitai)
return pnginfo_dict
@classmethod
def gen_parameters_str(cls, pnginfo_dict):
result = pnginfo_dict.get("Positive prompt", "") + "\n"
result += "Negative prompt: " + pnginfo_dict.get("Negative prompt", "") + "\n"
s_list = []
pnginfo_dict_without_prompt = {
k: v
for k, v in pnginfo_dict.items()
if k not in {"Positive prompt", "Negative prompt"}
}
for k, v in pnginfo_dict_without_prompt.items():
s = str(v).strip().replace("\n", " ")
s_list.append(f"{k}: {s}")
return result + ", ".join(s_list)
@classmethod
def get_hashes_for_civitai(
cls, inputs_before_sampler_node, inputs_before_this_node
):
resource_hashes = {}
model_hashes = inputs_before_sampler_node.get(MetaField.MODEL_HASH, [])
model_hash_value = cls._value_from_entries(model_hashes)
if model_hash_value is not None:
resource_hashes["model"] = model_hash_value
vae_hashes = inputs_before_this_node.get(MetaField.VAE_HASH, [])
vae_hash_value = cls._value_from_entries(vae_hashes)
if vae_hash_value is not None:
resource_hashes["vae"] = vae_hash_value
lora_model_names = inputs_before_sampler_node.get(MetaField.LORA_MODEL_NAME, [])
lora_model_hashes = inputs_before_sampler_node.get(
MetaField.LORA_MODEL_HASH, []
)
for lora_model_name, lora_model_hash in zip(
lora_model_names, lora_model_hashes
):
lora_model_name = os.path.splitext(os.path.basename(lora_model_name[1]))[0]
resource_hashes[f"lora:{lora_model_name}"] = lora_model_hash[1]
embedding_names = inputs_before_sampler_node.get(MetaField.EMBEDDING_NAME, [])
embedding_hashes = inputs_before_sampler_node.get(MetaField.EMBEDDING_HASH, [])
for embedding_name, embedding_hash in zip(embedding_names, embedding_hashes):
embedding_name = os.path.splitext(os.path.basename(embedding_name[1]))[0]
resource_hashes[f"embed:{embedding_name}"] = embedding_hash[1]
return resource_hashes
@classmethod
def gen_loras(cls, inputs, calc_model_hash):
pnginfo_dict = {}
model_names = inputs.get(MetaField.LORA_MODEL_NAME, [])
model_hashes = inputs.get(MetaField.LORA_MODEL_HASH, [])
strength_models = inputs.get(MetaField.LORA_STRENGTH_MODEL, [])
strength_clips = inputs.get(MetaField.LORA_STRENGTH_CLIP, [])
index = 0
for model_name, model_hashe, strength_model, strength_clip in zip(
model_names, model_hashes, strength_models, strength_clips
):
field_prefix = f"Lora_{index}"
pnginfo_dict[f"{field_prefix} Model name"] = os.path.basename(model_name[1])
if calc_model_hash:
pnginfo_dict[f"{field_prefix} Model hash"] = model_hashe[1]
pnginfo_dict[f"{field_prefix} Strength model"] = strength_model[1]
pnginfo_dict[f"{field_prefix} Strength clip"] = strength_clip[1]
index += 1
return pnginfo_dict
@classmethod
def gen_embeddings(cls, inputs, calc_model_hash):
pnginfo_dict = {}
embedding_names = inputs.get(MetaField.EMBEDDING_NAME, [])
embedding_hashes = inputs.get(MetaField.EMBEDDING_HASH, [])
index = 0
for embedding_name, embedding_hashe in zip(embedding_names, embedding_hashes):
field_prefix = f"Embedding_{index}"
pnginfo_dict[f"{field_prefix} name"] = os.path.basename(embedding_name[1])
if calc_model_hash:
pnginfo_dict[f"{field_prefix} hash"] = embedding_hashe[1]
index += 1
return pnginfo_dict
@classmethod
def get_sampler_for_civitai(cls, sampler_names, schedulers):
"""
Get the pretty sampler name for Civitai in the form of `<Sampler Name> <Scheduler name>`.
- `dpmpp_2m` and `karras` will return `DPM++ 2M Karras`
If there is a matching sampler name but no matching scheduler name, return only the matching sampler name.
- `dpmpp_2m` and `exponential` will return only `DPM++ 2M`
if there is no matching sampler and scheduler name, return `<sampler_name>_<scheduler_name>`
- `ipndm` and `normal` will return `ipndm`
- `ipndm` and `karras` will return `ipndm_karras`
Reference: https://github.com/civitai/civitai/blob/main/src/server/common/constants.ts
Last update: https://github.com/civitai/civitai/blob/a2e6d267eefe6f44811a640c570739bcb078e4a5/src/server/common/constants.ts#L138-L165
"""
def sampler_with_karras_exponential(sampler, scheduler):
match scheduler:
case "karras":
sampler += " Karras"
case "exponential":
sampler += " Exponential"
return sampler
def sampler_with_karras(sampler, scheduler):
if scheduler == "karras":
return sampler + " Karras"
return sampler
sampler = cls._value_from_entries(sampler_names)
scheduler = cls._value_from_entries(schedulers) or "normal"
if sampler is None:
return ""
match sampler:
case "euler" | "euler_cfg_pp":
return "Euler"
case "euler_ancestral" | "euler_ancestral_cfg_pp":
return "Euler a"
case "heun" | "heunpp2":
return "Huen"
case "dpm_2":
return sampler_with_karras("DPM2", scheduler)
case "dpm_2_ancestral":
return sampler_with_karras("DPM2 a", scheduler)
case "lms":
return sampler_with_karras("LMS", scheduler)
case "dpm_fast":
return "DPM fast"
case "dpm_adaptive":
return "DPM adaptive"
case "dpmpp_2s_ancestral":
return sampler_with_karras("DPM++ 2S a", scheduler)
case "dpmpp_sde" | "dpmpp_sde_gpu":
return sampler_with_karras("DPM++ SDE", scheduler)
case "dpmpp_2m":
return sampler_with_karras("DPM++ 2M", scheduler)
case "dpmpp_2m_sde" | "dpmpp_2m_sde_gpu":
return sampler_with_karras("DPM++ 2M SDE", scheduler)
case "dpmpp_3m_sde" | "dpmpp_3m_sde_gpu":
return sampler_with_karras_exponential("DPM++ 3M SDE", scheduler)
case "lcm":
return "LCM"
case "ddim":
return "DDIM"
case "uni_pc" | "uni_pc_bh2":
return "UniPC"
if scheduler == "normal":
return sampler
return sampler + "_" + scheduler