Files

263 lines
8.8 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Raykosan (RaykoStudio)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import os
import re
import json
import numpy as np
from PIL import Image, PngImagePlugin
import folder_paths
try:
from comfy.cli_args import args
DISABLE_METADATA = args.disable_metadata
except Exception:
DISABLE_METADATA = False
STRENGTH_WORDS = ("strength", "weight", "wt")
NAME_EXCLUDE_WORDS = ("strength", "weight", "wt", "toggle",
"mode", "device", "shift", "type", "data")
IGNORE_CLASS_TYPES = {"RSSaveLoRA"}
def _sanitize(s: str) -> str:
s = re.sub(r'[\\/:*?"<>|\r\n\t]+', "_", str(s))
return s.strip(" .")
def _clean_lora_name(name: str) -> str:
name = re.split(r'[\\/]', str(name))[-1]
return re.sub(r'\.(safetensors|ckpt|pt|bin)$', '', name, flags=re.I)
def _fmt_strength(v) -> str:
try:
return f"{float(v):.2f}"
except (TypeError, ValueError):
return "?"
def _is_number(v) -> bool:
return isinstance(v, (int, float)) and not isinstance(v, bool)
def _is_link(v) -> bool:
return isinstance(v, (list, tuple)) and len(v) == 2 and isinstance(v[1], int)
def _resolve_link(prompt: dict, link):
src = prompt.get(str(link[0]))
if not src or src.get("class_type") != "PrimitiveNode":
return None
val = src.get("inputs", {}).get("value")
slot = link[1]
if isinstance(val, list):
return val[slot] if slot < len(val) else (val[0] if val else None)
return val
def _read_input(prompt: dict, inputs: dict, key: str):
v = inputs.get(key)
if _is_link(v):
v = _resolve_link(prompt, v)
return v
def _parse_lora_data(val):
if isinstance(val, (str, bytes)):
try:
val = json.loads(val)
except (json.JSONDecodeError, ValueError):
return []
if isinstance(val, dict):
val = [val]
if not isinstance(val, list):
return []
out = []
for item in val:
if not isinstance(item, dict):
continue
if item.get("enabled", True) in (False, 0, "false", "False"):
continue
name = (item.get("name") or item.get("lora_name")
or item.get("model_name") or item.get("path"))
if not isinstance(name, str) or not name.strip():
continue
strength = None
for sk in ("strength_model", "strength", "weight", "model_strength"):
v = item.get(sk)
if _is_number(v):
strength = v
break
if isinstance(v, str):
try:
strength = float(v)
break
except ValueError:
pass
out.append((name.strip(), strength))
return out
def _resolve_lora_data(prompt: dict, val, depth=0):
if depth > 5 or not _is_link(val):
return val
src = prompt.get(str(val[0]))
if isinstance(src, dict):
src_inputs = src.get("inputs", {})
if "lora_data" in src_inputs:
return _resolve_lora_data(prompt, src_inputs["lora_data"], depth + 1)
return val
def _parse_lora_widget(val):
if not isinstance(val, dict):
return None
if "lora" not in val and "name" not in val:
return None
if val.get("on", True) in (False, 0):
return None
name = val.get("lora") or val.get("name")
if not isinstance(name, str) or not name.strip():
return None
strength = val.get("strength", val.get("strength_model"))
if not _is_number(strength):
strength = None
return (name.strip(), strength)
def _find_strength(prompt: dict, inputs: dict, name_key: str):
m = re.search(r'(\d+)\s*$', name_key)
suffix = m.group(1) if m else None
candidates = []
for k in inputs:
kl = k.lower()
if not any(w in kl for w in STRENGTH_WORDS):
continue
if suffix is not None and not kl.endswith(f"_{suffix}"):
continue
candidates.append(k)
candidates.sort(key=lambda k: 0 if "model" in k.lower() else 1)
for k in candidates:
v = _read_input(prompt, inputs, k)
if _is_number(v):
return v
if isinstance(v, str):
try:
return float(v)
except ValueError:
pass
return None
def find_loras(prompt: dict):
loras, seen = [], set()
def add(name, strength):
name = _clean_lora_name(name)
key = (name, str(strength))
if key not in seen:
seen.add(key)
loras.append((name, strength))
for node in prompt.values():
if not isinstance(node, dict):
continue
cls = str(node.get("class_type", ""))
inputs = node.get("inputs", {})
if not isinstance(inputs, dict):
continue
if cls in IGNORE_CLASS_TYPES:
continue
if "lora_data" in inputs:
parsed = _parse_lora_data(_resolve_lora_data(prompt, inputs["lora_data"]))
for name, strength in parsed:
add(name, strength)
continue
cls_has_lora = "lora" in cls.lower()
if not cls_has_lora and not any("lora" in k.lower() for k in inputs):
continue
for key in inputs:
kl = key.lower()
if "lora" not in kl or any(w in kl for w in NAME_EXCLUDE_WORDS):
continue
raw = inputs[key]
if isinstance(raw, dict) and ("lora" in raw or "name" in raw):
widget = _parse_lora_widget(raw)
if widget:
add(widget[0], widget[1])
continue
val = _read_input(prompt, inputs, key)
if not isinstance(val, str) or not val.strip():
continue
add(val.strip(), _find_strength(prompt, inputs, key))
return loras
class RSSaveLoRA:
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
CATEGORY = "🦊 RaykoStudio"
FUNCTION = "save_images"
DESCRIPTION = "An image saving node with automatic substitution of the name and power of LoRA in the file name"
def save_images(self, images, filename_prefix="ComfyUI",
prompt=None, extra_pnginfo=None):
filename_prefix += self.prefix_append
loras = find_loras(prompt) if prompt else []
lora_info = "; ".join(
f"{name} ({_fmt_strength(st)})" for name, st in loras
)
if lora_info:
filename_prefix = f"{filename_prefix}_{_sanitize(lora_info)}"
full_output_folder, filename, counter, subfolder, filename_prefix = \
folder_paths.get_save_image_path(
filename_prefix, self.output_dir,
images[0].shape[1], images[0].shape[0]
)
results = []
for batch_number, image in enumerate(images):
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
metadata = None
if not DISABLE_METADATA:
metadata = PngImagePlugin.PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
fn = filename.replace("%batch_num%", str(batch_number))
file = f"{fn}_{counter:05}_.png"
img.save(os.path.join(full_output_folder, file),
pnginfo=metadata, compress_level=self.compress_level)
results.append({"filename": file, "subfolder": subfolder,
"type": self.type})
counter += 1
return {"ui": {"images": results}}
NODE_CLASS_MAPPINGS = {
"RSSaveLoRA": RSSaveLoRA
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RSSaveLoRA": "🦊 RS Save Image LoRA"
}