307 lines
15 KiB
Python
307 lines
15 KiB
Python
import os
|
|
from datetime import datetime
|
|
import json
|
|
import piexif
|
|
import piexif.helper
|
|
from PIL import Image
|
|
from PIL.PngImagePlugin import PngInfo
|
|
import numpy as np
|
|
import folder_paths
|
|
from .utils import get_sha256
|
|
from .prompt_metadata_extractor import PromptMetadataExtractor
|
|
from nodes import MAX_RESOLUTION
|
|
|
|
def parse_checkpoint_name(ckpt_name):
|
|
return os.path.basename(ckpt_name)
|
|
|
|
def parse_checkpoint_name_without_extension(ckpt_name):
|
|
return os.path.splitext(parse_checkpoint_name(ckpt_name))[0]
|
|
|
|
def get_timestamp(time_format):
|
|
now = datetime.now()
|
|
try:
|
|
timestamp = now.strftime(time_format)
|
|
except:
|
|
timestamp = now.strftime("%Y-%m-%d-%H%M%S")
|
|
|
|
return timestamp
|
|
|
|
def save_json(image_info, filename):
|
|
try:
|
|
workflow = (image_info or {}).get('workflow')
|
|
if workflow is None:
|
|
print('No image info found, skipping saving of JSON')
|
|
with open(f'{filename}.json', 'w') as workflow_file:
|
|
json.dump(workflow, workflow_file)
|
|
print(f'Saved workflow to {filename}.json')
|
|
except Exception as e:
|
|
print(f'Failed to save workflow as json due to: {e}, proceeding with the remainder of saving execution')
|
|
|
|
def make_pathname(filename, seed, modelname, counter, time_format, sampler_name, steps, cfg, scheduler, denoise, clip_skip):
|
|
filename = filename.replace("%date", get_timestamp("%Y-%m-%d"))
|
|
filename = filename.replace("%time", get_timestamp(time_format))
|
|
filename = filename.replace("%model", parse_checkpoint_name(modelname))
|
|
filename = filename.replace("%seed", str(seed))
|
|
filename = filename.replace("%counter", str(counter))
|
|
filename = filename.replace("%sampler_name", sampler_name)
|
|
filename = filename.replace("%steps", str(steps))
|
|
filename = filename.replace("%cfg", str(cfg))
|
|
filename = filename.replace("%scheduler", scheduler)
|
|
filename = filename.replace("%basemodelname", parse_checkpoint_name_without_extension(modelname))
|
|
filename = filename.replace("%denoise", str(denoise))
|
|
filename = filename.replace("%clip_skip", str(clip_skip))
|
|
return filename
|
|
|
|
def make_filename(filename, seed, modelname, counter, time_format, sampler_name, steps, cfg, scheduler, denoise, clip_skip):
|
|
filename = make_pathname(filename, seed, modelname, counter, time_format, sampler_name, steps, cfg, scheduler, denoise, clip_skip)
|
|
return get_timestamp(time_format) if filename == "" else filename
|
|
|
|
class ImageSaver:
|
|
def __init__(self):
|
|
self.output_dir = folder_paths.output_directory
|
|
self.civitai_sampler_map = {
|
|
'euler_ancestral': 'Euler a',
|
|
'euler': 'Euler',
|
|
'lms': 'LMS',
|
|
'heun': 'Heun',
|
|
'dpm_2': 'DPM2',
|
|
'dpm_2_ancestral': 'DPM2 a',
|
|
'dpmpp_2s_ancestral': 'DPM++ 2S a',
|
|
'dpmpp_2m': 'DPM++ 2M',
|
|
'dpmpp_sde': 'DPM++ SDE',
|
|
'dpmpp_2m_sde': 'DPM++ 2M SDE',
|
|
'dpmpp_3m_sde': 'DPM++ 3M SDE',
|
|
'dpm_fast': 'DPM fast',
|
|
'dpm_adaptive': 'DPM adaptive',
|
|
'ddim': 'DDIM',
|
|
'plms': 'PLMS',
|
|
'uni_pc_bh2': 'UniPC',
|
|
'uni_pc': 'UniPC',
|
|
'lcm': 'LCM',
|
|
}
|
|
|
|
def get_civitai_sampler_name(self, sampler_name, scheduler):
|
|
# based on: https://github.com/civitai/civitai/blob/main/src/server/common/constants.ts#L122
|
|
if sampler_name in self.civitai_sampler_map:
|
|
civitai_name = self.civitai_sampler_map[sampler_name]
|
|
|
|
if scheduler == "karras":
|
|
civitai_name += " Karras"
|
|
elif scheduler == "exponential":
|
|
civitai_name += " Exponential"
|
|
|
|
return civitai_name
|
|
else:
|
|
if scheduler != 'normal':
|
|
return f"{sampler_name}_{scheduler}"
|
|
else:
|
|
return sampler_name
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE", { "tooltip": "image(s) to save"}),
|
|
"filename": ("STRING", {"default": '%time_%basemodelname_%seed', "multiline": False, "tooltip": "filename (available variables: %date, %time, %model, %seed, %counter, %sampler_name, %steps, %cfg, %scheduler, %basemodelname, %denoise, %clip_skip)"}),
|
|
"path": ("STRING", {"default": '', "multiline": False, "tooltip": "path to save the images (under Comfy's save directory)"}),
|
|
"extension": (['png', 'jpeg', 'webp'], { "tooltip": "file extension/type to save image as"}),
|
|
},
|
|
"optional": {
|
|
"steps": ("INT", {"default": 20, "min": 1, "max": 10000, "tooltip": "number of steps"}),
|
|
"cfg": ("FLOAT", {"default": 7.0, "min": 0.0, "max": 100.0, "tooltip": "CFG value"}),
|
|
"modelname": ("STRING", {"default": '', "multiline": False, "tooltip": "model name"}),
|
|
"sampler_name": ("STRING", {"default": '', "multiline": False, "tooltip": "sampler name (as string)"}),
|
|
"scheduler": ("STRING", {"default": 'normal', "multiline": False, "tooltip": "scheduler name (as string)"}),
|
|
"positive": ("STRING", {"default": 'unknown', "multiline": True, "tooltip": "positive prompt"}),
|
|
"negative": ("STRING", {"default": 'unknown', "multiline": True, "tooltip": "negative prompt"}),
|
|
"seed_value": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "seed"}),
|
|
"width": ("INT", {"default": 512, "min": 0, "max": MAX_RESOLUTION, "step": 8, "tooltip": "image width"}),
|
|
"height": ("INT", {"default": 512, "min": 0, "max": MAX_RESOLUTION, "step": 8, "tooltip": "image height"}),
|
|
"lossless_webp": ("BOOLEAN", {"default": True, "tooltip": "if True, saved WEBP files will be lossless"}),
|
|
"quality_jpeg_or_webp": ("INT", {"default": 100, "min": 1, "max": 100, "tooltip": "quality setting of JPEG/WEBP"}),
|
|
"optimize_png": ("BOOLEAN", {"default": False, "tooltip": "if True, saved PNG files will be optimized (can reduce file size but is slower)"}),
|
|
"counter": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "tooltip": "counter"}),
|
|
"denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "tooltip": "denoise value"}),
|
|
"clip_skip": ("INT", {"default": 0, "min": -24, "max": 24, "tooltip": "skip last CLIP layers (positive or negative value, 0 for no skip)"}),
|
|
"time_format": ("STRING", {"default": "%Y-%m-%d-%H%M%S", "multiline": False, "tooltip": "timestamp format"}),
|
|
"save_workflow_as_json": ("BOOLEAN", {"default": False, "tooltip": "if True, also saves the workflow as a separate JSON file"}),
|
|
"embed_workflow_in_png": ("BOOLEAN", {"default": True, "tooltip": "if True, embeds the workflow in the saved PNG files"}),
|
|
"additional_hashes": ("STRING", {"default": "", "multiline": False, "tooltip": "hashes separated by commas, optionally with names. 'Name:HASH' (e.g., 'MyLoRA:FF735FF83F98')"}),
|
|
},
|
|
"hidden": {
|
|
"prompt": "PROMPT",
|
|
"extra_pnginfo": "EXTRA_PNGINFO",
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ()
|
|
FUNCTION = "save_files"
|
|
|
|
OUTPUT_NODE = True
|
|
|
|
CATEGORY = "ImageSaver"
|
|
DESCRIPTION = "Save images with civitai-compatible generation metadata"
|
|
|
|
def save_files(
|
|
self,
|
|
images,
|
|
seed_value,
|
|
steps,
|
|
cfg,
|
|
sampler_name,
|
|
scheduler,
|
|
positive,
|
|
negative,
|
|
modelname,
|
|
quality_jpeg_or_webp,
|
|
lossless_webp,
|
|
optimize_png,
|
|
width,
|
|
height,
|
|
counter,
|
|
filename,
|
|
path,
|
|
extension,
|
|
time_format,
|
|
denoise,
|
|
clip_skip,
|
|
additional_hashes="",
|
|
save_workflow_as_json=False,
|
|
embed_workflow_in_png=True,
|
|
prompt=None,
|
|
extra_pnginfo=None,
|
|
):
|
|
filename = make_filename(filename, seed_value, modelname, counter, time_format, sampler_name, steps, cfg, scheduler, denoise, clip_skip)
|
|
path = make_pathname(path, seed_value, modelname, counter, time_format, sampler_name, steps, cfg, scheduler, denoise, clip_skip)
|
|
ckpt_path = folder_paths.get_full_path("checkpoints", modelname)
|
|
|
|
if not ckpt_path:
|
|
ckpt_path = folder_paths.get_full_path("diffusion_models", modelname)
|
|
|
|
if ckpt_path:
|
|
modelhash = get_sha256(ckpt_path)[:10]
|
|
else:
|
|
modelhash = ""
|
|
|
|
metadata_extractor = PromptMetadataExtractor([positive, negative])
|
|
embeddings = metadata_extractor.get_embeddings()
|
|
loras = metadata_extractor.get_loras()
|
|
civitai_sampler_name = self.get_civitai_sampler_name(sampler_name.replace('_gpu', ''), scheduler)
|
|
|
|
# Process additional_hashes input (a string) by normalizing, removing extra spaces/newlines, and splitting by comma
|
|
manual_list = additional_hashes.replace("\n", ",").split(",")
|
|
manual_entries = {}
|
|
|
|
for entry in manual_list:
|
|
entry = entry.strip()
|
|
if not entry:
|
|
continue
|
|
|
|
# Check if a name is provided using "Name:HASH" format
|
|
if ":" in entry:
|
|
name, hash_value = entry.split(":", 1) # Only split at the first `:`
|
|
name, hash_value = name.strip(), hash_value.strip()
|
|
else:
|
|
name, hash_value = None, entry.strip()
|
|
|
|
if hash_value in manual_entries:
|
|
print(f"[ImageSaver] Skipping duplicate hash: {hash_value}")
|
|
continue # Skip duplicates
|
|
|
|
manual_entries[hash_value] = name # Store name-hash mapping
|
|
|
|
# Remove hashes already present in the computed LoRAs
|
|
existing_lora_hashes = set(loras.values()) # Get set of LoRA hashes
|
|
filtered_entries = {}
|
|
for hash_value, name in manual_entries.items():
|
|
if hash_value in existing_lora_hashes:
|
|
print(f"[ImageSaver] Skipping manual hash already present in LoRAs: {hash_value}")
|
|
continue
|
|
filtered_entries[hash_value] = name
|
|
|
|
# Limit to 30 manual hashes
|
|
limited_entries = dict(list(filtered_entries.items())[:30])
|
|
|
|
# Store named hashes using the given name, otherwise use "manualX"
|
|
additional_hashes_dict = {}
|
|
for i, (hash_value, name) in enumerate(limited_entries.items(), start=1):
|
|
key_name = name if name else f"manual{i}" # Use the given name, otherwise default to manualX
|
|
additional_hashes_dict[key_name] = hash_value
|
|
|
|
# Convert all hashes to JSON format
|
|
hashes = json.dumps(embeddings | loras | additional_hashes_dict | {"model": modelhash})
|
|
basemodelname = parse_checkpoint_name_without_extension(modelname)
|
|
|
|
positive_a111_params = positive.strip()
|
|
negative_a111_params = f"\nNegative prompt: {negative.strip()}"
|
|
a111_params = f"{positive_a111_params}{negative_a111_params}\nSteps: {steps}, Sampler: {civitai_sampler_name}, CFG scale: {cfg}, Seed: {seed_value}, Size: {width}x{height}{f', Clip skip: {abs(clip_skip)}' if clip_skip != 0 else ''}, Model hash: {modelhash}, Model: {basemodelname}, Hashes: {hashes}, Version: ComfyUI"
|
|
|
|
output_path = os.path.join(self.output_dir, path)
|
|
|
|
if output_path.strip() != '':
|
|
if not os.path.exists(output_path.strip()):
|
|
print(f'The path `{output_path.strip()}` specified doesn\'t exist! Creating directory.')
|
|
os.makedirs(output_path, exist_ok=True)
|
|
|
|
filenames = self.save_images(images, output_path, filename, a111_params, extension, quality_jpeg_or_webp, lossless_webp, optimize_png, prompt, extra_pnginfo, save_workflow_as_json, embed_workflow_in_png)
|
|
|
|
subfolder = os.path.normpath(path)
|
|
return {"ui": {"images": map(lambda filename: {"filename": filename, "subfolder": subfolder if subfolder != '.' else '', "type": 'output'}, filenames)}}
|
|
|
|
def save_images(self, images, output_path, filename_prefix, a111_params, extension, quality_jpeg_or_webp, lossless_webp, optimize_png, prompt, extra_pnginfo, save_workflow_as_json, embed_workflow_in_png) -> list[str]:
|
|
paths = list()
|
|
for image in images:
|
|
i = 255. * image.cpu().numpy()
|
|
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
|
|
|
current_filename_prefix = self.get_unique_filename(output_path, filename_prefix, extension)
|
|
filename = f"{current_filename_prefix}.{extension}"
|
|
filepath = os.path.join(output_path, filename)
|
|
|
|
if extension == 'png':
|
|
metadata = PngInfo()
|
|
metadata.add_text("parameters", a111_params)
|
|
|
|
if embed_workflow_in_png:
|
|
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]))
|
|
|
|
img.save(filepath, pnginfo=metadata, optimize=optimize_png)
|
|
else: # webp & jpeg
|
|
img.save(filepath, optimize=True, quality=quality_jpeg_or_webp, lossless=lossless_webp)
|
|
exif_bytes = piexif.dump({
|
|
"Exif": {
|
|
piexif.ExifIFD.UserComment: piexif.helper.UserComment.dump(a111_params, encoding="unicode")
|
|
},
|
|
})
|
|
piexif.insert(exif_bytes, filepath)
|
|
|
|
if save_workflow_as_json:
|
|
save_json(extra_pnginfo, os.path.join(output_path, current_filename_prefix))
|
|
|
|
paths.append(filename)
|
|
return paths
|
|
|
|
def get_unique_filename(self, output_path, filename_prefix, extension):
|
|
existing_files = [f for f in os.listdir(output_path) if f.startswith(filename_prefix) and f.endswith(extension)]
|
|
|
|
if not existing_files:
|
|
return f"{filename_prefix}"
|
|
|
|
suffixes = []
|
|
for f in existing_files:
|
|
name, _ = os.path.splitext(f)
|
|
parts = name.split('_')
|
|
if parts[-1].isdigit():
|
|
suffixes.append(int(parts[-1]))
|
|
|
|
if suffixes:
|
|
next_suffix = max(suffixes) + 1
|
|
else:
|
|
next_suffix = 1
|
|
|
|
return f"{filename_prefix}_{next_suffix:02d}"
|