Merge pull request #63 from StableLlama/fix_too_large_EXIF

Fix image saving and allow list empty entry filtering
This commit is contained in:
StableLlama
2026-10-16 19:24:57 +02:00
committed by GitHub
4 changed files with 561 additions and 52 deletions
+15 -9
View File
@@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## [2.2.0] - 2026-10-16
Added:
- Data List and LIST: new "filter empty" node that removes every empty entry
@@ -13,8 +13,6 @@ Added:
An optional boolean switch also treats a STRING consisting only of whitespace
as empty.
## [2.1.1] - 2026-10-07
Fixed:
- "save IMAGE to file" and "save IMAGE+MASK to file" no longer fail with
"EXIF data is too long" when saving a JPG whose embedded workflow metadata is
@@ -24,6 +22,14 @@ Fixed:
and the reduction is reported; the image itself is always written. Should
embedding metadata ever fail for another reason, the image is written again
without metadata instead of not at all.
- "save IMAGE to file" and "save IMAGE+MASK to file": the `parameters` metadata
now ends with the sampler settings of the workflow (`Steps: 8, Sampler: euler,
Schedule type: simple, CFG scale: 1, Seed: 51, Size: 1024x1024`). Sites like
Civitai only read the prompt out of a `parameters` text that contains such a
line; without it the whole text is discarded and the explicitly set `prompt`
was not extracted at all. The settings are read from the sampler node of the
workflow and nothing is invented - when the graph does not provide them, the
text stays the plain prompt as before.
## [2.1.0] - 2026-09-19
@@ -34,8 +40,8 @@ Backward compatible changes:
it saved and the images appear in ComfyUI's generated-images list. In addition
both nodes got a second `images` output that passes the images through
unchanged, like the built-in node does.
- files written somewhere else (plain `path` mode) are still saved, they just
cannot be previewed, as ComfyUI can only serve files from its own folders
- files written somewhere else (plain `path` mode) are still saved, they just
cannot be previewed, as ComfyUI can only serve files from its own folders
- Both save nodes now embed ComfyUI's workflow metadata (the API prompt and the
full workflow) in addition to the existing prompt metadata. Dragging a saved
file back onto the canvas therefore restores the workflow, while the explicitly
@@ -44,8 +50,8 @@ Backward compatible changes:
two channels worth having when a whole batch is saved from a data list: the
workflow then contains every prompt of the graph, whereas `parameters` always
holds the prompt of the image that was actually written.
- ComfyUI's `--disable-metadata` option suppresses both channels, exactly like
it does for the built-in save node
- ComfyUI's `--disable-metadata` option suppresses both channels, exactly like
it does for the built-in save node
- No widget was added, removed or reordered, so existing workflows keep their
saved widget values.
@@ -54,7 +60,7 @@ Backward compatible changes:
Critical change that ensures reliablity and **should** have no bad side
effects, but when you see any, please report:
- DICT, LIST, SET and Data List nodes (plus the DICT/LIST-taking STRING nodes)
- DICT, LIST, SET and Data List nodes (plus the DICT/LIST-taking STRING nodes)
now degrade gracefully when their data source is not connected (`None`) instead
of raising: read/query nodes return their natural "no data" result (the `get`
default, an empty list, `False`, `0`, `None`), and transform nodes behave as if
@@ -69,7 +75,7 @@ effects, but when you see any, please report:
as `%date:yyyy-MM-dd%`
Backward compatible changes:
- Add a "continue if not empty" flow-control node: it passes a value through
- Add a "continue if not empty" flow-control node: it passes a value through
unchanged when it is non-empty, and blocks execution (via an execution blocker)
when the value is empty. It is list-aware (`INPUT_IS_LIST` / `OUTPUT_IS_LIST`),
so it is immune to ComfyUI's empty-input batching crash and passes data lists
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "basic_data_handling"
version = "2.1.0"
version = "2.2.0"
description = """Basic Python functions for manipulating data that every programmer is used to, lightweight with no additional dependencies.
Supported data types:
+283 -42
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):
@@ -1272,6 +1404,50 @@ def build_xmp_packet(metadata_text: str) -> bytes:
return packet.encode("utf-8")
# A JPEG file carries EXIF in a single APP1 marker, whose length field is 16 bit:
# Pillow refuses to write anything bigger than this with "EXIF data is too long".
# The full workflow JSON of a non-trivial graph exceeds that easily, so JPEG EXIF
# must be trimmed (see ``_fit_jpeg_exif``) instead of failing the whole save.
_JPEG_MAX_EXIF_BYTES = 65533
def _fit_jpeg_exif(metadata_text: str, workflow_metadata: Optional[dict[str, str]] = None) -> Optional[bytes]:
"""
Build a JPEG EXIF block that fits into Pillow's single-APP1-marker limit.
A JPEG's EXIF lives in one 16-bit-sized segment, so anything beyond
``_JPEG_MAX_EXIF_BYTES`` makes Pillow raise "EXIF data is too long" and fail
the entire save. As the full-graph workflow JSON of a real graph is much
larger than that, the metadata channels are dropped one by one - least
essential first - until the block fits, and what was dropped is reported.
The image itself is always written.
Returns the EXIF bytes, or ``None`` when even the plain prompt does not fit
(nothing is embedded then).
"""
workflow_metadata = workflow_metadata or {}
# Richest first: everything, then without the (usually huge) full-graph
# "workflow" entry, then without any workflow info, then without the prompt.
without_workflow_graph = {key: value for key, value in workflow_metadata.items() if key != "workflow"}
candidates: list[tuple[str, dict[str, str], str]] = [(metadata_text, dict(workflow_metadata), "")]
if len(without_workflow_graph) != len(workflow_metadata):
candidates.append((metadata_text, without_workflow_graph, 'had to drop the full "workflow" graph'))
if workflow_metadata:
candidates.append((metadata_text, {}, "had to drop the workflow prompt info"))
if metadata_text:
candidates.append(("", {}, "had to drop the prompt metadata"))
for text, wf_meta, note in candidates:
exif = build_image_exif(text, include_description=True, workflow_metadata=wf_meta)
if exif is not None and len(exif) <= _JPEG_MAX_EXIF_BYTES:
if note:
print(f"Basic data handling: The metadata is too large for a JPEG EXIF block; {note}.")
return exif
print("Basic data handling: The metadata is too large for a JPEG EXIF block; saving the image without metadata.")
return None
def metadata_save_kwargs(metadata_text: str, fmt: str, workflow_metadata: Optional[dict[str, str]] = None) -> dict:
"""
Return the extra keyword arguments that embed ``metadata_text`` (the explicit
@@ -1288,7 +1464,7 @@ def metadata_save_kwargs(metadata_text: str, fmt: str, workflow_metadata: Option
pnginfo = build_png_info(metadata_text, workflow_metadata)
return {"pnginfo": pnginfo} if pnginfo is not None else {}
if fmt in ("jpg", "jpeg"):
exif = build_image_exif(metadata_text, include_description=True, workflow_metadata=workflow_metadata)
exif = _fit_jpeg_exif(metadata_text, workflow_metadata)
return {"exif": exif} if exif is not None else {}
if fmt in ("webp", "jxl"):
exif = build_image_exif(metadata_text, include_description=False, workflow_metadata=workflow_metadata)
@@ -1626,34 +1802,57 @@ def _has_jxl_support() -> bool:
return False
def _save_pil_image(
pil_img, path: str, fmt: str, quality: int, metadata_text: str, workflow_metadata: Optional[dict[str, str]] = None
) -> None:
"""
Write ``pil_img`` to ``path`` once, letting any error propagate. ``fmt`` is a
canonical save token (see ``_PIL_FORMAT_BY_TOKEN``). For the lossy/metadata-
capable set (png/jpg/webp/jxl) the prompt text and the workflow metadata are
embedded and ``quality`` is honoured; all other discovered formats are
written plainly.
"""
if fmt == "png":
pil_img.save(path, format="PNG", **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "jpg":
# JPEG cannot carry alpha; drop it so an RGBA input still saves.
pil_img.convert("RGB").save(path, format="JPEG", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "webp":
pil_img.save(path, format="WEBP", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "jxl":
pil_img.save(path, format="JXL", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
else:
if metadata_text or workflow_metadata:
print("Basic data handling: Prompt/workflow metadata is not supported for this format; skipping it.")
pil_img.save(path, format=_PIL_FORMAT_BY_TOKEN.get(fmt, fmt.upper()))
def _write_pil_image(
pil_img, path: str, fmt: str, quality: int, metadata_text: str, workflow_metadata: Optional[dict[str, str]] = None
) -> bool:
"""
Write ``pil_img`` to ``path``. ``fmt`` is a canonical save token (see
``_PIL_FORMAT_BY_TOKEN``). For the lossy/metadata-capable set (png/jpg/webp/
jxl) the prompt text and the workflow metadata are embedded and ``quality``
is honoured; all other discovered formats are written plainly. Returns True
on success.
Write ``pil_img`` to ``path``, returning True on success.
Embedded metadata must never cost the user their image: when the first
attempt fails and metadata was involved, the image is written again without
any metadata (a format/library limit on the metadata side, e.g. a container
that cannot hold the requested block) and that is reported. Formats without
metadata support are written plainly from the start.
"""
try:
if fmt == "png":
pil_img.save(path, format="PNG", **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "jpg":
# JPEG cannot carry alpha; drop it so an RGBA input still saves.
pil_img.convert("RGB").save(path, format="JPEG", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "webp":
pil_img.save(path, format="WEBP", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
elif fmt == "jxl":
pil_img.save(path, format="JXL", quality=quality, **metadata_save_kwargs(metadata_text, fmt, workflow_metadata))
else:
if metadata_text or workflow_metadata:
print("Basic data handling: Prompt/workflow metadata is not supported for this format; skipping it.")
pil_img.save(path, format=_PIL_FORMAT_BY_TOKEN.get(fmt, fmt.upper()))
_save_pil_image(pil_img, path, fmt, quality, metadata_text, workflow_metadata)
return True
except Exception as e:
print(f"Basic data handling: Error saving image: {e}")
return False
if not metadata_text and not workflow_metadata:
print(f"Basic data handling: Error saving image: {e}")
return False
try:
print(f"Basic data handling: Saving with metadata failed ({e}); retrying without metadata.")
_save_pil_image(pil_img, path, fmt, quality, "", None)
return True
except Exception as retry_error:
print(f"Basic data handling: Error saving image: {retry_error}")
return False
def _ui_image_entry(path: str) -> Optional[dict[str, str]]:
@@ -1709,12 +1908,18 @@ 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.
A JPEG can only hold one 16-bit-sized EXIF block, so for very large
workflows the workflow info is reduced (largest channel first) instead of
failing the save; the image itself is always written.
"""
@classmethod
@@ -1740,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.
@@ -1806,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).
@@ -1855,12 +2075,18 @@ 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.
A JPEG can only hold one 16-bit-sized EXIF block, so for very large
workflows the workflow info is reduced (largest channel first) instead of
failing the save; the image itself is always written.
"""
@classmethod
@@ -1888,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.
@@ -1965,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).
+262
View File
@@ -953,6 +953,173 @@ def test_path_save_image_embeds_workflow_and_explicit_prompt(tmp_path, monkeypat
assert json.loads(img.text["workflow"]) == extra_pnginfo["workflow"]
#: The sampler settings an A1111 infotext reader knows. Readers (Civitai and
#: similar) scan the ``parameters`` text from its end for a line holding at least
#: three of them - ``Sampler`` among them - and discard the whole text when there
#: is no such line, so the prompt would not be extracted either.
_A1111_SAMPLER_KEYS = ("Sampler", "CFG scale", "Seed", "Steps", "ENSD", "Schedule type")
def read_as_a1111_parameters(parameters_text):
"""Return the sampler settings a reader finds in a ``parameters`` text."""
for line in reversed(parameters_text.splitlines()):
settings = {}
for item in line.split(","):
key, separator, value = item.partition(":")
if separator and key.strip() in _A1111_SAMPLER_KEYS:
settings[key.strip()] = value.strip()
if len(settings) >= 3 and "Sampler" in settings:
return settings
return None
def _sampler_workflow(**overrides):
"""An API workflow prompt holding one KSampler with literal settings."""
settings = {"steps": 8, "cfg": 1.0, "sampler_name": "euler", "scheduler": "simple", "seed": 51, "denoise": 1.0}
settings.update(overrides)
return {"3": {"class_type": "KSampler", "inputs": {"model": ["10", 0], "positive": ["6", 0], **settings}}}
def test_path_save_image_parameters_text_carries_the_sampler_settings(tmp_path):
"""The explicit prompt is only extracted by readers like Civitai when the
``parameters`` text ends with a ``Steps: ...`` line, so the save node adds the
sampler settings of the workflow below the prompt."""
save_node = PathSaveImageRGB()
out = str(tmp_path / "a1111_meta")
result = save_node.save_image(
_batch_rgb(1),
out,
prompt="the prompt of this image",
negative_prompt="bad",
workflow_prompt=_sampler_workflow(),
workflow_extra_pnginfo={"workflow": {"nodes": [{"id": 3}]}},
)
assert save_success(result) is True
with Image.open(out + ".png") as img:
img.load()
parameters = img.text["parameters"]
assert parameters == (
"the prompt of this image\nNegative prompt: bad\n"
"Steps: 8, Sampler: euler, Schedule type: simple, CFG scale: 1, Seed: 51, Size: 16x16"
)
# Everything above the settings line is the prompt, exactly what a reader
# of A1111 parameters text expects.
assert read_as_a1111_parameters(parameters) == {
"Steps": "8",
"Sampler": "euler",
"Schedule type": "simple",
"CFG scale": "1",
"Seed": "51",
}
def test_path_save_image_with_mask_also_carries_the_sampler_settings(tmp_path):
"""Both save nodes share the metadata composition."""
save_node = PathSaveImageRGBA()
out = str(tmp_path / "a1111_rgba")
result = save_node.save_image_with_mask(
_batch_rgb(1), torch.zeros(1, 16, 16), out, prompt="explicit", workflow_prompt=_sampler_workflow()
)
assert save_success(result) is True
with Image.open(out + ".png") as img:
img.load()
assert img.text["parameters"].endswith("\nSteps: 8, Sampler: euler, Schedule type: simple, CFG scale: 1, Seed: 51, Size: 16x16")
def test_path_save_image_sampler_settings_follow_value_links(tmp_path):
"""A sampler widget is often driven by a plain value node (this pack's
``IntCreate`` and the like): such a link carries the setting, while a device
that computes a value stays unknown and is left out rather than guessed."""
workflow_prompt = {
"3": {
"class_type": "KSampler",
"inputs": {"steps": ["5", 0], "cfg": 3.5, "sampler_name": "dpmpp_2m", "seed": ["7", 0], "scheduler": "karras"},
},
"5": {"class_type": "Basic data handling: IntCreate", "inputs": {"value": "20"}},
"7": {"class_type": "RandomInt", "inputs": {"min": 0, "max": 100}},
}
out = str(tmp_path / "linked")
result = PathSaveImageRGB().save_image(_batch_rgb(1), out, prompt="explicit", workflow_prompt=workflow_prompt)
assert save_success(result) is True
with Image.open(out + ".png") as img:
img.load()
# "Steps: 20" comes from the value node, the seed cannot be resolved and
# is therefore missing - which the line itself has to survive.
assert img.text["parameters"] == "explicit\nSteps: 20, Sampler: dpmpp_2m, Schedule type: karras, CFG scale: 3.5, Size: 16x16"
assert read_as_a1111_parameters(img.text["parameters"]) == {
"Steps": "20",
"Sampler": "dpmpp_2m",
"Schedule type": "karras",
"CFG scale": "3.5",
}
def test_path_save_image_uses_the_last_pass_of_a_multi_sampler_workflow(tmp_path):
"""A hires fix runs two samplers; like A1111 (which reports its second pass)
the settings of the pass that produced the file are the ones written."""
workflow_prompt = {
"1": _sampler_workflow()["3"],
"2": {"class_type": "LatentUpscale", "inputs": {"samples": ["1", 0]}},
"3": {
"class_type": "KSampler",
"inputs": {"steps": 20, "cfg": 7.0, "sampler_name": "dpmpp_2m", "scheduler": "karras", "seed": 52, "latent_image": ["2", 0]},
},
}
out = str(tmp_path / "second_pass")
result = PathSaveImageRGB().save_image(_batch_rgb(1), out, prompt="explicit", workflow_prompt=workflow_prompt)
assert save_success(result) is True
with Image.open(out + ".png") as img:
img.load()
assert (
img.text["parameters"] == "explicit\nSteps: 20, Sampler: dpmpp_2m, Schedule type: karras, CFG scale: 7, Seed: 52, Size: 16x16"
)
def test_path_save_image_without_sampler_settings_keeps_the_plain_prompt(tmp_path):
"""A workflow whose settings are spread over several nodes gets no settings
line at all - the text stays the plain prompt of the previous versions."""
workflow_prompt = {
"3": {"class_type": "SamplerCustomAdvanced", "inputs": {"noise": ["4", 0], "guider": ["5", 0], "sampler": ["6", 0]}},
"4": {"class_type": "BasicScheduler", "inputs": {"steps": 20, "scheduler": "simple"}},
}
out = str(tmp_path / "no_settings")
result = PathSaveImageRGB().save_image(_batch_rgb(1), out, prompt="explicit", workflow_prompt=workflow_prompt)
assert save_success(result) is True
with Image.open(out + ".png") as img:
img.load()
assert img.text["parameters"] == "explicit"
assert read_as_a1111_parameters(img.text["parameters"]) is None
def test_build_generation_parameters_reads_only_what_the_graph_provides():
"""The settings line is derived from the workflow and never invented."""
import src.basic_data_handling.path_nodes as pn
assert pn.build_generation_parameters(None) == ""
assert pn.build_generation_parameters("not a workflow") == ""
assert pn.build_generation_parameters({}) == ""
# The image size is part of the line only when it is known ...
assert pn.build_generation_parameters(_sampler_workflow()) == "Steps: 8, Sampler: euler, Schedule type: simple, CFG scale: 1, Seed: 51"
# ... a sampling that only partly denoises is reported like A1111 does ...
assert pn.build_generation_parameters(_sampler_workflow(denoise=0.6)).endswith("Denoising strength: 0.6")
# ... and without the settings the line is built around there is no line.
for missing in ("steps", "cfg", "sampler_name"):
workflow_prompt = _sampler_workflow()
del workflow_prompt["3"]["inputs"][missing]
assert pn.build_generation_parameters(workflow_prompt) == ""
def test_path_save_image_workflow_metadata_in_exif(tmp_path, monkeypatch):
"""Formats without a native text chunk keep the explicit prompt in
UserComment and store the workflow in the fields ComfyUI uses for them."""
@@ -977,6 +1144,101 @@ def test_path_save_image_workflow_metadata_in_exif(tmp_path, monkeypatch):
assert exif.get(0x010F) == "workflow:" + json.dumps(extra_pnginfo["workflow"])
def _oversized_workflow_metadata():
"""A workflow whose full graph JSON comfortably exceeds one JPEG EXIF block
(a JPEG carries EXIF in a single 16-bit-sized APP1 marker)."""
big_graph = {"nodes": [{"id": i, "type": "SomeNode", "widgets_values": ["x" * 200]} for i in range(300)]}
workflow_prompt = {"3": {"class_type": "KSampler", "inputs": {"text": "a" * 500}}}
return workflow_prompt, {"workflow": big_graph}
def test_path_save_image_oversized_jpeg_metadata_keeps_prompt(tmp_path, capsys):
"""Too much metadata for a JPEG EXIF block must not fail the save: the
largest (full-graph) channel is dropped, the rest is kept, and it is
reported."""
save_node = PathSaveImageRGB()
img = _batch_rgb(1)
workflow_prompt, extra_pnginfo = _oversized_workflow_metadata()
out = str(tmp_path / "oversized")
result = save_node.save_image(
img, out, format="jpg", prompt="explicit", workflow_prompt=workflow_prompt, workflow_extra_pnginfo=extra_pnginfo
)
assert save_success(result) is True
assert os.path.exists(out + ".jpg")
with Image.open(out + ".jpg") as loaded:
loaded.load()
assert loaded.format == "JPEG"
exif = loaded.getexif()
# The explicit prompt (and the smaller API prompt) survive ...
assert decode_user_comment(exif.get_ifd(0x8769).get(0x9286)) == "explicit"
assert exif.get(0x0110) == "prompt:" + json.dumps(workflow_prompt)
# ... while the oversized full workflow graph was dropped.
assert 0x010F not in exif
assert 'had to drop the full "workflow" graph' in capsys.readouterr().out
def test_path_save_image_oversized_jpeg_drops_all_metadata_but_saves(tmp_path, capsys):
"""When even the remaining channels do not fit, the image is still written -
just without metadata."""
save_node = PathSaveImageRGB()
img = _batch_rgb(1)
_, extra_pnginfo = _oversized_workflow_metadata()
out = str(tmp_path / "huge")
# The explicit prompt itself is far beyond a single EXIF block.
result = save_node.save_image(img, out, format="jpg", prompt="x" * 70000, workflow_extra_pnginfo=extra_pnginfo)
assert save_success(result) is True
assert os.path.exists(out + ".jpg")
with Image.open(out + ".jpg") as loaded:
loaded.load()
assert loaded.format == "JPEG"
assert not loaded.getexif()
assert "saving the image without metadata" in capsys.readouterr().out
def test_path_save_image_png_keeps_oversized_workflow(tmp_path):
"""Formats with room enough (PNG text chunks) keep the metadata untouched."""
save_node = PathSaveImageRGB()
img = _batch_rgb(1)
workflow_prompt, extra_pnginfo = _oversized_workflow_metadata()
out = str(tmp_path / "big_png")
result = save_node.save_image(img, out, format="png", workflow_prompt=workflow_prompt, workflow_extra_pnginfo=extra_pnginfo)
assert save_success(result) is True
with Image.open(out + ".png") as loaded:
loaded.load()
assert json.loads(loaded.text["workflow"]) == extra_pnginfo["workflow"]
def test_write_pil_image_retries_without_metadata(tmp_path, monkeypatch):
"""If embedding metadata fails for an unexpected reason, the image is saved
again without it instead of losing the image."""
import src.basic_data_handling.path_nodes as pn
attempts = []
def fake_save(pil_img, path, fmt, quality, metadata_text, workflow_metadata=None):
attempts.append(bool(metadata_text or workflow_metadata))
if metadata_text or workflow_metadata:
raise ValueError("EXIF data is too long")
pil_img.save(path, format="PNG")
monkeypatch.setattr(pn, "_save_pil_image", fake_save)
img = Image.new("RGB", (8, 8), (255, 0, 0))
out = str(tmp_path / "retry.png")
assert pn._write_pil_image(img, out, "png", 95, "prompt", {"workflow": "{}"}) is True
assert attempts == [True, False]
with Image.open(out) as loaded:
loaded.load()
def test_path_save_image_metadata_can_be_disabled(tmp_path, monkeypatch):
"""ComfyUI's ``--disable-metadata`` suppresses both metadata channels, like
it does in the built-in save node."""