Make saved image Comfy compliant

This commit is contained in:
StableLlama
2026-10-16 18:28:00 +02:00
parent 5ba4e602f3
commit dd6f936003
3 changed files with 364 additions and 21 deletions
+189 -21
View File
@@ -1084,28 +1084,152 @@ class PathSaveStringFile(ComfyNodeABC):
return (False,)
def compose_prompt_text(prompt: str, negative_prompt: str) -> str:
def compose_prompt_text(prompt: str, negative_prompt: str, generation_parameters: str = "") -> str:
"""
Build the generation-parameter text embedded into saved images.
Follows the Stable Diffusion WebUI convention: the (optional) positive
prompt is written first, followed by an optional ``Negative prompt:``
line::
prompt is written first, followed by an optional ``Negative prompt:`` line
and the optional ``generation_parameters`` line (see
``build_generation_parameters``)::
<positive prompt>
Negative prompt: <negative prompt>
Steps: 8, Sampler: euler, ...
Returns an empty string when neither value is provided, in which case no
metadata is embedded into the file.
Returns an empty string when nothing is provided, in which case no metadata
is embedded into the file.
"""
lines = []
if prompt.strip():
lines.append(prompt.strip())
if negative_prompt.strip():
lines.append(f"Negative prompt: {negative_prompt.strip()}")
if generation_parameters:
lines.append(generation_parameters)
return "\n".join(lines)
def _is_node_link(value: Any) -> bool:
"""True for a ComfyUI ``[node_id, output_index]`` input link."""
return isinstance(value, (list, tuple)) and len(value) == 2 and isinstance(value[0], str) and isinstance(value[1], int)
def _static_input_value(value: Any, workflow_prompt: Any) -> Any:
"""
Return a sampler setting as the value the graph was run with.
An input that is not driven by a link is serialised as its plain value: that
is the value. A driven one is a link instead, and what it carries is unknown
in general - but when the link points at a node consisting of nothing but a
``value`` input (ComfyUI's primitives, this pack's ``IntCreate``/
``FloatCreate``, ...) then that ``value`` *is* the setting the user typed, so
it is taken. Anything computed stays unknown (``None``) instead of being
guessed, and the same holds for everything that is not a single value (a
list input feeding a sampler is a batch, not a setting).
"""
if not _is_node_link(value):
return value
if not isinstance(workflow_prompt, dict):
return None
node = workflow_prompt.get(value[0])
inputs = node.get("inputs") if isinstance(node, dict) else None
if isinstance(inputs, dict) and len(inputs) == 1:
source = inputs.get("value")
if isinstance(source, (str, int, float)):
return source
return None
def _ancestor_node_ids(node_id: str, workflow_prompt: dict) -> set[str]:
"""All node ids reachable from ``node_id`` by following its input links."""
seen: set[str] = set()
stack = [node_id]
while stack:
node = workflow_prompt.get(stack.pop())
inputs = node.get("inputs") if isinstance(node, dict) else None
if not isinstance(inputs, dict):
continue
for value in inputs.values():
if _is_node_link(value) and value[0] not in seen:
seen.add(value[0])
stack.append(value[0])
return seen
def _format_setting(value: Any) -> str:
"""Format one sampler setting the way A1111 writes it (``1.0`` -> ``1``)."""
if isinstance(value, float) and value.is_integer():
return str(int(value))
return str(value)
def build_generation_parameters(workflow_prompt: Any, width: int = 0, height: int = 0) -> str:
"""
Compose the A1111-style generation-parameters line of a save.
Readers of the ``parameters`` text look for a line holding the sampler
settings (``Steps:``, ``Sampler:``, ``CFG scale:``, ...) and treat the whole
text as unreadable when there is none - the explicitly set prompt would then
not be extracted at all (which is what Civitai did with the saved files).
The settings are therefore read from the sampler node of ComfyUI's own
workflow prompt (``PROMPT``), so the line describes the graph that produced
the image, e.g.::
Steps: 8, Sampler: euler, Schedule type: simple, CFG scale: 1, Seed: 51, Size: 1024x1024
Only settings the graph really provides are written, and only when they are
known without guessing (see ``_static_input_value``). Returns an empty
string when the three settings the line is built around (``steps``, ``cfg``,
``sampler_name``) are not all available; the ``parameters`` text then stays
the plain prompt text.
"""
if not isinstance(workflow_prompt, dict):
return ""
samplers: dict[str, dict] = {}
for node_id, node in workflow_prompt.items():
inputs = node.get("inputs") if isinstance(node, dict) else None
if isinstance(inputs, dict) and all(key in inputs for key in ("steps", "cfg", "sampler_name")):
samplers[str(node_id)] = inputs
if not samplers:
return ""
# A graph may hold several samplers (hires fix and other multi-pass
# workflows). The pass that ran last describes the file, exactly like A1111
# reports the settings of its second pass: that is a sampler whose output no
# other sampler consumed (it is no other sampler's ancestor), and among those
# the one with the deepest dependency chain.
ancestors = {node_id: _ancestor_node_ids(node_id, workflow_prompt) for node_id in samplers}
consumed_by_a_later_pass = set().union(*ancestors.values())
last_pass = [node_id for node_id in samplers if node_id not in consumed_by_a_later_pass]
chosen = max(last_pass or list(samplers), key=lambda node_id: len(ancestors[node_id]))
inputs = samplers[chosen]
steps = _static_input_value(inputs["steps"], workflow_prompt)
cfg = _static_input_value(inputs["cfg"], workflow_prompt)
sampler = _static_input_value(inputs["sampler_name"], workflow_prompt)
if steps is None or cfg is None or not sampler:
return ""
settings = [f"Steps: {_format_setting(steps)}", f"Sampler: {sampler}"]
scheduler = _static_input_value(inputs.get("scheduler"), workflow_prompt)
if scheduler:
settings.append(f"Schedule type: {scheduler}")
settings.append(f"CFG scale: {_format_setting(cfg)}")
seed = _static_input_value(inputs.get("seed"), workflow_prompt)
if seed is None:
seed = _static_input_value(inputs.get("noise_seed"), workflow_prompt)
if seed is not None:
settings.append(f"Seed: {_format_setting(seed)}")
if width and height:
settings.append(f"Size: {width}x{height}")
denoise = _static_input_value(inputs.get("denoise"), workflow_prompt)
if isinstance(denoise, (int, float)) and not isinstance(denoise, bool) and float(denoise) != 1.0:
settings.append(f"Denoising strength: {_format_setting(denoise)}")
return ", ".join(settings)
def _metadata_disabled() -> bool:
"""
True when ComfyUI was started with ``--disable-metadata``.
@@ -1145,7 +1269,7 @@ def build_workflow_metadata(workflow_prompt: Any, workflow_extra_pnginfo: Any) -
def compose_save_metadata(
prompt: str, negative_prompt: str, workflow_prompt: Any, workflow_extra_pnginfo: Any
prompt: str, negative_prompt: str, workflow_prompt: Any, workflow_extra_pnginfo: Any, width: int = 0, height: int = 0
) -> tuple[str, dict[str, str]]:
"""
Build both metadata channels of an image save.
@@ -1154,13 +1278,21 @@ def compose_save_metadata(
``parameters`` text of the explicit prompt(s) and the ``{key: json}`` mapping
of ComfyUI's workflow prompt info.
The prompt text is completed by the sampler settings of the workflow (see
``build_generation_parameters``): without that line readers like Civitai
discard the whole ``parameters`` text as unreadable, so the explicitly set
prompt would not be extracted.
ComfyUI's ``--disable-metadata`` flag suppresses *all* metadata, exactly like
the built-in save node does, so a run started with it writes files without
either channel.
"""
if _metadata_disabled():
return "", {}
return compose_prompt_text(prompt, negative_prompt), build_workflow_metadata(workflow_prompt, workflow_extra_pnginfo)
generation_parameters = build_generation_parameters(workflow_prompt, width, height)
return compose_prompt_text(prompt, negative_prompt, generation_parameters), build_workflow_metadata(
workflow_prompt, workflow_extra_pnginfo
)
def build_png_info(metadata_text: str, workflow_metadata: Optional[dict[str, str]] = None):
@@ -1776,9 +1908,12 @@ class PathSaveImageRGB(ComfyNodeABC):
Two independent metadata channels are embedded, so both stay usable: with
``prompt``/``negative_prompt`` the A1111-style ``parameters`` text is written
(PNG text chunk, EXIF ``UserComment``, XMP for JPEG XL) which image viewers
display and which is easy to extract afterwards, and in addition ComfyUI's
hidden workflow prompt info (``prompt``/``workflow`` chunks) is stored, which
is what makes a saved file drag-and-drop back into ComfyUI as a workflow.
display and which is easy to extract afterwards - completed by the
``Steps: ...`` line of the workflow's sampler settings, without which readers
like Civitai discard the whole text as unreadable - and in addition
ComfyUI's hidden workflow prompt info (``prompt``/``workflow`` chunks) is
stored, which is what makes a saved file drag-and-drop back into ComfyUI as
a workflow.
When a batch is saved from a data list the workflow necessarily contains
every prompt of the graph; the prompt of the individual image is the one in
``parameters``. Formats that cannot carry text metadata ignore both.
@@ -1810,8 +1945,20 @@ class PathSaveImageRGB(ComfyNodeABC):
),
"quality": (IO.INT, {"default": 95, "min": 1, "max": 100, "tooltip": "Quality for lossy formats (jpg/webp/jxl)."}),
"create_dirs": (IO.BOOLEAN, {"default": True, "tooltip": "Create missing parent directories (plain path mode only)."}),
"prompt": (IO.STRING, {"default": "", "tooltip": "Optional positive prompt embedded as parameters metadata."}),
"negative_prompt": (IO.STRING, {"default": "", "tooltip": "Optional negative prompt embedded as parameters metadata."}),
"prompt": (
IO.STRING,
{
"default": "",
"tooltip": 'Optional positive prompt embedded as "parameters" metadata, together with the sampler settings of the workflow (readers like Civitai need them to extract the prompt).',
},
),
"negative_prompt": (
IO.STRING,
{
"default": "",
"tooltip": 'Optional negative prompt embedded as "parameters" metadata, together with the sampler settings of the workflow (readers like Civitai need them to extract the prompt).',
},
),
# Appended last on purpose: the node stores widget values
# positionally, so adding before the existing widgets would shift
# old workflows and mis-assign their saved values.
@@ -1876,8 +2023,11 @@ class PathSaveImageRGB(ComfyNodeABC):
paths, create_dirs = _plan_save_paths(path, fmt, use_prefix_mode, batch, width, height)
# Compose both metadata channels: the explicit prompt(s) of this save
# (the A1111-style "parameters" text) and ComfyUI's workflow prompt info.
metadata_text, workflow_metadata = compose_save_metadata(prompt, negative_prompt, workflow_prompt, workflow_extra_pnginfo)
# (the A1111-style "parameters" text, completed by the sampler settings
# of the workflow) and ComfyUI's workflow prompt info.
metadata_text, workflow_metadata = compose_save_metadata(
prompt, negative_prompt, workflow_prompt, workflow_extra_pnginfo, width, height
)
# Reporting the written files makes the frontend preview them and list
# them with the generated images (same payload as the built-in node).
@@ -1925,9 +2075,12 @@ class PathSaveImageRGBA(ComfyNodeABC):
Two independent metadata channels are embedded, so both stay usable: with
``prompt``/``negative_prompt`` the A1111-style ``parameters`` text is written
(PNG text chunk, EXIF ``UserComment``, XMP for JPEG XL) which image viewers
display and which is easy to extract afterwards, and in addition ComfyUI's
hidden workflow prompt info (``prompt``/``workflow`` chunks) is stored, which
is what makes a saved file drag-and-drop back into ComfyUI as a workflow.
display and which is easy to extract afterwards - completed by the
``Steps: ...`` line of the workflow's sampler settings, without which readers
like Civitai discard the whole text as unreadable - and in addition
ComfyUI's hidden workflow prompt info (``prompt``/``workflow`` chunks) is
stored, which is what makes a saved file drag-and-drop back into ComfyUI as
a workflow.
When a batch is saved from a data list the workflow necessarily contains
every prompt of the graph; the prompt of the individual image is the one in
``parameters``. Formats that cannot carry text metadata ignore both.
@@ -1961,8 +2114,20 @@ class PathSaveImageRGBA(ComfyNodeABC):
"quality": (IO.INT, {"default": 95, "min": 1, "max": 100, "tooltip": "Quality for lossy formats (webp/jxl)."}),
"invert_mask": (IO.BOOLEAN, {"default": False, "tooltip": "Invert the mask before using it as the alpha channel."}),
"create_dirs": (IO.BOOLEAN, {"default": True, "tooltip": "Create missing parent directories (plain path mode only)."}),
"prompt": (IO.STRING, {"default": "", "tooltip": "Optional positive prompt embedded as parameters metadata."}),
"negative_prompt": (IO.STRING, {"default": "", "tooltip": "Optional negative prompt embedded as parameters metadata."}),
"prompt": (
IO.STRING,
{
"default": "",
"tooltip": 'Optional positive prompt embedded as "parameters" metadata, together with the sampler settings of the workflow (readers like Civitai need them to extract the prompt).',
},
),
"negative_prompt": (
IO.STRING,
{
"default": "",
"tooltip": 'Optional negative prompt embedded as "parameters" metadata, together with the sampler settings of the workflow (readers like Civitai need them to extract the prompt).',
},
),
# Appended last on purpose: the node stores widget values
# positionally, so adding before the existing widgets would shift
# old workflows and mis-assign their saved values.
@@ -2038,8 +2203,11 @@ class PathSaveImageRGBA(ComfyNodeABC):
mask_frames = mask.cpu()
# Compose both metadata channels: the explicit prompt(s) of this save
# (the A1111-style "parameters" text) and ComfyUI's workflow prompt info.
metadata_text, workflow_metadata = compose_save_metadata(prompt, negative_prompt, workflow_prompt, workflow_extra_pnginfo)
# (the A1111-style "parameters" text, completed by the sampler settings
# of the workflow) and ComfyUI's workflow prompt info.
metadata_text, workflow_metadata = compose_save_metadata(
prompt, negative_prompt, workflow_prompt, workflow_extra_pnginfo, width, height
)
# Reporting the written files makes the frontend preview them and list
# them with the generated images (same payload as the built-in node).