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:
+7
-3
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]})
|
||||
|
||||
@@ -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]]})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user