ComfyUI/comfy_extras/nodes_save_image_promotable.py
Glary-Bot da90bc93e4 Add SaveImagePromotable PoC node
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.
2026-05-16 03:04:37 +00:00

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)",
}