Files
AdiosF6F 4af6182d57 feat(nodes): implement deferred execution pipe architecture
Introduce an optional Pipe architecture to streamline metadata management
and reduce wire clutter in complex, multi-stage workflows.

This new system utilizes deferred execution (lazy evaluation), passing
metadata and configuration state as raw dictionaries through the pipe.
Expensive operations, such as checkpoint hashing and Civitai API lookups,
are now deferred until the final image save execution. This approach
prevents premature data mutation and significantly reduces execution
overhead during workflow design and routing.

To maintain architectural cleanliness and follow existing module patterns,
these nodes are housed in a dedicated `nodes_pipe.py` file. The pipe
configuration uses dynamic dictionary composition to inherit UI definitions
safely without inheriting incorrect base execution logic.

Key additions:
- `MakeImageSaverSimpleConfig` & `MakeImageSaverMetadataConfig`: Provide
  standalone input configurations.
- `MakeImageSaverPipe`: Bundles configurations into a single connection.
- `EditImageSaverPipe`: Enables non-destructive branching and overrides.
- `ReadImageSaverPipe`: Allows extraction of pipe data for external use.
- `ImageSaverFromPipe`: Triggers disk/network actions.
2026-05-21 21:54:11 +02:00

307 lines
14 KiB
Python

from typing import Any
import torch
import comfy.samplers
from .nodes import ImageSaverSimple, ImageSaverMetadata
class MakeImageSaverSimpleConfig:
"""Standalone node for creating Saver Settings (filename, path, extension, etc.)."""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
base = ImageSaverSimple.INPUT_TYPES()
required = {}
for k, v in base.get("required", {}).items():
if k != "images":
required[k] = v
optional = {}
for k, v in base.get("optional", {}).items():
if k not in ["metadata", "show_preview"]:
optional[k] = v
return {
"required": required,
"optional": optional
}
RETURN_TYPES = ("SIMPLE_SAVER_CONFIG",)
RETURN_NAMES = ("simple_saver_config",)
FUNCTION = "make_config"
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Create a standalone Saver Configuration for use with Pipe nodes."
def make_config(self, **kwargs) -> tuple[dict[str, Any]]:
return (kwargs,)
class MakeImageSaverMetadataConfig:
"""Standalone node for creating Metadata Configuration."""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
# Inherit all inputs from ImageSaverMetadata to preserve exact UI order
base = ImageSaverMetadata.INPUT_TYPES()
optional = {}
for k, v in base.get("optional", {}).items():
# Renaming and UI Upgrades:
# 1. 'seed_value' -> 'seed': renamed to trigger ComfyUI's standard JS for "control_after_generate" (random/fixed).
if k == "seed_value":
optional["seed"] = v
# 2. 'sampler_name': upgraded to COMBO to enable direct wiring to KSamplers or other nodes.
elif k == "sampler_name":
optional["sampler_name"] = (comfy.samplers.KSampler.SAMPLERS, v[1])
# 3. 'scheduler_name': upgraded to COMBO to enable direct wiring.
# Note: We keep the '_name' suffix to remain consistent with the original Metadata engine's internal dictionary keys.
elif k == "scheduler_name":
optional["scheduler_name"] = (comfy.samplers.KSampler.SCHEDULERS, v[1])
else:
optional[k] = v
return {
"required": {},
"optional": optional
}
RETURN_TYPES = ("METADATA_CONFIG",)
RETURN_NAMES = ("metadata_config",)
FUNCTION = "make_config"
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Create a standalone Metadata Configuration for use with Pipe nodes."
def make_config(self, **kwargs) -> tuple[dict[str, Any]]:
# We must map 'seed' back to 'seed_value' for the author's engine
if "seed" in kwargs:
kwargs["seed_value"] = kwargs.pop("seed")
return (kwargs,)
class MakeImageSaverPipe:
"""Bundles metadata and Image Saver settings into a single Pipe connection."""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"simple_saver_config": ("SIMPLE_SAVER_CONFIG", {"tooltip": "Required standalone Saver Configuration."}),
"metadata_config": ("METADATA_CONFIG", {"tooltip": "Required standalone Metadata Configuration."}),
},
}
RETURN_TYPES = ("IMAGESAVER_PIPE",)
RETURN_NAMES = ("pipe",)
FUNCTION = "make_pipe"
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Bundles metadata and Image Saver settings into a single Pipe connection."
def make_pipe(self, simple_saver_config: dict[str, Any], metadata_config: dict[str, Any]) -> tuple[dict[str, Any]]:
# 1. Create the Pipe (Bundled Configuration)
# We store the raw configs for downstream editing (Lazy Evaluation)
pipe = {
"metadata_config": metadata_config,
"simple_saver_config": simple_saver_config
}
return (pipe,)
class EditImageSaverPipe:
"""Safely override metadata or saver settings in an existing Image Saver Pipe."""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
# 1. Dynamic Inheritance: We pull the source-of-truth from the config nodes
# This logical chaining prevents redundant upgrades/renaming.
base_meta = MakeImageSaverMetadataConfig.INPUT_TYPES()["optional"]
# 2. Scope Definition: Define which fields we want to expose for scenario branching.
exposed_keys = [
"modelname", "positive", "negative", "width", "height", "seed",
"steps", "cfg", "sampler_name", "scheduler_name", "denoise", "clip_skip", "additional_hashes", "custom"
]
# 3. Scenario Identifiers: Filename and Path are placed at the top as primary branch markers.
# We use '[original]' as a default to enable non-destructive string editing (append/prepend).
optional = {
"filename": ("STRING", {"default": "[original]", "tooltip": "String inputs support the [original] placeholder to append/prepend."}),
"path": ("STRING", {"default": "[original]", "tooltip": "String inputs support the [original] placeholder to append/prepend."}),
"counter": ("INT", {"forceInput": True}),
}
# 4. Visual Synchronization: Iteratively add metadata fields to preserve the relative order.
for k, v in base_meta.items():
if k in exposed_keys:
# UX Refinement: String fields remain text boxes (with [original] default).
# All other types (INT, FLOAT, COMBO) use forceInput: True to prevent
# accidental overwrites by default widget values.
if v[0] == "STRING":
optional[k] = (v[0], {**v[1], "default": "[original]"})
else:
optional[k] = (v[0], {**v[1], "forceInput": True})
return {
"required": {
"pipe": ("IMAGESAVER_PIPE",),
},
"optional": optional
}
RETURN_TYPES = ("IMAGESAVER_PIPE",)
RETURN_NAMES = ("pipe",)
FUNCTION = "edit_pipe"
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Safely override metadata or saver settings in an existing Image Saver Pipe. (Creates a new branch without modifying the original).\nString fields support the '[original]' placeholder to append/prepend."
def edit_pipe(self, pipe: dict[str, Any], **kwargs) -> tuple[dict[str, Any]]:
metadata_config = pipe["metadata_config"].copy()
simple_saver_config = pipe["simple_saver_config"].copy()
def apply_string_edit(new_val: Any, original_val: Any) -> Any:
if new_val is not None:
# Fast-fail if the user left the widget completely untouched
if new_val == "[original]":
return original_val
# If they modified it but kept the placeholder (e.g., "[original], masterpiece")
if isinstance(new_val, str) and "[original]" in new_val:
return new_val.replace("[original]", str(original_val) if original_val is not None else "")
# If they completely replaced the text
return new_val
return original_val
# 1. Handle Saver Config Overrides
for key in ["filename", "path"]:
simple_saver_config[key] = apply_string_edit(kwargs.get(key), simple_saver_config.get(key))
if kwargs.get("counter") is not None:
simple_saver_config["counter"] = kwargs.get("counter")
# 2. Handle Metadata Config Overrides
for key in ["modelname", "positive", "negative", "sampler_name", "scheduler_name", "additional_hashes", "custom"]:
metadata_config[key] = apply_string_edit(kwargs.get(key), metadata_config.get(key))
for key in ["width", "height", "steps", "cfg", "denoise", "clip_skip"]:
if kwargs.get(key) is not None:
metadata_config[key] = kwargs.get(key)
if kwargs.get("seed") is not None:
metadata_config["seed_value"] = kwargs.get("seed")
return ({"metadata_config": metadata_config, "simple_saver_config": simple_saver_config},)
class ReadImageSaverPipe:
"""
Extracts commonly modified metadata and saver settings from an existing Image Saver Pipe.
This node uses 'Architectural Reflection' to automatically mirror the structure
of the Edit node, ensuring the Extraction order always matches the Modification order.
"""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"pipe": ("IMAGESAVER_PIPE",),
},
}
# 1. Reflection: We dynamically pull the dictionary definition from the Edit node.
# This guarantees that if a field is added/removed in Edit, the Read node
# self-heals its output sockets instantly.
_edit_inputs = EditImageSaverPipe.INPUT_TYPES()["optional"]
# 2. Dynamic Schema: Map the input wire types directly to output wire types.
RETURN_TYPES = ("IMAGESAVER_PIPE",) + tuple(v[0] for v in _edit_inputs.values())
RETURN_NAMES = ("pipe",) + tuple(_edit_inputs.keys())
FUNCTION = "read_pipe"
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Extracts commonly modified metadata and saver settings from an existing Image Saver Pipe. (Lazy extraction: does not trigger metadata hashing)."
def read_pipe(self, pipe: dict[str, Any]) -> tuple[Any, ...]:
metadata_config = pipe["metadata_config"]
simple_saver_config = pipe["simple_saver_config"]
# 3. Dynamic Fallbacks: We pull the maintainer's base defaults to ensure
# that 'Read' operations are safe and predictable even with old/missing data.
base_meta_defaults = ImageSaverMetadata.INPUT_TYPES()["optional"]
base_saver_req = ImageSaverSimple.INPUT_TYPES()["required"]
base_saver_opt = ImageSaverSimple.INPUT_TYPES()["optional"]
res = [pipe]
# 4. Iterative Unpacking: We loop over the names we dynamically generated above.
# This removes the need for hardcoded list indexing and prevents off-by-one errors.
for key in self.RETURN_NAMES[1:]: # Skip the first 'pipe' return
if key in ["filename", "path"]:
res.append(simple_saver_config.get(key, base_saver_req[key][1].get("default", "")))
elif key == "counter":
res.append(simple_saver_config.get(key, base_saver_opt[key][1].get("default", 0)))
else:
meta_key = "seed_value" if key == "seed" else key
default_val = base_meta_defaults[meta_key][1].get("default")
res.append(metadata_config.get(meta_key, default_val))
return tuple(res)
class ImageSaverFromPipe:
"""
Save images using settings and metadata unpacked from an Image Saver Pipe.
"""
@classmethod
def INPUT_TYPES(cls) -> dict[str, Any]:
return {
"required": {
"pipe": ("IMAGESAVER_PIPE",),
"images": ("IMAGE", {"tooltip": "image(s) to save"}),
},
"optional": {
"show_preview": ("BOOLEAN", {"default": True, "tooltip": "if True, displays saved images in the UI preview"}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("IMAGESAVER_PIPE", "STRING", "STRING")
RETURN_NAMES = ("pipe", "hashes", "a1111_params")
OUTPUT_TOOLTIPS = ("The pass-through Image Saver Pipe", "Comma-separated list of the hashes to chain with other Image Saver additional_hashes","Written parameters to the image metadata")
FUNCTION = "save_files"
OUTPUT_NODE = True
CATEGORY = "ImageSaver/Pipe"
DESCRIPTION = "Save images using settings and metadata unpacked from an Image Saver Pipe."
def save_files(self,
images: list[torch.Tensor],
pipe: dict[str, Any],
show_preview: bool = True,
prompt: dict[str, Any] | None = None,
extra_pnginfo: dict[str, Any] | None = None,
) -> dict[str, Any]:
# 1. Resolve the Lazy Pipe (The "Ingredients")
# Extract the raw configurations carried by the pipe.
metadata_config = pipe["metadata_config"]
simple_saver_config = pipe["simple_saver_config"]
# 2. Call the author's EXISTING resolution engine (The "Cooking")
# This performs hashing and Civitai API lookups using the original logic.
metadata = ImageSaverMetadata.make_metadata(**metadata_config)
# 3. CALL THE EXISTING SAVER NODE AS A FUNCTION (The "Engine")
# By instantiating the base node and calling its save_images method, we
# ensure that 100% of the saving logic, path formatting, and UI preview
# code is reused without duplication.
# **simple_saver_config: Unpacks the dictionary (filename, path, extension, etc.)
# directly into the method's keyword arguments.
base_node = ImageSaverSimple()
res = base_node.save_images(
images=images,
metadata=metadata,
show_preview=show_preview,
prompt=prompt, # Required for workflow embedding
extra_pnginfo=extra_pnginfo, # Required for workflow embedding
**simple_saver_config # Supplies filename, path, extension, webp toggles, etc.
)
# 4. Final Wrap
# The base node returns (hashes, params), which we return directly along with the pipe pass-through.
final_hashes, a111_params = res["result"]
res["result"] = (pipe, final_hashes, a111_params)
return res