Made Load Images From Path preview the image and a bug fix where it removed .ext multiple times. Added image previews to Load Text Image Pair-nodes, Metadata Extractor-nodes.

This commit is contained in:
MNeMoNiCuZ
2026-09-28 17:38:11 +02:00
parent e59eeee14e
commit b958cc46f0
15 changed files with 454 additions and 101 deletions
+7 -3
View File
@@ -33,7 +33,7 @@ from .nodes.prompt_property_extractor import PromptPropertyExtractor
from .nodes.colorful_starting_image import ColorfulStartingImage
from .nodes.load_random_checkpoint import LoadRandomCheckpoint
from .nodes.load_images import LoadImagesFromPath
from .nodes.load_images import LoadImagesFromPath, register_load_images_routes
from .nodes.random_int_in_range import RandomIntInRange
from .nodes.random_float_in_range import RandomFloatInRange
from .nodes.random_bool import RandomBool
@@ -132,6 +132,9 @@ def _build_replacement(old_node_id: str, node_cls: type[io.ComfyNode]) -> io.Nod
values in a saved workflow JSON are matched back to input ids.
"""
schema = node_cls.GET_SCHEMA()
old_input_ids = {"input_path": "folder_path"} if schema.node_id in (
"MNeMiC_LoadTextImagePairSingle", "MNeMiC_LoadTextImagePairsList"
) else {}
input_ids = []
widget_ids = []
for node_input in schema.inputs:
@@ -142,7 +145,7 @@ def _build_replacement(old_node_id: str, node_cls: type[io.ComfyNode]) -> io.Nod
is_widget = isinstance(node_input, io.WidgetInput) and not getattr(node_input, "force_input", False)
if not is_widget:
continue # sockets are not in widgets_values
widget_ids.append(input_id)
widget_ids.append(old_input_ids.get(input_id, input_id))
if _has_control_after_generate(node_input, input_id):
# The frontend attaches a linked "control after generate" widget to
# these, and it takes its own slot in the saved widgets_values
@@ -156,7 +159,7 @@ def _build_replacement(old_node_id: str, node_cls: type[io.ComfyNode]) -> io.Nod
new_node_id=schema.node_id,
old_node_id=old_node_id,
old_widget_ids=widget_ids,
input_mapping=[{"new_id": i, "old_id": i} for i in input_ids],
input_mapping=[{"new_id": i, "old_id": old_input_ids.get(i, i)} for i in input_ids],
output_mapping=[{"new_idx": i, "old_idx": i} for i in range(len(schema.outputs))],
)
@@ -166,6 +169,7 @@ class MnemicExtension(ComfyExtension):
install_runtime_hooks("ImageSaveWithMetadata")
ensure_env_file()
register_llm_routes()
register_load_images_routes()
by_new_id = {}
for node_cls in await self.get_node_list():
+138 -23
View File
@@ -1,5 +1,8 @@
import os
import json
import asyncio
import base64
from io import BytesIO
import torch
import numpy as np
from PIL import Image
@@ -9,6 +12,18 @@ from comfy_api.latest import io
MAX_PATH_OUTPUTS = 32
PATH_FORMAT_LABELS = {
"path only": "path",
"path only with trailing separator": "path/",
"filename without extension": "file",
"filename only": "file.ext",
"full file path without extension": "path/file",
"full file path": "path/file.ext",
"relative path only": "relative_path",
"relative path only with trailing separator": "relative_path/",
"relative file path without extension": "relative_path/file",
"relative file path": "relative_path/file.ext",
}
LEGACY_PATH_FORMAT_LABELS = {
"full file path": "full path with ext",
"full file path without extension": "full path without ext",
"path only": f"folder path without trailing {os.sep}",
@@ -23,22 +38,131 @@ PATH_FORMAT_LABELS = {
PATH_FORMATS = list(PATH_FORMAT_LABELS.values())
PATH_FORMAT_ALIASES = dict(PATH_FORMAT_LABELS)
# Normalize saved labels when a workflow moves between Windows and Unix.
for label in PATH_FORMATS:
for key, label in LEGACY_PATH_FORMAT_LABELS.items():
PATH_FORMAT_ALIASES[label] = PATH_FORMAT_LABELS[key]
if "trailing " in label:
for separator in ("/", "\\"):
PATH_FORMAT_ALIASES[label[:-1] + separator] = label
PATH_FORMAT_ALIASES[label[:-1] + separator] = PATH_FORMAT_LABELS[key]
PATH_FORMAT_TOOLTIPS = dict(zip(PATH_FORMATS, [
"Absolute folder path and filename, including the extension.",
"Absolute folder path and filename, with the final extension removed.",
"Absolute containing folder, without a final slash. Drive roots keep their required slash.",
f"Absolute containing folder, ending in {os.sep}.",
"Filename including its extension, without the folder path.",
"Filename with the final extension removed, without the folder path.",
"Folder path and filename relative to ComfyUI's input folder, including the extension.",
"Folder path and filename relative to ComfyUI's input folder, with the final extension removed.",
"Filename including its extension, without the folder path.",
"Absolute folder path and filename, with the final extension removed.",
"Absolute folder path and filename, including the extension.",
"Containing folder relative to ComfyUI's input folder, without a final slash. The input folder itself is a dot.",
f"Containing folder relative to ComfyUI's input folder, ending in {os.sep}.",
"Folder path and filename relative to ComfyUI's input folder, with the final extension removed.",
"Folder path and filename relative to ComfyUI's input folder, including the extension.",
]))
PATH_FORMAT_EXAMPLES = [
"C:/",
"C:/",
"image",
"image.png",
"C:/image",
"C:/image.png",
".",
"./",
"image",
"image.png",
]
for label, example in zip(PATH_FORMATS, PATH_FORMAT_EXAMPLES):
PATH_FORMAT_TOOLTIPS[label] += (
"\nExample: " + example.replace("/", os.sep)
)
def _find_images(input_path, include_subfolders=False, supported_exts=None):
if supported_exts is None:
supported_exts = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif']
if not input_path:
return []
if not os.path.isabs(input_path):
from folder_paths import get_input_directory
input_dir = get_input_directory()
if not input_dir or not os.path.isdir(input_dir):
return []
input_path = os.path.join(input_dir, input_path)
if os.path.isdir(input_path):
if include_subfolders:
return sorted(
os.path.join(root, filename)
for root, _, filenames in os.walk(input_path)
for filename in filenames
if os.path.splitext(filename)[1].lower() in supported_exts
)
return [os.path.join(input_path, name) for name in sorted(os.listdir(input_path))
if os.path.splitext(name)[1].lower() in supported_exts]
if os.path.isfile(input_path) and os.path.splitext(input_path)[1].lower() in supported_exts:
return [input_path]
return []
def register_load_images_routes():
from aiohttp import web
from server import PromptServer
def thumbnail(input_path, seed, include_subfolders, kind, text_extension, force_reload):
text = ""
if kind in ("pair_single", "pair_list"):
from ..utils.file_utils import find_image_text_pairs, resolve_image_pair_path
input_path = resolve_image_pair_path(input_path)
if kind == "pair_list" and not force_reload:
from . import load_text_image_pairs_list as pairs_module
cached = pairs_module._CACHED_DATA
if cached and pairs_module._CACHED_FOLDER_PATH == input_path:
batch = cached[4]
if len(batch):
pixels = batch[seed % len(batch)].cpu().numpy()
image = Image.fromarray(np.clip(pixels * 255, 0, 255).astype(np.uint8))
image.thumbnail((512, 512))
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue(), cached[1][seed % len(batch)]
pairs = find_image_text_pairs(input_path, text_extension)
files = [pair[0] for pair in pairs]
if pairs:
text_path = pairs[seed % len(pairs)][1]
if text_path:
with open(text_path, "r", encoding="utf-8") as caption:
text = caption.read()
elif kind in ("metadata_single", "metadata_list"):
from .metadata_extractor_single import METADATA_IMAGE_EXTENSIONS
files = _find_images(input_path, supported_exts=METADATA_IMAGE_EXTENSIONS)
elif kind == "images":
files = _find_images(input_path, include_subfolders)
else:
raise ValueError("Unknown preview source.")
if not files:
return None, ""
with Image.open(files[seed % len(files)]) as source:
image = source.convert("RGB")
image.thumbnail((512, 512))
buffer = BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue(), text
@PromptServer.instance.routes.get("/mnemic/load-images/preview")
async def preview(request):
try:
body, text = await asyncio.to_thread(
thumbnail, request.query.get("input_path", ""),
int(request.query.get("seed", "0")),
request.query.get("include_subfolders") == "true",
request.query.get("kind", "images"),
request.query.get("text_format_extension", "txt"),
request.query.get("force_reload") == "true",
)
except (OSError, ValueError):
return web.Response(status=400)
if request.query.get("kind") in ("pair_single", "pair_list"):
return web.json_response({
"image": "data:image/png;base64," + base64.b64encode(body).decode("ascii") if body else None,
"text": text,
}, headers={"Cache-Control": "no-store"})
return web.Response(body=body, status=200 if body else 404,
content_type="image/png", headers={"Cache-Control": "no-store"})
def _format_path(image_path, path_format):
@@ -88,6 +212,11 @@ class LoadImagesFromPath(io.ComfyNode):
category="⚡ MNeMiC Nodes",
description="Loads a single image from a directory, allowing sequential iteration through the folder.",
inputs=[
io.Boolean.Input(
"include_subfolders",
default=False,
tooltip="Include images from all subfolders of the selected folder.",
),
io.Int.Input(
"seed",
default=0,
@@ -129,7 +258,7 @@ class LoadImagesFromPath(io.ComfyNode):
)
@classmethod
def execute(cls, seed: int, input_path: str, path_format: str = "full file path", extra_path_formats: str = "[]") -> io.NodeOutput:
def execute(cls, seed: int, input_path: str, path_format: str = "full file path", extra_path_formats: str = "[]", include_subfolders: bool = False) -> io.NodeOutput:
try:
extra_formats = json.loads(extra_path_formats)
except (TypeError, ValueError) as error:
@@ -142,21 +271,7 @@ class LoadImagesFromPath(io.ComfyNode):
if not input_path:
raise ValueError("Input path cannot be empty.")
if not os.path.isabs(input_path):
from folder_paths import get_input_directory
input_dir = get_input_directory()
if not input_dir or not os.path.isdir(input_dir):
return _node_output(None, None, [], 0, 0)
input_path = os.path.join(input_dir, input_path)
supported_exts = ['.png', '.jpg', '.jpeg', '.webp', '.bmp', '.gif']
if os.path.isdir(input_path):
files_found = [os.path.join(input_path, f) for f in sorted(os.listdir(input_path)) if os.path.splitext(f)[1].lower() in supported_exts]
elif os.path.isfile(input_path) and os.path.splitext(input_path)[1].lower() in supported_exts:
files_found = [input_path]
else:
files_found = []
files_found = _find_images(input_path, include_subfolders)
if not files_found:
return _node_output(None, None, [], 0, 0)
+17 -16
View File
@@ -3,7 +3,7 @@ import torch
import numpy as np
from PIL import Image
from comfy_api.latest import io
from comfy_api.latest import io, ui
from ..utils.file_utils import find_image_text_pairs
@@ -25,21 +25,21 @@ class LoadTextImagePairSingle(io.ComfyNode):
tooltip="Index of the pair to load, starting at 0. Increments every run by default to step through the folder.",
),
io.String.Input(
"folder_path",
"input_path",
multiline=False,
default="",
tooltip="Path to a folder containing image and text files with matching basenames. This is used only if image_input and text_input are not connected.",
tooltip="Folder containing images and optional matching text files. Relative paths start in ComfyUI's input folder.",
),
io.Image.Input(
"image_input",
optional=True,
tooltip="A single image or a list/batch of images. This input has priority over the folder_path.",
tooltip="A single image or a list/batch of images. This input has priority over input_path.",
),
io.String.Input(
"text_input",
optional=True,
force_input=True,
tooltip="A single text string or a list of strings. This input has priority over the folder_path.",
tooltip="A single text string or a list of strings. This input has priority over input_path.",
),
io.String.Input(
"text_format_extension",
@@ -58,20 +58,18 @@ class LoadTextImagePairSingle(io.ComfyNode):
)
@classmethod
def execute(cls, seed, folder_path=None, image_input=None, text_input=None, text_format_extension="txt") -> io.NodeOutput:
def execute(cls, seed, input_path=None, image_input=None, text_input=None, text_format_extension="txt", folder_path=None) -> io.NodeOutput:
if image_input is not None and text_input is not None:
# Handle direct inputs
image = image_input
text = text_input if isinstance(text_input, str) else str(text_input)
total_count = image.shape[0]
return io.NodeOutput(image, text, "", "", total_count)
return io.NodeOutput(image, text, "", "", total_count,
ui={**ui.PreviewImage(image[:1], cls=cls).as_dict(), "pair_text": [text]} if total_count else {"images": []})
if not folder_path or not os.path.isdir(folder_path):
return io.NodeOutput(None, "", "", "", 0)
pairs = find_image_text_pairs(folder_path, text_format_extension)
pairs = find_image_text_pairs(input_path if input_path is not None else folder_path, text_format_extension)
if not pairs:
return io.NodeOutput(None, "", "", "", 0)
return io.NodeOutput(None, "", "", "", 0, ui={"images": []})
total_count = len(pairs)
current_index = seed % total_count
@@ -82,10 +80,13 @@ class LoadTextImagePairSingle(io.ComfyNode):
i = Image.open(image_path).convert("RGB")
image = np.array(i).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
with open(text_path, 'r', encoding='utf-8') as f:
text = f.read()
text = ""
if text_path:
with open(text_path, 'r', encoding='utf-8') as f:
text = f.read()
except Exception as e:
print(f"Error loading pair {basename}: {e}")
return io.NodeOutput(None, "", "", "", total_count)
return io.NodeOutput(None, "", "", "", total_count, ui={"images": []})
return io.NodeOutput(image, text, image_path, basename, total_count)
return io.NodeOutput(image, text, image_path, basename, total_count,
ui={**ui.PreviewImage(image[:1], cls=cls).as_dict(), "pair_text": [text]})
+24 -18
View File
@@ -3,9 +3,9 @@ import torch
import numpy as np
from PIL import Image
import torchvision.transforms.functional as F
from comfy_api.latest import io
from comfy_api.latest import io, ui
from ..utils.file_utils import find_image_text_pairs
from ..utils.file_utils import find_image_text_pairs, resolve_image_pair_path
# Cache kept at module level: V3 nodes execute as classmethods on a per-run class
# clone and cannot hold instance state between executions.
@@ -30,10 +30,10 @@ class LoadTextImagePairsList(io.ComfyNode):
tooltip="Index of the first pair to load, starting at 0. Increments every run by default to step through the folder.",
),
io.String.Input(
"folder_path",
"input_path",
multiline=False,
default="",
tooltip="Path to a folder containing image and text files with matching basenames. This is used only if image_input and text_input are not connected.",
tooltip="Folder containing images and optional matching text files. Relative paths start in ComfyUI's input folder.",
),
io.Boolean.Input(
"force_reload",
@@ -43,13 +43,13 @@ class LoadTextImagePairsList(io.ComfyNode):
io.Image.Input(
"image_input",
optional=True,
tooltip="A single image or a list/batch of images. This input has priority over the folder_path.",
tooltip="A single image or a list/batch of images. This input has priority over input_path.",
),
io.String.Input(
"text_input",
optional=True,
force_input=True,
tooltip="A single text string or a list of strings. This input has priority over the folder_path.",
tooltip="A single text string or a list of strings. This input has priority over input_path.",
),
io.Int.Input(
"limit_count",
@@ -97,7 +97,7 @@ class LoadTextImagePairsList(io.ComfyNode):
return cropped_tensor.permute(1, 2, 0)
@classmethod
def execute(cls, seed, folder_path, force_reload=False, limit_count=0, text_format_extension="txt", image_input=None, text_input=None) -> io.NodeOutput:
def execute(cls, seed, input_path=None, force_reload=False, limit_count=0, text_format_extension="txt", image_input=None, text_input=None, folder_path=None) -> io.NodeOutput:
global _CACHED_DATA, _CACHED_FOLDER_PATH
if image_input is not None and text_input is not None:
@@ -152,35 +152,40 @@ class LoadTextImagePairsList(io.ComfyNode):
final_basenames = rotated_basenames
final_images = torch.cat((batched_images[current_index:], batched_images[:current_index]), dim=0)
return io.NodeOutput(final_images, final_texts, final_paths, final_basenames, total_count)
return io.NodeOutput(final_images, final_texts, final_paths, final_basenames, total_count,
ui={**ui.PreviewImage(final_images[:1], cls=cls).as_dict(), "pair_text": [final_texts[0]]} if len(final_images) else {"images": []})
if not force_reload and _CACHED_FOLDER_PATH == folder_path and _CACHED_DATA:
input_path = resolve_image_pair_path(input_path if input_path is not None else folder_path)
if not force_reload and _CACHED_FOLDER_PATH == input_path and _CACHED_DATA:
print("LoadTextImagePairsList: Using cached data.")
all_images, all_texts, all_paths, all_basenames, batched_images = _CACHED_DATA
else:
if not folder_path or not os.path.isdir(folder_path):
return io.NodeOutput(None, "", "", "", 0)
if not input_path or not os.path.isdir(input_path):
return io.NodeOutput(None, "", "", "", 0, ui={"images": []})
print("LoadTextImagePairsList: Loading new data from disk.")
pairs = find_image_text_pairs(folder_path, text_format_extension)
pairs = find_image_text_pairs(input_path, text_format_extension)
if not pairs:
return io.NodeOutput(None, "", "", "", 0)
return io.NodeOutput(None, "", "", "", 0, ui={"images": []})
all_images, all_texts, all_paths, all_basenames = [], [], [], []
for image_path, text_path, basename in pairs:
try:
i = Image.open(image_path).convert("RGB")
image = np.array(i).astype(np.float32) / 255.0
text = ""
if text_path:
with open(text_path, 'r', encoding='utf-8') as f:
text = f.read()
all_images.append(torch.from_numpy(image)[None,])
with open(text_path, 'r', encoding='utf-8') as f:
all_texts.append(f.read())
all_texts.append(text)
all_paths.append(image_path)
all_basenames.append(basename)
except Exception as e:
print(f"Error loading pair {basename}: {e}")
if not all_images:
return io.NodeOutput(None, "", "", "", 0)
return io.NodeOutput(None, "", "", "", 0, ui={"images": []})
first_img_h, first_img_w = all_images[0].shape[1], all_images[0].shape[2]
processed_images = []
@@ -194,7 +199,7 @@ class LoadTextImagePairsList(io.ComfyNode):
batched_images = torch.cat(processed_images, dim=0)
_CACHED_DATA = (all_images, all_texts, all_paths, all_basenames, batched_images)
_CACHED_FOLDER_PATH = folder_path
_CACHED_FOLDER_PATH = input_path
total_count = len(all_texts)
current_index = seed % total_count
@@ -217,4 +222,5 @@ class LoadTextImagePairsList(io.ComfyNode):
final_basenames = rotated_basenames
final_images = torch.cat((batched_images[current_index:], batched_images[:current_index]), dim=0)
return io.NodeOutput(final_images, final_texts, final_paths, final_basenames, total_count)
return io.NodeOutput(final_images, final_texts, final_paths, final_basenames, total_count,
ui={**ui.PreviewImage(final_images[:1], cls=cls).as_dict(), "pair_text": [final_texts[0]]})
+8 -6
View File
@@ -6,9 +6,10 @@ from PIL import Image
from typing import List
import itertools
from comfy_api.latest import io
from comfy_api.latest import io, ui
from ..utils.metadata_utils import extract_metadata_from_file, resize_and_crop_image
from .metadata_extractor_single import METADATA_IMAGE_EXTENSIONS
class MetadataExtractorList(io.ComfyNode):
@@ -89,10 +90,10 @@ class MetadataExtractorList(io.ComfyNode):
if not os.path.isabs(input_path):
from folder_paths import get_input_directory
input_dir = get_input_directory()
if not input_dir or not os.path.isdir(input_dir): return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [])
if not input_dir or not os.path.isdir(input_dir): return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [], ui={"images": []})
input_path = os.path.join(input_dir, input_path)
supported_exts = ['.png', '.jpg', '.jpeg', '.tiff', '.tif']
supported_exts = METADATA_IMAGE_EXTENSIONS
files_found = []
if os.path.isdir(input_path):
# Get the initial list of files, sorted for deterministic order.
@@ -100,7 +101,7 @@ class MetadataExtractorList(io.ComfyNode):
elif os.path.isfile(input_path) and os.path.splitext(input_path)[1].lower() in supported_exts:
files_found = [input_path]
if not files_found: return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [])
if not files_found: return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [], ui={"images": []})
total_files = len(files_found)
start_index = seed % total_files
@@ -122,7 +123,7 @@ class MetadataExtractorList(io.ComfyNode):
print(f"Skipping file {file_path}: {e}")
if not final_images:
return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [])
return io.NodeOutput(torch.zeros((0, 64, 64, 3)), [], [], [], [], [], ui={"images": []})
first_image_height, first_image_width = final_images[0].shape[0], final_images[0].shape[1]
@@ -143,4 +144,5 @@ class MetadataExtractorList(io.ComfyNode):
filtered_params_list_grouped = ["\n".join(get_filtered_values(m.get('parsed_params', {}))) for m in final_metadata]
raw_meta_list_json = [json.dumps(m.get('metadata', {}), indent=4, default=str) for m in final_metadata]
return io.NodeOutput(image_list, pos_prompt_list, neg_prompt_list, parsed_params_list_json, filtered_params_list_grouped, raw_meta_list_json)
return io.NodeOutput(image_list, pos_prompt_list, neg_prompt_list, parsed_params_list_json, filtered_params_list_grouped, raw_meta_list_json,
ui=ui.PreviewImage(image_list[:1], cls=cls))
+7 -5
View File
@@ -4,10 +4,11 @@ import torch
import numpy as np
from PIL import Image
from comfy_api.latest import io
from comfy_api.latest import io, ui
from ..utils.metadata_utils import extract_metadata_from_file
METADATA_IMAGE_EXTENSIONS = ('.png', '.jpg', '.jpeg', '.tiff', '.tif')
class MetadataExtractorSingle(io.ComfyNode):
@classmethod
@@ -72,11 +73,11 @@ class MetadataExtractorSingle(io.ComfyNode):
from folder_paths import get_input_directory
input_dir = get_input_directory()
if not input_dir or not os.path.isdir(input_dir):
return io.NodeOutput(*((None,) * 6))
return io.NodeOutput(*((None,) * 6), ui={"images": []})
input_path = os.path.join(input_dir, input_path)
file_to_process = None
supported_exts = ['.png', '.jpg', '.jpeg', '.tiff', '.tif']
supported_exts = METADATA_IMAGE_EXTENSIONS
if os.path.isfile(input_path) and os.path.splitext(input_path)[1].lower() in supported_exts:
file_to_process = input_path
@@ -99,7 +100,7 @@ class MetadataExtractorSingle(io.ComfyNode):
print(f"Error processing file {file_to_process}: {e}")
if image_tensor is None or metadata is None:
return io.NodeOutput(torch.zeros((1, 64, 64, 3)), "", "", "{}", "", "{}")
return io.NodeOutput(torch.zeros((1, 64, 64, 3)), "", "", "{}", "", "{}", ui={"images": []})
filter_keys = [k.strip().lower() for k in filter_params.split(',') if k.strip()]
@@ -113,4 +114,5 @@ class MetadataExtractorSingle(io.ComfyNode):
raw_meta_json = json.dumps(metadata.get('metadata', {}), indent=4, default=str)
parsed_params_json = json.dumps(parsed_params, indent=4, default=str)
return io.NodeOutput(image_tensor, pos_prompt, neg_prompt, parsed_params_json, filtered_params_list, raw_meta_json)
return io.NodeOutput(image_tensor, pos_prompt, neg_prompt, parsed_params_json, filtered_params_list, raw_meta_json,
ui=ui.PreviewImage(image_tensor[:1], cls=cls))
+2 -2
View File
@@ -1,7 +1,7 @@
[project]
name = "comfyui-mnemic-nodes"
description = "Modernized the: Load Images from Path, it now has dynamic multiple outputs for the resulting file. The String Cleaning is now showing one operation only, and you can add more operations dynamically. The Save Text File With Path now has settings for overwrite and collision handling. Added a simple Find/Replace node."
version = "3.1.0"
description = "Made Load Images From Path preview the image and a bug fix where it removed .ext multiple times. Added image previews to Load Text Image Pair-nodes, Metadata Extractor-nodes."
version = "3.1.1"
license = { file = "LICENSE" }
dependencies = ["configparser", "groq", "transformers", "torch", "tiktoken", "imageio", "tqdm", "piexif", "requests", "colorama", "opencv-python", "python-dotenv"]
+11 -4
View File
@@ -197,12 +197,20 @@ def find_best_match(search_term, file_list, log=False, wildcard_paths=None, fuzz
return matches[0][1] if matches else None
def resolve_image_pair_path(input_path):
if input_path and not os.path.isabs(input_path):
from folder_paths import get_input_directory
input_path = os.path.join(get_input_directory(), input_path)
return input_path
def find_image_text_pairs(folder_path, text_format_extension="txt"):
"""
Finds pairs of image and text files with matching basenames in a folder.
Finds images and optional text files with matching basenames in a folder.
Returns a sorted list of tuples, where each tuple contains (image_path, text_path, basename).
"""
if not os.path.isdir(folder_path):
folder_path = resolve_image_pair_path(folder_path)
if not folder_path or not os.path.isdir(folder_path):
return []
image_files = {}
@@ -219,7 +227,6 @@ def find_image_text_pairs(folder_path, text_format_extension="txt"):
pairs = []
for basename in sorted(image_files.keys()):
if basename in text_files:
pairs.append((image_files[basename], text_files[basename], basename))
pairs.append((image_files[basename], text_files.get(basename), basename))
return pairs
+21 -15
View File
@@ -2,28 +2,33 @@
Loads an image or steps through a folder, with a mask and separate path or filename outputs.
The image preview updates automatically when the path, seed, or subfolder toggle changes.
It shows the image for the current widget values without running the workflow.
The preview is hidden when those inputs are connected to other nodes.
## Inputs
- **include_subfolders**: Include images from all subfolders. Defaults to off.
- **seed**: Image index, starting at 0 and wrapping at the file count. Defaults to increment.
- **input_path**: Folder or image file. Relative paths start at ComfyUI's input folder; empty is an error.
- **output1**, **output2**, ...: Format of each text output. The first defaults to **full path with ext**; hover for the selected format's tooltip.
- **output1**, **output2**, ...: Format of each text output. The first defaults to **path/file.ext**; hover over options in the open dropdown for descriptions and examples.
- **Add file/path output**: Add a text output and choose its format, up to 32 outputs.
- **Remove last file/path output**: Remove the last additional output and its connections.
For `C:/ComfyUI/input/portraits/photo.v2.png`, with `C:/ComfyUI/input` as the input folder:
For `C:\image.png`, with `C:\` as the input folder:
| Format | Output |
| --- | --- |
| full path with ext | `C:/ComfyUI/input/portraits/photo.v2.png` |
| full path without ext | `C:/ComfyUI/input/portraits/photo.v2` |
| folder path without trailing / | `C:/ComfyUI/input/portraits` |
| folder path with trailing / | `C:/ComfyUI/input/portraits/` |
| filename with ext | `photo.v2.png` |
| filename without ext | `photo.v2` |
| relative path with ext | `portraits/photo.v2.png` |
| relative path without ext | `portraits/photo.v2` |
| relative folder without trailing / | `portraits` |
| relative folder with trailing / | `portraits/` |
| path | `C:\` |
| path/ | `C:\` |
| file | `image` |
| file.ext | `image.png` |
| path/file | `C:\image` |
| path/file.ext | `C:\image.png` |
| relative_path | `.` |
| relative_path/ | `.\` |
| relative_path/file | `image` |
| relative_path/file.ext | `image.png` |
## Outputs
@@ -35,13 +40,14 @@ For `C:/ComfyUI/input/portraits/photo.v2.png`, with `C:/ComfyUI/input` as the in
## Examples
Choose **folder path with trailing /** for **output1** and **filename with ext**
Choose **path/** for **output1** and **file.ext**
for **output2** to connect the folder and filename separately.
## Notes
- Supports PNG, JPG, JPEG, WEBP, BMP and GIF. Folders are read in filename order, without subfolders.
- Supports PNG, JPG, JPEG, WEBP, BMP and GIF. Folders are read in filename order. With **include_subfolders** enabled, images from all subfolders are included in path order.
- No usable images returns empty outputs and a count of 0.
- Paths use your system's separators. Relative paths may contain `..`; relative folder output is `.` for the input folder itself.
- Labels and paths use `\` on Windows and `/` elsewhere. Drive roots keep their required slash. File paths never end in a slash.
- Labels use `/`; output paths use `\` on Windows and `/` elsewhere. Drive roots keep their required slash. File paths never end in a slash.
- On Windows, relative formats require the image and input folder to share a drive.
- Extensionless formats remove only the final image extension. Dots within the filename are preserved.
+8 -3
View File
@@ -7,7 +7,8 @@ training datasets, where `photo_01.png` sits next to `photo_01.txt`.
- **seed** — Which pair to load. Set its control to **increment** to walk the
dataset.
- **folder_path** — Folder holding the images and text files. Used only when
- **input_path**: Folder holding the images and text files. Relative paths start
in ComfyUI's input folder. Used only when
the two direct inputs below are not connected.
- **image_input** *(optional)* — An image already in the workflow.
- **text_input** *(optional)* — Text already in the workflow. When both direct
@@ -25,7 +26,11 @@ training datasets, where `photo_01.png` sits next to `photo_01.txt`.
## Notes
Pairing is by matching basename. An image with no caption file is not a pair
and is skipped. A missing folder returns empty outputs with a `total_count` of
The image and text previews update when input_path, seed or caption extension
changes. Missing captions leave the text preview blank. Connected inputs show
the first output image and text after execution.
Pairing is by matching basename. Images without caption files return empty text.
A missing folder returns empty outputs with a `total_count` of
0. When using the direct inputs, the path and filename outputs are empty
because there is no file behind them.
+8 -1
View File
@@ -6,7 +6,8 @@ dataset as parallel lists, rotated so the seed picks the starting point.
## Inputs
- **seed** — Where the lists start. Set its control to **increment** to rotate.
- **folder_path** — Folder holding the images and caption files.
- **input_path**: Folder holding the images and caption files. Relative paths
start in ComfyUI's input folder.
- **force_reload** — Re-read from disk instead of using the cache. Turn it on
after changing files on disk, then off again.
- **image_input** / **text_input** *(optional)* — Data already in the workflow.
@@ -28,6 +29,12 @@ All in the same order, one entry per pair:
## Notes
The preview shows only the first selected image and its text, and updates when
input_path, seed or caption extension changes. Missing captions leave the text
preview blank. Images without captions remain in the output with empty text.
The preview uses the cached batch when available. Connected inputs show only
the first output image and text after execution.
The folder is read once and cached per path; `force_reload` clears it. Images
whose dimensions differ from the first are resized and centre-cropped so the
batch is uniform. A single text input paired with several images is applied to
+3
View File
@@ -30,6 +30,9 @@ All outputs are lists in the same order, one entry per image:
## Notes
The preview shows only the first selected image and updates when the path or
seed changes. Connected inputs show only the first output image after execution.
Files are sorted by name before rotating, so the order is stable. Images whose
dimensions differ from the first one are resized and centre-cropped to match,
because a batch has to be uniform — a console warning says when this happens.
@@ -27,6 +27,9 @@ the raw block as it was stored.
## Notes
The preview updates when the path or seed changes. Connected inputs show the
selected output image after execution. Empty results clear the preview.
Reads png, jpg, jpeg, tiff and tif. Files with no metadata return empty
strings and `{}` rather than failing. Keys absent from the file come back as
empty lines in `filtered_params_list`, so the line positions stay aligned with
+5 -4
View File
@@ -106,7 +106,7 @@ function addStylesheet() {
style.textContent = `
.mnemic-llm { display:flex; flex-direction:column; gap:6px; width:100%; height:100%; box-sizing:border-box;
padding:6px 8px 8px; font:12px/1.4 system-ui, sans-serif; color:var(--fg-color); overflow:hidden; }
.mnemic-llm-status { display:flex; align-items:center; gap:6px; flex-wrap:wrap; min-height:20px; }
.mnemic-llm-status { display:flex; align-items:center; gap:6px; min-height:20px; }
.mnemic-llm-dot { width:9px; height:9px; border-radius:50%; flex-shrink:0; background:#888; }
.mnemic-llm-dot.ok { background:#3fb950; box-shadow:0 0 6px #3fb95088; }
.mnemic-llm-dot.warn { background:#d29922; }
@@ -116,12 +116,13 @@ function addStylesheet() {
.mnemic-llm-chip { padding:1px 7px; border-radius:10px; background:var(--comfy-input-bg); border:1px solid var(--border-color);
white-space:nowrap; font-size:11px; }
.mnemic-llm-chip.bad { border-color:#d29922; color:#d29922; }
.mnemic-llm-host { opacity:.6; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.mnemic-llm-test { flex:0 0 auto; min-width:0; margin-left:auto; padding:2px 8px; }
.mnemic-llm-host { flex:1; opacity:.6; font-size:11px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; min-width:0; }
.mnemic-llm-btn.mnemic-llm-test { flex:0 0 auto; min-width:0; margin-left:auto; padding:2px 8px; }
.mnemic-llm-pickrow { display:flex; gap:6px; }
.mnemic-llm-pick { flex:1; min-width:0; display:flex; align-items:center; gap:4px; padding:3px 6px; border-radius:5px;
background:var(--comfy-input-bg); color:var(--fg-color); border:1px solid var(--border-color); cursor:pointer; }
.mnemic-llm-pick:hover { border-color:#58a6ff; }
.mnemic-llm-pickrow > [data-act="endpoints"] { flex:0 0 calc((100% - 6px) / 3); box-sizing:border-box; }
.mnemic-llm-pick.mnemic-llm-pick-combo { padding:0; cursor:default; }
.mnemic-llm-pick-value { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; font-size:11.5px; }
.mnemic-llm-pick-input { flex:1; min-width:0; padding:3px 0 3px 6px; border:none; background:none; color:var(--fg-color); font-size:11.5px; }
@@ -429,9 +430,9 @@ class LLMPanel {
<span class="mnemic-llm-dot"></span>
<span class="mnemic-llm-chip" data-role="where"></span>
<span class="mnemic-llm-chip" data-role="key"></span>
<span class="mnemic-llm-host"></span>
<button class="mnemic-llm-btn mnemic-llm-test" data-act="test" title="Check that the endpoint answers">Test</button>
</div>
<div class="mnemic-llm-host"></div>
<div class="mnemic-llm-pickrow">
<button class="mnemic-llm-pick" data-act="endpoints" title="Endpoint - click to choose">
<span class="mnemic-llm-pick-value" data-role="endpoint-value"></span>
+192 -1
View File
@@ -1,6 +1,155 @@
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
const EXTRA_WIDGET = "extra_path_formats";
const formatHelp = new Map();
function addImagePreview(node, kind = "images") {
const isPair = kind.startsWith("pair_");
const container = document.createElement("div");
// Keep image dimensions out of DOM layout measurement. The node owns the
// available rectangle; the image fits inside it without changing node size.
container.style.cssText = "position:relative; height:100%; width:100%; min-width:0; min-height:0; overflow:hidden; contain:size layout;";
const image = document.createElement("img");
image.alt = "Selected image";
image.title = "Image selected by the current seed.";
image.style.cssText = "position:absolute; inset:0; width:100%; height:100%; object-fit:contain; object-position:center; display:none;";
container.append(image);
const widget = node.addDOMWidget("image_preview", "mnemic_image_preview", container, {
serialize: false, hideOnZoom: false,
getMinHeight: () => 120,
getValue: () => "", setValue: () => {},
});
widget.serialize = false;
const widgets = [widget];
const caption = isPair ? document.createElement("div") : null;
let captionObserver;
if (caption) {
node.properties ??= {};
const textHeight = () => node.properties.mnmPairTextPreviewHeight ?? 120;
const textContainer = document.createElement("div");
textContainer.style.cssText = "display:flex; flex-direction:column; flex:0 0 auto; width:100%; font-size:12px; color:var(--input-text, #ddd);";
const header = document.createElement("div");
header.textContent = "text_preview";
header.title = "Text for the selected image.";
header.style.cssText = "height:22px; box-sizing:border-box; padding:2px 4px; opacity:.8;";
caption.title = "Text for the selected image.";
caption.setAttribute("aria-label", "text_preview");
caption.style.cssText = "flex:none; box-sizing:border-box; min-height:40px; resize:vertical; overflow:auto; white-space:pre-wrap; overflow-wrap:break-word; font-family:monospace; line-height:1.5; padding:4px 6px; border-radius:6px; background:var(--comfy-input-bg, rgba(0,0,0,.25));";
caption.style.height = `${textHeight()}px`;
textContainer.append(header, caption);
textContainer.addEventListener("pointerdown", (event) => event.stopPropagation());
const textWidget = node.addDOMWidget("text_preview", "mnemic_text_preview", textContainer, {
serialize: false, hideOnZoom: false,
});
textWidget.serialize = false;
// Match the wildcard/LLM text panels: a fixed widget row with a separately resizable body.
textWidget.computeSize = (width) => [width ?? node.size[0], 22 + textHeight()];
textWidget.computeLayoutSize = undefined;
widgets.push(textWidget);
captionObserver = new ResizeObserver(() => {
if (!caption.isConnected || !caption.offsetHeight || caption.offsetHeight === textHeight()) return;
node.properties.mnmPairTextPreviewHeight = caption.offsetHeight;
node.setDirtyCanvas?.(true, true);
});
captionObserver.observe(caption);
const onConfigure = node.onConfigure;
node.onConfigure = function () {
const result = onConfigure?.apply(this, arguments);
caption.style.height = `${textHeight()}px`;
return result;
};
}
let previous;
let controller;
let objectUrl;
let revision = 0;
const clear = () => {
image.style.display = "none";
image.removeAttribute("src");
if (caption) caption.textContent = "";
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = undefined;
};
async function update() {
const pathName = "input_path";
const names = [pathName, "seed", "include_subfolders", "text_format_extension", "force_reload"];
const values = Object.fromEntries(names.map((name) => [name, node.widgets.find((item) => item.name === name)?.value]));
values.input_path = values[pathName];
values.kind = kind;
for (const name of Object.keys(values)) if (values[name] === undefined) delete values[name];
// Connected values are not available until execution; do not show a stale widget selection.
const connected = (name) => node.inputs?.some((input) => input.name === name && input.link != null);
const directImages = connected("image_input") && (!kind.startsWith("pair_") || connected("text_input"));
const linked = directImages || names.some(connected);
const key = JSON.stringify([values, linked]);
if (key === previous) return;
previous = key;
const current = ++revision;
controller?.abort();
clear();
if (linked || !values.input_path || !Number.isFinite(Number(values.seed))) return;
controller = new AbortController();
try {
const query = new URLSearchParams(values);
const response = await api.fetchApi(`/mnemic/load-images/preview?${query}`, { signal: controller.signal });
if (!response.ok) return;
if (isPair) {
const data = await response.json();
if (current !== revision) return;
caption.textContent = data.text ?? "";
if (data.image) {
image.src = data.image;
image.style.display = "block";
}
return;
}
const blob = await response.blob();
if (current !== revision) return;
objectUrl = URL.createObjectURL(blob);
image.src = objectUrl;
image.style.display = "block";
} catch (error) {
if (error.name !== "AbortError") console.warn("Image preview failed", error);
}
}
// Observe programmatic seed increments as well as direct widget edits.
const timer = setInterval(update, 300);
if (kind !== "images") {
const onExecuted = node.onExecuted;
node.onExecuted = function (message) {
const result = onExecuted?.call(this, { ...message, images: [] });
node.imgs = null;
const names = ["input_path", "seed", "image_input", "text_input"];
if (node.inputs?.some((input) => names.includes(input.name) && input.link != null)) {
++revision;
controller?.abort();
clear();
if (caption) caption.textContent = message.pair_text?.[0] ?? "";
const preview = message.images?.[0];
if (preview) {
image.src = api.apiURL(`/view?${new URLSearchParams(preview)}`);
image.style.display = "block";
}
} else {
previous = undefined;
void update();
}
return result;
};
}
const onRemoved = node.onRemoved;
node.onRemoved = function () {
clearInterval(timer);
captionObserver?.disconnect();
++revision;
controller?.abort();
clear();
return onRemoved?.apply(this, arguments);
};
return widgets;
}
function moveLegacyOutput(outputs) {
if (!["image_path", "path/file output"].includes(outputs?.[2]?.name)) return false;
@@ -10,9 +159,26 @@ function moveLegacyOutput(outputs) {
app.registerExtension({
name: "mnemic.LoadImagesFromPath",
setup() {
const updateMenus = () => {
for (const menu of document.querySelectorAll(".litecontextmenu, .p-select-overlay, .p-dropdown-panel")) {
const entries = [...menu.querySelectorAll('[role="option"], .litemenu-entry')];
for (const entry of entries) {
const help = formatHelp.get(entry.textContent.trim());
if (help) entry.title = help;
}
}
};
new MutationObserver(updateMenus).observe(document.body, { childList: true, characterData: true, subtree: true });
},
beforeConfigureGraph(data) {
function migrate(graph) {
for (const node of graph.nodes ?? []) {
if (["MNeMiC_LoadTextImagePairSingle", "MNeMiC_LoadTextImagePairsList"].includes(node.type)) {
for (const input of node.inputs ?? []) {
if (input.name === "folder_path") input.name = "input_path";
}
}
if (node.type !== "MNeMiC_LoadImagesFromPath" || !moveLegacyOutput(node.outputs)) continue;
for (const link of graph.links ?? []) {
const origin = Array.isArray(link) ? link[1] : link.origin_id;
@@ -28,11 +194,34 @@ app.registerExtension({
migrate(data);
},
beforeRegisterNodeDef(nodeType, nodeData) {
const previewKind = {
MNeMiC_LoadTextImagePairSingle: "pair_single",
MNeMiC_LoadTextImagePairsList: "pair_list",
MNeMiC_MetadataExtractorSingle: "metadata_single",
MNeMiC_MetadataExtractorList: "metadata_list",
}[nodeData.name];
if (previewKind) {
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function () {
for (const input of this.inputs ?? []) {
if (input.name === "folder_path") input.name = "input_path";
}
return onConfigure?.apply(this, arguments);
};
const onCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function () {
const result = onCreated?.apply(this, arguments);
addImagePreview(this, previewKind);
return result;
};
return;
}
if (nodeData.name !== "MNeMiC_LoadImagesFromPath") return;
const formatInput = nodeData.input.required.path_format;
const formats = Array.isArray(formatInput[0]) ? formatInput[0] : formatInput[1].options;
const aliases = formatInput[1].format_aliases ?? {};
const tooltips = formatInput[1].format_tooltips ?? {};
for (const [format, help] of Object.entries(tooltips)) formatHelp.set(format, help);
const normalizeFormat = (value) => aliases[value] ?? value;
const maxOutputs = nodeData.input.optional[EXTRA_WIDGET][1].max_path_outputs;
@@ -64,7 +253,7 @@ app.registerExtension({
const outputIndex = (index) => index + 4;
function resize() {
node.setSize([node.size[0], node.computeSize()[1]]);
node.setSize([node.size[0], Math.max(node.size[1], node.computeSize()[1])]);
node.setDirtyCanvas(true, true);
}
@@ -146,6 +335,8 @@ app.registerExtension({
remove.tooltip = "Remove the last additional path/file output and its connections.";
buttons.push(remove);
buttons.push(...addImagePreview(node));
node._restorePathOutputs = restore;
restore();
resize();