mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-31 11:27:24 +08:00
Pass-through SaveImage variant with accumulating previews and a promote/lock feature. The node: - Saves images and passes the input tensor through as the output, so it fits naturally mid-graph (unlike core SaveImage which is a sink). - Exposes an 'accumulate' flag, mirroring upstream PR #12647 — the frontend uses this to append previews to a per-node gallery instead of replacing it. - Accepts an optional 'promoted_asset_ref' STRING widget that the frontend writes when the user clicks a 'lock' UI on a preview. When set, the node skips saving, loads the referenced image from output/input/temp, and outputs that image. Stale refs silently fall back to pass-through. - IS_CHANGED returns a ref-derived key (incl. file mtime) when locked, so re-queues with the same lock are cache hits and upstream ancestors are skipped. Unlocked, it defers to normal input-signature caching. Includes unit tests covering ref parsing (incl. path-traversal and symlink-escape rejection), path resolution, pass-through and locked execution, and IS_CHANGED behavior. 24/24 pass; ruff clean.
250 lines
8.8 KiB
Python
250 lines
8.8 KiB
Python
"""
|
|
SaveImagePromotable: a pass-through SaveImage variant with accumulating previews
|
|
and a "promote/lock" feature.
|
|
|
|
Modes:
|
|
- Pass-through (default): saves incoming images, emits preview UI, returns the
|
|
input tensor as output. With `accumulate=True`, the frontend appends previews
|
|
to a gallery instead of replacing it.
|
|
- Locked: when `promoted_asset_ref` is a non-empty JSON ref to a saved asset,
|
|
the node skips saving, loads the referenced image, and outputs that image.
|
|
The frontend is expected to write the ref into the widget when the user
|
|
clicks the "lock" UI on a preview.
|
|
|
|
Caching: IS_CHANGED returns a stable key derived from the ref (+ file mtime)
|
|
when locked, so re-queues with the same lock are cache hits and upstream
|
|
ancestors are skipped. Unlocked, IS_CHANGED returns False to defer to normal
|
|
input-signature caching.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
import numpy as np
|
|
import torch
|
|
from PIL import Image, ImageOps, ImageSequence
|
|
from PIL.PngImagePlugin import PngInfo
|
|
|
|
import folder_paths
|
|
import node_helpers
|
|
from comfy.cli_args import args
|
|
|
|
|
|
def _parse_promoted_ref(promoted_asset_ref: str) -> dict | None:
|
|
if not promoted_asset_ref:
|
|
return None
|
|
try:
|
|
ref = json.loads(promoted_asset_ref)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return None
|
|
if not isinstance(ref, dict):
|
|
return None
|
|
filename = ref.get("filename")
|
|
if not isinstance(filename, str) or not filename:
|
|
return None
|
|
subfolder = ref.get("subfolder", "") or ""
|
|
asset_type = ref.get("type", "output") or "output"
|
|
if not isinstance(subfolder, str) or not isinstance(asset_type, str):
|
|
return None
|
|
# Reject anything that could escape the base directory.
|
|
if os.path.isabs(subfolder) or ".." in subfolder.split(os.sep):
|
|
return None
|
|
if os.path.isabs(filename) or ".." in filename.split(os.sep):
|
|
return None
|
|
return {"filename": filename, "subfolder": subfolder, "type": asset_type}
|
|
|
|
|
|
def _resolve_ref_path(ref: dict) -> str | None:
|
|
asset_type = ref["type"]
|
|
if asset_type == "output":
|
|
base = folder_paths.get_output_directory()
|
|
elif asset_type == "input":
|
|
base = folder_paths.get_input_directory()
|
|
elif asset_type == "temp":
|
|
base = folder_paths.get_temp_directory()
|
|
else:
|
|
return None
|
|
path = os.path.join(base, ref["subfolder"], ref["filename"])
|
|
# Defense-in-depth: ensure the resolved path stays inside the base dir.
|
|
base_real = os.path.realpath(base)
|
|
path_real = os.path.realpath(path)
|
|
if not path_real.startswith(base_real + os.sep) and path_real != base_real:
|
|
return None
|
|
if not os.path.isfile(path_real):
|
|
return None
|
|
return path_real
|
|
|
|
|
|
def _load_image_tensor(path: str) -> torch.Tensor:
|
|
img = node_helpers.pillow(Image.open, path)
|
|
output_images: list[torch.Tensor] = []
|
|
w: int | None = None
|
|
h: int | None = None
|
|
for frame in ImageSequence.Iterator(img):
|
|
frame = node_helpers.pillow(ImageOps.exif_transpose, frame)
|
|
image = frame.convert("RGB")
|
|
if not output_images:
|
|
w, h = image.size
|
|
if image.size != (w, h):
|
|
continue
|
|
arr = np.array(image).astype(np.float32) / 255.0
|
|
output_images.append(torch.from_numpy(arr)[None,])
|
|
if not output_images:
|
|
raise RuntimeError(f"Failed to decode any frames from {path}")
|
|
return torch.cat(output_images, dim=0)
|
|
|
|
|
|
class SaveImagePromotable:
|
|
"""Pass-through SaveImage with accumulating previews and promote/lock.
|
|
|
|
Inputs:
|
|
images: IMAGE tensor to save + pass through (ignored when locked).
|
|
filename_prefix: STRING prefix for saved files.
|
|
accumulate: BOOLEAN — when True, frontend appends previews to gallery.
|
|
promoted_asset_ref: STRING — JSON ref written by the frontend on lock.
|
|
Empty string means "not locked, normal pass-through".
|
|
Output:
|
|
IMAGE — input pass-through, or the loaded promoted image when locked.
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.output_dir = folder_paths.get_output_directory()
|
|
self.type = "output"
|
|
self.compress_level = 4
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": (
|
|
"IMAGE",
|
|
{
|
|
"tooltip": "Images to save and pass through. Ignored when a promoted asset is locked."
|
|
},
|
|
),
|
|
"filename_prefix": (
|
|
"STRING",
|
|
{"default": "ComfyUI", "tooltip": "Prefix for saved files."},
|
|
),
|
|
"accumulate": (
|
|
"BOOLEAN",
|
|
{
|
|
"default": False,
|
|
"tooltip": "When enabled, previews append to a per-node gallery instead of replacing it.",
|
|
},
|
|
),
|
|
},
|
|
"optional": {
|
|
"promoted_asset_ref": (
|
|
"STRING",
|
|
{
|
|
"default": "",
|
|
"multiline": False,
|
|
"tooltip": "JSON ref to a saved asset. Set by the UI; do not edit manually.",
|
|
},
|
|
),
|
|
},
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("images",)
|
|
FUNCTION = "execute"
|
|
OUTPUT_NODE = True
|
|
CATEGORY = "image"
|
|
DESCRIPTION = "Saves images, shows accumulating previews, and passes the input through. A promoted (locked) preview overrides pass-through to output the chosen image."
|
|
|
|
def _save_images(self, images, filename_prefix, prompt, extra_pnginfo):
|
|
full_output_folder, filename, counter, subfolder, _ = (
|
|
folder_paths.get_save_image_path(
|
|
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
|
)
|
|
)
|
|
results: list[dict] = []
|
|
for batch_number, image in enumerate(images):
|
|
arr = 255.0 * image.cpu().numpy()
|
|
img = Image.fromarray(np.clip(arr, 0, 255).astype(np.uint8))
|
|
metadata: PngInfo | None = None
|
|
if not args.disable_metadata:
|
|
metadata = PngInfo()
|
|
if prompt is not None:
|
|
metadata.add_text("prompt", json.dumps(prompt))
|
|
if extra_pnginfo is not None:
|
|
for key in extra_pnginfo:
|
|
metadata.add_text(key, json.dumps(extra_pnginfo[key]))
|
|
filename_with_batch = filename.replace("%batch_num%", str(batch_number))
|
|
out_name = f"{filename_with_batch}_{counter:05}_.png"
|
|
img.save(
|
|
os.path.join(full_output_folder, out_name),
|
|
pnginfo=metadata,
|
|
compress_level=self.compress_level,
|
|
)
|
|
results.append(
|
|
{"filename": out_name, "subfolder": subfolder, "type": self.type}
|
|
)
|
|
counter += 1
|
|
return results
|
|
|
|
def execute(
|
|
self,
|
|
images,
|
|
filename_prefix="ComfyUI",
|
|
accumulate=False, # noqa: ARG002
|
|
promoted_asset_ref="",
|
|
prompt=None,
|
|
extra_pnginfo=None,
|
|
):
|
|
ref = _parse_promoted_ref(promoted_asset_ref)
|
|
if ref is not None:
|
|
path = _resolve_ref_path(ref)
|
|
if path is not None:
|
|
tensor = _load_image_tensor(path)
|
|
tensor = tensor.to(device=images.device, dtype=images.dtype)
|
|
return {
|
|
"ui": {"images": [ref]},
|
|
"result": (tensor,),
|
|
}
|
|
# Ref is set but stale (file deleted / failed validation): fall
|
|
# through to pass-through so the user gets a working graph rather
|
|
# than an execution error.
|
|
|
|
saved = self._save_images(images, filename_prefix, prompt, extra_pnginfo)
|
|
return {"ui": {"images": saved}, "result": (images,)}
|
|
|
|
@classmethod
|
|
def IS_CHANGED(
|
|
cls,
|
|
images, # noqa: ARG003
|
|
filename_prefix="ComfyUI",
|
|
accumulate=False, # noqa: ARG003
|
|
promoted_asset_ref="",
|
|
prompt=None, # noqa: ARG003
|
|
extra_pnginfo=None, # noqa: ARG003
|
|
):
|
|
ref = _parse_promoted_ref(promoted_asset_ref)
|
|
if ref is None:
|
|
return False
|
|
path = _resolve_ref_path(ref)
|
|
if path is None:
|
|
return f"PROMOTED::MISSING::{promoted_asset_ref}"
|
|
try:
|
|
stat = os.stat(path)
|
|
sig = f"{stat.st_size}:{stat.st_mtime_ns}"
|
|
except OSError:
|
|
sig = "NOSTAT"
|
|
return f"PROMOTED::{promoted_asset_ref}::{sig}"
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"SaveImagePromotable": SaveImagePromotable,
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"SaveImagePromotable": "Save Image (Promotable, PoC)",
|
|
}
|