Files
SilverAndJade-comfyui-silve…/node.py
T

1858 lines
74 KiB
Python
Executable File

import io
import os
import sys
import re
import random
import time
from datetime import datetime
target_dir = sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
import folder_paths
import torch
import numpy as np
from urllib.parse import urlparse, urljoin
from bs4 import BeautifulSoup
from PIL import Image, ImageOps
from server import PromptServer
import requests
import hashlib
from typing import Any, List, Tuple
import comfy.model_management
class AnyType(str):
def __ne__(self, __value: object) -> bool:
return False
any_return = AnyType("*")
class SilverRandomFromList:
@classmethod
def INPUT_TYPES(cls):
cls.LORA_NAMES = folder_paths.get_filename_list("loras")
return {
"required": {
"input_list": (any_return),
}
}
RETURN_TYPES = (any_return, any_return)
RETURN_NAMES = ("random_item", "first_item")
FUNCTION = "get_random_from_list"
CATEGORY = "silver"
def get_random_from_list(self, input_list: List[Any]) -> Tuple[Any, Any]:
"""
Returns a random item from the input list and the first item.
Args:
input_list (List[Any]): The input list to select from
Returns:
Tuple[Any, Any]: A tuple containing (random_item, first_item)
"""
if not input_list:
return (None, None)
first_item = input_list[0] if input_list else None
random_item = random.choice(input_list)
return (random_item, first_item)
class SilverLoraModelLoader:
@classmethod
def INPUT_TYPES(cls):
cls.LORA_NAMES = folder_paths.get_filename_list("loras")
return {
"required": {
"regex_filter": ("STRING", {"default": ".*"}),
"lora_name": (cls.LORA_NAMES, ),
"action": (["fixed", "increment", "decrement", "randomize"], {"default": "increment"}),
"repeat_count": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"current_repeat": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1}),
"fetch_lora_info": ("BOOLEAN", {"default": False}),
}
}
RETURN_TYPES = (any_return, "STRING", "STRING", any_return, any_return)
RETURN_NAMES = ("COMBO", "STRING", "lora_tags", "example_prompts", "example_images")
FUNCTION = "load_lora_model"
CATEGORY = "silver"
def _get_cache_dir(self):
"""Get the cache directory, creating it if it doesn't exist."""
import os
cache_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'lora_cache')
os.makedirs(cache_dir, exist_ok=True)
return cache_dir
def _get_cache_path(self, hash_value):
"""Get the cache file path for a given hash."""
import os
return os.path.join(self._get_cache_dir(), f"{hash_value}.json")
def _load_from_cache(self, hash_value):
"""Load Lora info from cache if it exists."""
import json
import os
cache_path = self._get_cache_path(hash_value)
if os.path.exists(cache_path):
try:
with open(cache_path, 'r') as f:
data = json.load(f)
return data.get('tags_str', ''), data.get('example_prompts', ['']), data.get('example_images', [''])
except Exception as e:
print(f"Error loading cache for hash {hash_value}: {str(e)}")
return None
def _save_to_cache(self, hash_value, tags_str, example_prompts, example_images):
"""Save Lora info to cache."""
import json
import os
cache_path = self._get_cache_path(hash_value)
try:
with open(cache_path, 'w') as f:
json.dump({
'tags_str': tags_str,
'example_prompts': example_prompts,
'example_images': example_images
}, f)
except Exception as e:
print(f"Error saving cache for hash {hash_value}: {str(e)}")
def get_lora_info(self, lora_name):
try:
import hashlib
import requests
import os
import json
# Get the lora file path
lora_path = folder_paths.get_full_path("loras", lora_name)
if not os.path.exists(lora_path):
return "", [""], [""]
# Calculate hash
sha256_hash = hashlib.sha256()
with open(lora_path, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
sha256_hash.update(chunk)
hash_value = sha256_hash.hexdigest()
# Try to load from cache first
cached_data = self._load_from_cache(hash_value)
if cached_data is not None:
return cached_data
# If not in cache, query CivitAI
api_url = f"https://civitai.com/api/v1/model-versions/by-hash/{hash_value}"
response = requests.get(api_url)
if response.status_code != 200:
return "", [""], [""]
data = response.json()
# Extract tags
tags = data.get("trainedWords", [])
tags_str = ", ".join(tags) if tags else ""
# Extract example prompts
example_prompts = []
for image in data.get("images", []):
if image.get("hasMeta"):
if "meta" in image and "prompt" in image["meta"]:
example_prompts.append(image["meta"]["prompt"])
# Extract example image URLs
example_images = [img["url"] for img in data.get("images", []) if "url" in img and img.get("type") != "video"]
if len(example_prompts) == 0:
example_prompts.append("")
if len(example_images) == 0:
example_images.append("")
# Save to cache
self._save_to_cache(hash_value, tags_str, example_prompts, example_images)
return tags_str, example_prompts, example_images
except Exception as e:
print(f"Error getting Lora info: {str(e)}")
return "", [""], [""]
def load_lora_model(self, regex_filter=".*", lora_name="None", action="fixed", repeat_count=1, current_repeat=0, fetch_lora_info=False):
lora_names = self.LORA_NAMES
current_model_index = next((i for i, name in enumerate(lora_names) if name == lora_name), 0)
regex = re.compile(regex_filter, re.IGNORECASE)
filtered_loras = [i for i, name in enumerate(lora_names) if regex.search(name)]
if not filtered_loras:
return {"ui": {"next_index": [current_model_index], "next_repeat": [current_repeat]},
"result": (lora_name, lora_name)}
try:
filtered_idx_pos = filtered_loras.index(current_model_index)
except ValueError:
filtered_idx_pos = 0
next_repeat = current_repeat + 1
next_index = current_model_index
if next_repeat > repeat_count:
next_repeat = 1
max_filtered_pos = len(filtered_loras) - 1
if action == "increment":
filtered_idx_pos = (filtered_idx_pos + 1) if filtered_idx_pos < max_filtered_pos else 0
elif action == "decrement":
filtered_idx_pos = (filtered_idx_pos - 1) if filtered_idx_pos > 0 else max_filtered_pos
elif action == "randomize":
filtered_idx_pos = random.randint(0, max_filtered_pos)
next_index = filtered_loras[filtered_idx_pos]
next_model = lora_names[next_index]
lora_tags = ""
example_prompts = []
example_images = []
print(next_model)
if fetch_lora_info:
lora_tags, example_prompts, example_images = self.get_lora_info(next_model)
return {"ui": {"next_model": [next_model], "next_repeat": [next_repeat]},
"result": (next_model, next_model, lora_tags, example_prompts, example_images)}
class SilverFolderImageLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 64,
"step": 1
}),
"current_index": ("INT", {
"default": 0,
"min": 0,
"max": 1000000,
"step": 1
}),
"action": (["fixed", "increment", "decrement", "increment_wrap", "reset"], ),
"sort_by": (["name", "created", "modified", "size"], ),
"sort_order": (["ascending", "descending"], {"default": "ascending"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "INT")
RETURN_NAMES = ("images", "filenames", "current_index")
FUNCTION = "load_images_from_folder"
CATEGORY = "image"
# Tell ComfyUI this is a stateful node that should always execute
OUTPUT_NODE = True
FORCE_ALWAYS_EXECUTE = True
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Always execute
def __init__(self):
# Get server instance for state management
self.server = PromptServer.instance
if not hasattr(self.server, 'folder_image_indices'):
self.server.folder_image_indices = {}
# Instance-level storage for current folder state
self.folder_path = ""
self.image_files = []
self.total_images = 0
def _sort_image_files(self, folder_path, files, sort_by, sort_order="ascending"):
"""Sort image files based on the specified criterion and order."""
import re
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
if not files:
return files
reverse = (sort_order == "descending")
if sort_by == "name":
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
elif sort_by == "created":
def get_ctime_safe(f):
try:
return os.path.getctime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_ctime_safe, reverse=reverse)
elif sort_by == "modified":
def get_mtime_safe(f):
try:
return os.path.getmtime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_mtime_safe, reverse=reverse)
elif sort_by == "size":
def get_size_safe(f):
try:
return os.path.getsize(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_size_safe, reverse=reverse)
else:
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
return sorted_files
def _get_image_files(self, folder_path, sort_by="name", sort_order="ascending"):
"""Get sorted list of image files in the folder. Always refreshes and sorts."""
supported_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp']
try:
files = [
f for f in os.listdir(folder_path)
if os.path.splitext(f)[1].lower() in supported_extensions
]
except Exception as e:
print(f"[FolderImageLoader] Error listing files in {folder_path}: {e}")
files = []
sorted_files = self._sort_image_files(folder_path, files, sort_by, sort_order)
return sorted_files
def _process_image(self, img):
"""Process a PIL image into the format expected by ComfyUI."""
image = ImageOps.exif_transpose(img)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
return image
def load_images_from_folder(self, folder_path: str, batch_size: int = 1,
current_index: int = 0, action: str = 'fixed',
sort_by: str = 'name', sort_order: str = 'ascending'):
"""Load images from a specified folder with flexible index management."""
if not os.path.isdir(folder_path):
raise ValueError(f"[FolderImageLoader] Invalid folder path: {folder_path}")
# Always get freshly sorted file list
image_files = self._get_image_files(folder_path, sort_by, sort_order)
if not image_files:
raise ValueError(f"[FolderImageLoader] No supported image files found in {folder_path}")
# Ensure index is within bounds after sorting
current_idx = max(0, min(current_index, len(image_files) - 1))
# Load images
loaded_images = []
filenames = []
for i in range(batch_size):
idx = (current_idx + i) % len(image_files)
image_path = os.path.join(folder_path, image_files[idx])
try:
with Image.open(image_path) as img:
loaded_images.append(self._process_image(img))
filenames.append(image_files[idx])
except Exception as e:
print(f"[FolderImageLoader] Error loading image {image_path}: {e}")
continue
if not loaded_images:
raise ValueError("[FolderImageLoader] No valid images could be loaded from the specified folder")
# Check for consistent image sizes before stacking
image_shapes = [img.shape for img in loaded_images]
if len(set(image_shapes)) > 1:
raise ValueError("[FolderImageLoader] Different size images detected in the batch. Please only load one image at a time, or ensure all images are the same size.")
images = torch.cat(loaded_images, dim=0)
# Update index based on action
if action == "increment":
next_index = (current_idx + 1) % len(image_files)
elif action == "decrement":
next_index = (current_idx - 1) if current_idx > 0 else len(image_files) - 1
elif action == "increment_wrap":
next_index = (current_idx + 1) % len(image_files)
else: # "fixed" or "reset"
next_index = current_idx
self.current_index = current_idx
self.next_index = next_index
return {"ui": {"next_index": [next_index]}, "result": (images, filenames, next_index)}
class SilverFolderVideoLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
"frame_cap": ("INT", {
"default": 0,
"min": 0,
"max": 1000,
"step": 1,
"display": "number"
}),
"frame_rate_force": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 60.0,
"step": 1.0,
"display": "number"
}),
"start_frame": ("INT", {
"default": 0,
"min": 0,
"step": 1,
"display": "number"
}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 64,
"step": 1,
"display": "number"
}),
"current_index": ("INT", {
"default": 0,
"min": 0,
"max": 1000000,
"step": 1
}),
"action": (["fixed", "increment", "decrement", "increment_wrap", "reset"], ),
"sort_by": (["name", "created", "modified", "size"], ),
"sort_order": (["ascending", "descending"], {"default": "ascending"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "INT", "FLOAT", "AUDIO")
RETURN_NAMES = ("images", "filenames", "current_index", "frame_rate", "audio")
FUNCTION = "load_videos_from_folder"
CATEGORY = "video"
# Tell ComfyUI this is a stateful node that should always execute
OUTPUT_NODE = True
FORCE_ALWAYS_EXECUTE = True
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Always execute
def __init__(self):
# Get server instance for state management
self.server = PromptServer.instance
if not hasattr(self.server, 'folder_image_indices'):
self.server.folder_image_indices = {}
# Instance-level storage for current folder state
self.folder_path = ""
self.image_files = []
self.total_images = 0
def _sort_video_files(self, folder_path, files, sort_by, sort_order="ascending"):
"""Sort video files based on the specified criterion and order."""
import re
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
if not files:
return files
reverse = (sort_order == "descending")
if sort_by == "name":
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
elif sort_by == "created":
def get_ctime_safe(f):
try:
return os.path.getctime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_ctime_safe, reverse=reverse)
elif sort_by == "modified":
def get_mtime_safe(f):
try:
return os.path.getmtime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_mtime_safe, reverse=reverse)
elif sort_by == "size":
def get_size_safe(f):
try:
return os.path.getsize(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_size_safe, reverse=reverse)
else:
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
return sorted_files
def _get_video_files(self, folder_path, sort_by="name", sort_order="ascending"):
"""Get sorted list of video files in the folder. Always refreshes and sorts."""
supported_extensions = ['.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv']
try:
files = [
f for f in os.listdir(folder_path)
if os.path.splitext(f)[1].lower() in supported_extensions
]
except Exception as e:
print(f"[FolderVideoLoader] Error listing files in {folder_path}: {e}")
files = []
sorted_files = self._sort_video_files(folder_path, files, sort_by, sort_order)
return sorted_files
def _process_image(self, img):
"""Process a PIL image into the format expected by ComfyUI."""
image = ImageOps.exif_transpose(img)
image = image.convert("RGB")
image = np.array(image).astype(np.float32) / 255.0
image = torch.from_numpy(image)[None,]
return image
def _extract_frames(self, video_path, frame_cap=0, frame_rate=0.0, start_frame=0):
"""Extract frames and audio from video file with optional frame capping and frame rate control."""
try:
import cv2
from moviepy.editor import VideoFileClip, AudioFileClip, concatenate_audioclips
import numpy as np
# First, extract audio using moviepy
audio = None
try:
with VideoFileClip(video_path) as video_clip:
if video_clip.audio is not None:
# Create a copy of the audio to avoid resource cleanup issues
audio = video_clip.audio.copy()
except Exception as e:
print(f"[FolderVideoLoader] Warning: Could not extract audio from {video_path}: {e}")
# Then extract frames using OpenCV
cap = cv2.VideoCapture(video_path)
if not cap.isOpened():
raise ValueError(f"Could not open video file: {video_path}")
# Get video properties
fps = cap.get(cv2.CAP_PROP_FPS)
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
# Calculate frame step based on desired frame rate
frame_step = 1
if frame_rate > 0 and fps > frame_rate:
frame_step = int(round(fps / frame_rate))
# Calculate actual frame cap (0 means no cap)
if frame_cap > 0:
max_frames = min(frame_cap, total_frames - start_frame)
else:
max_frames = total_frames - start_frame
# Set start position
if start_frame > 0:
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
frames = []
frame_count = 0
while frame_count < max_frames:
ret, frame = cap.read()
if not ret:
break
if frame_count % frame_step == 0:
# Convert BGR to RGB and process
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
pil_img = Image.fromarray(frame_rgb)
frames.append(self._process_image(pil_img))
frame_count += 1
# Skip frames if needed
if frame_step > 1:
cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame + frame_count * frame_step)
cap.release()
if not frames:
raise ValueError(f"No frames could be extracted from {video_path}")
return torch.cat(frames, dim=0), fps, audio # Return frames, fps, and audio
except Exception as e:
print(f"[FolderVideoLoader] Error extracting frames from {video_path}: {e}")
raise
def load_videos_from_folder(self, folder_path: str, frame_cap: int = 0, frame_rate_force: float = 0.0,
start_frame: int = 0, batch_size: int = 1, current_index: int = 0,
action: str = 'fixed', sort_by: str = 'name', sort_order: str = 'ascending'):
"""Load video frames and audio from a specified folder with flexible index management."""
if not os.path.isdir(folder_path):
raise ValueError(f"[FolderVideoLoader] Invalid folder path: {folder_path}")
# Always get freshly sorted file list
video_files = self._get_video_files(folder_path, sort_by, sort_order)
if not video_files:
raise ValueError(f"[FolderVideoLoader] No supported video files found in {folder_path}")
# Ensure index is within bounds after sorting
current_idx = max(0, min(current_index, len(video_files) - 1))
# Load video frames and audio
all_frames = []
all_audio = []
filenames = []
for i in range(batch_size):
idx = (current_idx + i) % len(video_files)
video_path = os.path.join(folder_path, video_files[idx])
try:
frames, fps, audio = self._extract_frames(video_path, frame_cap, frame_rate_force, start_frame)
all_frames.append(frames)
if audio is not None:
all_audio.append(audio)
filenames.append(video_path)
except Exception as e:
print(f"[FolderVideoLoader] Error processing video {video_path}: {e}")
continue
if not all_frames:
raise ValueError("[FolderVideoLoader] No valid videos could be processed from the specified folder")
# Stack all frames from all videos
images = torch.cat(all_frames, dim=0)
# Combine audio if available
combined_audio = None
if all_audio:
from moviepy.audio.AudioClip import concatenate_audioclips
try:
# Filter out None audio clips and ensure they have duration > 0
valid_audio_clips = [clip for clip in all_audio if clip is not None and clip.duration > 0]
if valid_audio_clips:
if len(valid_audio_clips) == 1:
combined_audio = valid_audio_clips[0].copy()
else:
combined_audio = concatenate_audioclips(valid_audio_clips)
except Exception as e:
print(f"[FolderVideoLoader] Error combining audio: {e}")
combined_audio = None
# Update index based on action
if action == "increment":
next_index = (current_idx + 1) % len(video_files)
elif action == "decrement":
next_index = (current_idx - 1) if current_idx > 0 else len(video_files) - 1
elif action == "increment_wrap":
next_index = (current_idx + 1) % len(video_files)
else: # "fixed" or "reset"
next_index = current_idx
self.current_index = current_idx
self.next_index = next_index
return {
"ui": {"next_index": [next_index]},
"result": (images, ", ".join(filenames), next_index, fps, combined_audio)
}
class SilverFolderFilePathLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": ""}),
"regex_filter": ("STRING", {"default": ".*", "multiline": False}),
"batch_size": ("INT", {
"default": 1,
"min": 1,
"max": 64,
"step": 1
}),
"current_index": ("INT", {
"default": 0,
"min": 0,
"max": 1000000,
"step": 1
}),
"action": (["fixed", "increment", "decrement", "increment_wrap", "reset"], ),
"sort_by": (["name", "created", "modified", "size"], ),
"sort_order": (["ascending", "descending"], {"default": "ascending"}),
"wait_for_new": (["enable", "disable"], {"default": "disable"}),
}
}
RETURN_TYPES = ("STRING", "INT")
RETURN_NAMES = ("file_paths", "current_index")
FUNCTION = "load_files_from_folder"
CATEGORY = "image"
# Tell ComfyUI this is a stateful node that should always execute
OUTPUT_NODE = True
FORCE_ALWAYS_EXECUTE = True
@classmethod
def IS_CHANGED(cls, **kwargs):
return float("NaN") # Always execute
def __init__(self):
# Get server instance for state management
self.server = PromptServer.instance
if not hasattr(self.server, 'folder_image_indices'):
self.server.folder_image_indices = {}
if not hasattr(self.server, 'folder_monitor_state'):
self.server.folder_monitor_state = {}
# Instance-level storage for current folder state
self.folder_path = ""
self.total_images = 0
self.last_seen_files = set()
def _sort_files(self, folder_path, files, sort_by, sort_order="ascending"):
"""Sort files based on the specified criterion and order."""
import re
def natural_sort_key(s):
return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
if not files:
return files
reverse = (sort_order == "descending")
if sort_by == "name":
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
elif sort_by == "created":
def get_ctime_safe(f):
try:
return os.path.getctime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_ctime_safe, reverse=reverse)
elif sort_by == "modified":
def get_mtime_safe(f):
try:
return os.path.getmtime(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_mtime_safe, reverse=reverse)
elif sort_by == "size":
def get_size_safe(f):
try:
return os.path.getsize(os.path.join(folder_path, f))
except Exception:
return 0
sorted_files = sorted(files, key=get_size_safe, reverse=reverse)
else:
sorted_files = sorted(files, key=natural_sort_key, reverse=reverse)
return sorted_files
def _get_files(self, folder_path, sort_by="name", sort_order="ascending", regex_filter=".*"):
"""Get sorted list of files in the folder with regex filtering."""
if not os.path.isdir(folder_path):
return []
# Get all files in the directory
files = []
for f in os.listdir(folder_path):
file_path = os.path.join(folder_path, f)
if os.path.isfile(file_path):
files.append(f)
# Filter by regex if specified
if regex_filter and regex_filter != "*":
try:
regex = re.compile(regex_filter, re.IGNORECASE)
files = [f for f in files if regex.search(f)]
except re.error:
print(f"[FolderFilePathLoader] Invalid regex pattern: {regex_filter}")
# Sort files
return self._sort_files(folder_path, files, sort_by, sort_order)
def _get_current_files(self, folder_path, regex_filter):
"""Get current set of files matching the filter."""
if not os.path.isdir(folder_path):
return set()
try:
files = set()
for f in os.listdir(folder_path):
file_path = os.path.join(folder_path, f)
if os.path.isfile(file_path):
if not regex_filter or regex_filter == ".*" or re.search(regex_filter, f, re.IGNORECASE):
files.add(f)
return files
except Exception as e:
print(f"[FolderFilePathLoader] Error listing files: {e}")
return set()
def load_files_from_folder(self, folder_path: str, regex_filter: str = ".*", batch_size: int = 1,
current_index: int = 0, action: str = 'fixed',
sort_by: str = 'name', sort_order: str = 'ascending',
wait_for_new: str = 'disable'):
"""
Load files from a specified folder with flexible index management and regex filtering.
Args:
wait_for_new: If 'enable', will wait for new files to appear in the folder
"""
if not os.path.isdir(folder_path):
raise ValueError(f"[FolderFilePathLoader] Invalid folder path: {folder_path}")
# Get current files
current_files = self._get_current_files(folder_path, regex_filter)
# If waiting for new files and we've seen these files before
if wait_for_new == 'enable':
# Initialize last_seen_files if not already set
if not hasattr(self, 'last_seen_files'):
self.last_seen_files = set()
# If we have last_seen_files and no new files, wait for changes
if self.last_seen_files and not (current_files - self.last_seen_files):
print(f"[FolderFilePathLoader] Waiting for new files in {folder_path}...")
start_time = time.time()
poll_interval = 1.0 # seconds
max_wait_time = 300.0 # 5 minutes max wait
while time.time() - start_time < max_wait_time:
# Check for new files
current_files = self._get_current_files(folder_path, regex_filter)
new_files = current_files - self.last_seen_files
if new_files:
print(f"[FolderFilePathLoader] Detected {len(new_files)} new file(s)")
break
# Check for cancellation
if hasattr(self, 'cancelled') and self.cancelled:
raise Exception("Operation cancelled by user")
# Wait before polling again
time.sleep(min(poll_interval, 0.1)) # Don't sleep too long to maintain responsiveness
else:
raise TimeoutError(f"No new files detected in {folder_path} within {max_wait_time} seconds")
# Update last seen files for next time
self.last_seen_files = current_files
# Get sorted file list with regex filtering
self.files = self._get_files(folder_path, sort_by, sort_order, regex_filter)
self.total_files = len(self.files)
if not self.files:
raise ValueError(f"[FolderFilePathLoader] No supported files found in {folder_path}")
# Ensure index is within bounds after sorting
current_idx = max(0, min(current_index, len(self.files) - 1))
# Get file paths
file_paths = []
for i in range(batch_size):
idx = (current_idx + i) % len(self.files)
file_path = os.path.join(folder_path, self.files[idx])
try:
file_paths.append(file_path)
except Exception as e:
print(f"[FolderFilePathLoader] Error getting file path for {file_path}: {e}")
continue
# Update index based on action
if action == "increment":
next_index = (current_idx + 1) % len(self.files)
elif action == "decrement":
next_index = (current_idx - 1) if current_idx > 0 else len(self.files) - 1
elif action == "increment_wrap":
next_index = (current_idx + 1) % len(self.files)
else: # "fixed" or "reset"
next_index = current_idx
self.current_index = current_idx
self.next_index = next_index
return {"ui": {"next_index": [next_index]}, "result": (file_paths, next_index)}
class SilverFileTextLoader:
"""
Loads text from files with support for multiple encodings and flexible text splitting.
Handles various text file formats and provides robust error handling for different encodings.
"""
# Common text file extensions
TEXT_EXTENSIONS = {
'.txt', '.md', '.csv', '.log', '.srt', '.json', '.xml', '.html',
'.yaml', '.yml', '.ini', '.cfg', '.conf', '.py', '.js', '.css', '.tsv'
}
# Common text encodings to try (in order of likelihood)
ENCODINGS = [
'utf-8-sig', # UTF-8 with BOM
'utf-8', # Standard UTF-8
'utf-16', # UTF-16 (common in Windows)
'cp1252', # Windows Western European
'latin-1', # ISO-8859-1
'iso-8859-1', # Alternative name for latin-1
'ascii' # Fallback to ASCII
]
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"file_path": ("STRING", {
"default": "",
"multiline": False,
"dynamicPrompts": False,
"placeholder": "Path to text file"
}),
"split_mode": (["by line", "by paragraph"], {"default": "by line"}),
"current_index": ("INT", {
"default": 0,
"min": 0,
"max": 1000000,
"step": 1,
"display": "number"
}),
"action": (["fixed", "increment", "decrement", "randomize"], {
"default": "fixed"
})
}
}
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("text", "index", "count")
OUTPUT_NODE = True
FORCE_ALWAYS_EXECUTE = True
FUNCTION = "load_text_from_file"
CATEGORY = "silver_nodes/loaders"
def _try_decodings(self, file_path, encodings=None):
"""Try to read a file with multiple encodings."""
if encodings is None:
encodings = self.ENCODINGS
errors = []
for encoding in encodings:
try:
with open(file_path, 'r', encoding=encoding) as f:
content = f.read()
print(f"[FileTextLoader] Successfully read file with {encoding} encoding: {file_path}")
return content, encoding
except UnicodeDecodeError as e:
errors.append(f"{encoding}: {str(e)}")
except Exception as e:
errors.append(f"{encoding}: {str(e)}")
# If we get here, all encodings failed
error_msg = "\n".join([f" - {e}" for e in errors])
raise ValueError(
f"[FileTextLoader] Failed to decode file with any encoding: {file_path}\n"
f"Tried encodings:\n{error_msg}"
)
def _split_text(self, text, mode):
"""Split text into lines or paragraphs."""
if not text.strip():
return []
if mode == "by line":
# Split by lines, remove empty lines
return [line for line in text.splitlines() if line.strip()]
else:
# Split by paragraphs (multiple newlines with optional whitespace)
paragraphs = re.split(r'\s*\n\s*\n\s*', text)
return [p.strip() for p in paragraphs if p.strip()]
def _warn_unusual_extension(self, file_path):
"""Warn if the file has an unusual extension for a text file."""
ext = os.path.splitext(file_path)[1].lower()
if ext and ext not in self.TEXT_EXTENSIONS:
print(f"[FileTextLoader] Warning: Unusual file extension '{ext}' for text file: {file_path}")
def load_text_from_file(self, file_path: str, split_mode: str = "by line",
current_index: int = 0, action: str = "fixed"):
"""
Load text from a file with support for multiple encodings and text splitting modes.
Args:
file_path: Path to the text file
split_mode: How to split the text ('by line' or 'by paragraph')
current_index: Current index in the split items
action: Action to take on the index ('fixed', 'increment', 'decrement', 'randomize')
Returns:
Dictionary with text, index, and count of items
"""
# Input validation
if not file_path or not file_path.strip():
raise ValueError("[FileTextLoader] No file path provided")
file_path = file_path.strip()
if not os.path.exists(file_path):
raise FileNotFoundError(f"[FileTextLoader] File does not exist: {file_path}")
if os.path.isdir(file_path):
raise IsADirectoryError(f"[FileTextLoader] Path is a directory, not a file: {file_path}")
# Check file size to prevent loading huge files
try:
file_size = os.path.getsize(file_path)
if file_size > 10 * 1024 * 1024: # 10MB limit
raise ValueError(f"[FileTextLoader] File is too large ({file_size/1024/1024:.1f}MB). Maximum size is 10MB.")
except Exception as e:
print(f"[FileTextLoader] Warning: Could not check file size: {str(e)}")
# Warn about unusual file extensions but continue anyway
self._warn_unusual_extension(file_path)
# Try to read the file with multiple encodings
try:
text, used_encoding = self._try_decodings(file_path)
print(f"[FileTextLoader] Successfully decoded file with {used_encoding} encoding")
except Exception as e:
raise ValueError(f"[FileTextLoader] Failed to read file: {file_path}\nError: {str(e)}")
# Split text according to mode
try:
items = self._split_text(text, split_mode)
if not items:
raise ValueError(f"No content found after splitting by '{split_mode}'. File may be empty or contain only whitespace.")
except Exception as e:
raise ValueError(f"[FileTextLoader] Error processing text in file: {file_path}\nError: {str(e)}")
# Handle index and action
max_index = len(items) - 1
if max_index < 0:
return {"ui": {"current_index": [0]}, "result": ("", 0, 0)}
idx = max(0, min(current_index, max_index))
if action == "increment":
idx = (idx + 1) % (max_index + 1)
elif action == "decrement":
idx = (idx - 1) % (max_index + 1)
elif action == "randomize":
idx = random.randint(0, max_index)
# else fixed: do nothing
selected = items[idx]
print(f"[FileTextLoader] Loaded item {idx + 1}/{len(items)} from '{file_path}' (mode: {split_mode}, action: {action})")
return {
"ui": {
"next_index": [idx],
"total_items": [len(items)]
},
"result": (selected, idx, len(items))
}
class SilverFlickrRandomImage:
"""
A ComfyUI node that fetches a random image from Flickr based on search criteria.
Uses the public Flickr API with a predefined API key.
"""
# Flickr API configuration
FLICKR_API_KEY = "REDACTED"
FLICKR_API_BASE = "https://www.flickr.com/services/rest/"
_history_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'history', 'flickr_history.txt')
_loaded_hashes = set()
@classmethod
def _load_history(cls):
"""Load the history of loaded image hashes from file."""
try:
os.makedirs(os.path.dirname(cls._history_file), exist_ok=True)
if os.path.exists(cls._history_file):
with open(cls._history_file, 'r') as f:
cls._loaded_hashes = set(line.strip() for line in f if line.strip())
except Exception as e:
print(f"Warning: Failed to load Flickr history: {e}")
@classmethod
def _save_to_history(cls, content_hash):
"""Save a content hash to the history file with rolling history (max 1000 entries)."""
try:
# Ensure history directory exists
os.makedirs(os.path.dirname(cls._history_file), exist_ok=True)
# Read existing hashes if file exists
hashes = set()
if os.path.exists(cls._history_file):
with open(cls._history_file, 'r') as f:
hashes = set(line.strip() for line in f if line.strip())
# Add the new hash
hashes.add(content_hash)
# If we have more than 1000 hashes, keep only the 900 most recent
if len(hashes) > 1000:
# Convert to list and keep the most recent 900
hashes = list(hashes)[-900:]
# Write back to file
with open(cls._history_file, 'w') as f:
for h in hashes:
f.write(f"{h}\n")
# Update the in-memory set
cls._loaded_hashes = set(hashes)
except Exception as e:
print(f"Warning: Failed to save to Flickr history: {e}")
def __init__(self):
self._load_history()
def _get_content_hash(self, photo_id):
"""Generate a unique hash for the photo to prevent duplicates."""
return hashlib.md5(photo_id.encode('utf-8')).hexdigest()
@classmethod
def INPUT_TYPES(cls):
# Set default date range (2001-01-01 to today)
now = datetime.now()
default_min_date = "2001-01-01"
default_max_date = now.strftime("%Y-%m-%d")
# Available styles from Flickr - include 'any' as first option
styles = [
"any", # First option for "any style"
"blackandwhite", "depthoffield", "minimalism", "pattern",
"minimal", "abstract", "macro", "bokeh", "moody", "vibrant",
"pastel", "monochrome", "hdr", "longexposure", "silhouette"
]
return {
"required": {
"text": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
"min_upload_date": ("STRING", {"default": default_min_date, "multiline": False}),
"max_upload_date": ("STRING", {"default": default_max_date, "multiline": False}),
"orientation": (["any", "landscape", "portrait", "square", "panorama"], {"default": "any"}),
"styles": (styles, {"default": "any", "multiselect": True}),
"safe_search": (["Safe", "Moderate", "Restricted"], {"default": "Safe"}),
"per_page": ("INT", {"default": 100, "min": 1, "max": 500, "display": "number"}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32-1, "display": "number"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "image_url")
FUNCTION = "fetch_image"
CATEGORY = "image/loading"
def _parse_date(self, date_str):
"""Parse date string from YYYY-MM-DD to timestamp."""
try:
dt = datetime.strptime(date_str, "%Y-%m-%d")
return int(dt.timestamp())
except (ValueError, TypeError):
# Fallback to current date if parsing fails
return int(time.time())
def _process_styles(self, styles):
"""Process and validate styles parameter."""
# If 'any' is selected or no styles are specified, include all available styles
if not styles or "any" in styles:
return ""
return ",".join(styles)
def fetch_image(self, text, min_upload_date, max_upload_date, orientation, styles, safe_search, per_page, seed=0):
"""Fetch a random image from Flickr with retry logic and duplicate prevention.
Args:
seed: Random seed for reproducible results. If 0, uses system time.
"""
if seed > 0:
random.seed(seed)
max_retries = 3
retry_delay = 1 # seconds
for attempt in range(max_retries):
try:
# Convert safe search text to Flickr's numeric format
safe_search_map = {
"Safe": "1",
"Moderate": "2",
"Restricted": "3"
}
safe_search_value = safe_search_map.get(safe_search, "1") # Default to Safe
# Parse dates
min_date = self._parse_date(min_upload_date)
max_date = self._parse_date(max_upload_date)
# Ensure min_date is not greater than max_date
if min_date > max_date:
min_date, max_date = max_date, min_date
# Process styles
styles_value = self._process_styles(styles)
# Build search parameters - request multiple sizes to ensure we get at least one
params = {
"method": "flickr.photos.search",
"api_key": self.FLICKR_API_KEY,
"text": text,
"min_upload_date": str(min_date),
"max_upload_date": str(max_date),
"safe_search": safe_search_value,
"media": "photos",
"per_page": min(per_page, 100), # Reduced per_page since we'll try multiple pages
"format": "json",
"nojsoncallback": 1,
"extras": "url_b,url_c,url_z,url_m,url_n,url_o" # Multiple sizes for fallback, including original
}
# Handle orientation - if 'any', include all orientations
if orientation == "any":
params["orientation"] = "landscape,portrait,square,panorama"
else:
params["orientation"] = orientation
# Add styles parameter if we have any
if styles_value:
params["styles"] = styles_value
# Add empty license parameter as per API requirements
params["license"] = ""
# First, get total number of pages
search_response = self._flickr_api_call(params)
if not search_response or "photos" not in search_response:
raise Exception("Failed to fetch images from Flickr")
total_pages = min(int(search_response["photos"]["pages"]), 10) # Limit to 10 pages max
if total_pages == 0:
raise Exception("No images found matching the criteria")
# Try up to 3 random pages to find a new image
for _ in range(3):
# Get a random page
page = random.randint(1, total_pages)
params["page"] = page
page_response = self._flickr_api_call(params)
if not page_response or "photos" not in page_response:
continue
photos = page_response["photos"]["photo"]
if not photos:
continue
# Shuffle photos to get random order
random.shuffle(photos)
# Find a photo we haven't seen before
for photo in photos:
photo_hash = self._get_content_hash(photo["id"])
if photo_hash not in self._loaded_hashes:
# Try different sizes in order of preference (largest to smallest, with original as final fallback)
for size in ['url_b', 'url_c', 'url_z', 'url_m', 'url_n', 'url_o']:
image_url = photo.get(size)
if image_url:
break # Found a valid URL
if not image_url:
print(f"No suitable image URL found for photo {photo.get('id')}")
continue
# Download and process the image
image = self._download_and_process_image(image_url)
# Save to history before returning
self._save_to_history(photo_hash)
return (image, image_url)
# If we get here, we've tried multiple pages but couldn't find a new image
# Clear history if we've seen too many images
if len(self._loaded_hashes) > 1000:
self._loaded_hashes.clear()
if os.path.exists(self._history_file):
os.remove(self._history_file)
raise Exception("No new images found after multiple attempts")
except Exception as e:
if attempt == max_retries - 1: # Last attempt
print(f"Error in FlickrRandomImage after {max_retries} attempts: {str(e)}")
# Return a blank image on error
blank_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32)
return (blank_image, "")
time.sleep(retry_delay * (attempt + 1)) # Exponential backoff
def _flickr_api_call(self, params):
try:
# Log the full URL with query parameters for debugging
from urllib.parse import urlencode
base_url = "https://www.flickr.com/services/rest/"
full_url = f"{base_url}?{urlencode(params, doseq=True)}"
print(f"[FlickrRandomImage] Making API request to: {full_url}")
# First, get total number of pages
response = requests.get(base_url, params=params)
response.raise_for_status()
data = response.json()
return data
except requests.exceptions.RequestException as e:
print(f"Flickr API request failed: {e}")
return None
def _download_and_process_image(self, image_url):
"""Download and process the image into a tensor."""
try:
response = requests.get(image_url, timeout=10)
response.raise_for_status()
# Load image
image = Image.open(io.BytesIO(response.content)).convert("RGB")
# Resize if too large (to prevent memory issues)
max_size = 2048
if max(image.size) > max_size:
ratio = max_size / max(image.size)
new_size = tuple(int(dim * ratio) for dim in image.size)
image = image.resize(new_size, Image.Resampling.LANCZOS)
# Convert to tensor
image_np = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_np)[None,]
return image_tensor
except Exception as e:
print(f"Error processing image: {e}")
raise
class SilverWebImageLoader:
"""
A ComfyUI node that loads the largest image from a webpage URL.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
"exclude": ("STRING", {"default": "nav,logo,header,footer,banner,menu,thumbnail,thumb,icon,button,social,share,ad,ads,advert,sponsor,sponsored,widget,related,recommended,popular,trending,static,gif", "multiline": True}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32-1, "display": "number"}),
},
"optional": {
"timeout": ("INT", {"default": 10, "min": 1, "max": 60, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "load_image"
CATEGORY = "image/loading"
@classmethod
def _get_content_hash(cls, content):
"""Generate a hash of the image content for deduplication."""
import hashlib
return hashlib.sha256(content).hexdigest()
def _get_history_file(self):
"""Get the path to the image hash history file."""
import os
history_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'history')
os.makedirs(history_dir, exist_ok=True)
return os.path.join(history_dir, 'webimageloader_history.txt')
def _load_history(self):
"""Load the history of loaded image hashes from file."""
if not hasattr(self, '_loaded_hashes'):
self._loaded_hashes = set()
try:
history_file = self._get_history_file()
if os.path.exists(history_file):
with open(history_file, 'r') as f:
self._loaded_hashes = set(line.strip() for line in f if line.strip())
print(f"[Debug] Loaded {len(self._loaded_hashes)} image hashes from history")
except Exception as e:
print(f"Warning: Could not read image hash history: {e}")
return self._loaded_hashes
def _save_to_history(self, content_hash):
"""Save an image hash to the history file with rolling history (max 1000 entries)."""
if not hasattr(self, '_loaded_hashes'):
self._load_history()
if content_hash in self._loaded_hashes:
return
try:
history_file = self._get_history_file()
# Read existing hashes
hashes = set()
if os.path.exists(history_file):
with open(history_file, 'r') as f:
hashes = set(line.strip() for line in f if line.strip())
# Add the new hash
hashes.add(content_hash)
# If we have more than 1000 hashes, keep only the 900 most recent
if len(hashes) > 1000:
# Convert to list and keep the most recent 900
hashes = list(hashes)[-900:]
# Write back to file
with open(history_file, 'w') as f:
for h in hashes:
f.write(f"{h}\n")
# Update in-memory set
self._loaded_hashes = hashes
except Exception as e:
print(f"Warning: Could not update image hash history: {e}")
def __init__(self):
super().__init__()
self._load_history()
def _get_image_size_info(self, img_element, base_url):
"""Extract size information from an img element without downloading the image."""
size_info = {
'width': None,
'height': None,
'area': 0,
'url': None
}
# Get the image URL first
img_url = img_element.get('src', '')
if not img_url:
return None
# Normalize URL
if img_url.startswith('//'):
img_url = f"https:{img_url}" # Assume https for protocol-relative URLs
elif not img_url.startswith(('http://', 'https://')):
img_url = urljoin(base_url, img_url)
size_info['url'] = img_url
# 1. Check explicit width/height attributes
width = img_element.get('width')
height = img_element.get('height')
# 2. Check inline styles
if not all([width, height]):
style = img_element.get('style', '')
if style:
width_match = re.search(r'width\s*:\s*(\d+)px', style)
height_match = re.search(r'height\s*:\s*(\d+)px', style)
width = width or (width_match.group(1) if width_match else None)
height = height or (height_match.group(1) if height_match else None)
# 3. Check data attributes (common in lazy-loaded images)
if not all([width, height]):
width = img_element.get('data-width') or img_element.get('data-original-width')
height = img_element.get('data-height') or img_element.get('data-original-height')
# Convert to integers if we have both dimensions
try:
if width and height:
width = int(str(width).replace('px', '').strip())
height = int(str(height).replace('px', '').strip())
size_info.update({
'width': width,
'height': height,
'area': width * height
})
except (ValueError, TypeError):
pass
return size_info
def get_largest_image(self, url, exclude, timeout=10, seed=0, always_unique=False):
"""Fetch the largest image from the given URL with retry logic.
Args:
url: The URL of the webpage to fetch images from
exclude: Comma-separated keywords to exclude from image URLs
timeout: Request timeout in seconds
seed: Random seed for consistent selection (0 for random)
always_unique: Not used, kept for compatibility
Returns:
PIL.Image: The downloaded image
"""
import random
import time
from urllib.parse import urlparse, urljoin
# Set up session
session = requests.Session()
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
}
# Set random seed if provided
if seed > 0:
random.seed(seed)
# Get exclude keywords
exclude_keywords = {kw.strip().lower() for kw in exclude.split(',') if kw.strip()}
exclude_keywords.update({'sprite', 'thumbs'})
max_retries = 5
last_error = None
for attempt in range(max_retries):
try:
# Load history if not already loaded
self._load_history()
# Fetch the webpage
response = session.get(url, headers=headers, timeout=timeout)
response.raise_for_status()
# Parse HTML and find all img elements
soup = BeautifulSoup(response.text, 'html.parser')
img_elements = soup.find_all('img', src=True)
if not img_elements:
raise ValueError("No image elements found on the page")
# Extract and process image information
base_url = f"{urlparse(url).scheme}://{urlparse(url).netloc}"
image_infos = []
for img in img_elements:
# Get size info and URL
size_info = self._get_image_size_info(img, base_url)
if not size_info or not size_info['url']:
continue
# Skip excluded URLs
img_url_lower = size_info['url'].lower()
if any(kw in img_url_lower for kw in exclude_keywords):
continue
image_infos.append(size_info)
if not image_infos:
raise ValueError("No valid image URLs found after filtering")
# Sort by area (largest first), then by URL for consistency
image_infos.sort(key=lambda x: (-x['area'], x['url']))
# Try each image URL until one succeeds, starting with the largest
for img_info in image_infos:
try:
img_url = img_info['url']
img_response = session.get(img_url, headers=headers, timeout=timeout)
img_response.raise_for_status()
# Verify it's actually an image
if not img_response.headers.get('content-type', '').startswith('image/'):
print(f"Skipping non-image URL: {img_url}")
continue
image_content = img_response.content
content_hash = self._get_content_hash(image_content)
# Skip if we've already loaded this exact image
if content_hash in self._loaded_hashes:
print(f"Skipping duplicate image with hash: {content_hash}")
continue
# Add to history before returning
self._save_to_history(content_hash)
return Image.open(io.BytesIO(image_content)).convert("RGB")
except Exception as e:
print(f"Failed to download {img_url}: {e}")
continue
raise ValueError("Could not download any of the found images")
except Exception as e:
last_error = e
if attempt < max_retries - 1:
wait_time = 2 ** attempt # Exponential backoff
print(f"Attempt {attempt + 1} failed, retrying in {wait_time} seconds...")
time.sleep(wait_time)
continue
raise Exception(f"Failed after {max_retries} attempts. Last error: {str(e)}")
# If we get here, all attempts failed
if last_error:
raise last_error
raise Exception("Failed to load image")
def load_image(self, url, exclude="", seed=0, timeout=10):
"""Load an image from a URL with optional random seed for selection.
Args:
url: The URL to load images from
seed: Random seed for consistent selection (0 for random)
timeout: Request timeout in seconds
"""
# Validate URL
if not url or not isinstance(url, str) or not url.strip():
raise ValueError("Please provide a valid URL")
# Get the image (largest or selected by seed)
image = self.get_largest_image(url.strip(), exclude, timeout, seed)
# Convert to tensor
image_np = np.array(image).astype(np.float32) / 255.0
image_tensor = torch.from_numpy(image_np)[None,]
# Create a mask (all ones for RGB images)
mask = torch.ones((64, 64), dtype=torch.float32, device="cpu")
return (image_tensor, mask.unsqueeze(0))
class SilverUrlImageLoader:
"""
A ComfyUI node that loads an image directly from a URL with cache busting.
Tracks previously loaded images to avoid redundant downloads.
"""
_loaded_hashes = set()
_history_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'history', 'urlimageloader_history.txt')
@classmethod
def _load_history(cls):
"""Load the history of loaded image hashes from file."""
try:
os.makedirs(os.path.dirname(cls._history_file), exist_ok=True)
if os.path.exists(cls._history_file):
with open(cls._history_file, 'r') as f:
cls._loaded_hashes = set(line.strip() for line in f if line.strip())
except Exception as e:
print(f"Warning: Failed to load image history: {e}")
@classmethod
def _save_to_history(cls, content_hash):
"""Save a content hash to the history file with rolling history (max 1000 entries)."""
try:
# Ensure history directory exists
os.makedirs(os.path.dirname(cls._history_file), exist_ok=True)
# Read existing hashes if file exists
hashes = set()
if os.path.exists(cls._history_file):
with open(cls._history_file, 'r') as f:
hashes = set(line.strip() for line in f if line.strip())
# Add the new hash
hashes.add(content_hash)
# If we have more than 1000 hashes, keep only the 900 most recent
if len(hashes) > 1000:
# Convert to list and keep the most recent 900
hashes = list(hashes)[-900:]
# Write back to file
with open(cls._history_file, 'w') as f:
for h in hashes:
f.write(f"{h}\n")
# Update the in-memory set
cls._loaded_hashes = set(hashes)
except Exception as e:
print(f"Warning: Failed to save to image history: {e}")
def __init__(self):
super().__init__()
self._load_history()
@classmethod
def _get_content_hash(cls, content):
"""Generate a hash of the image content for deduplication."""
return hashlib.sha256(content).hexdigest()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 2**32-1, "display": "number"}),
},
"optional": {
"timeout": ("INT", {"default": 10, "min": 1, "max": 60, "step": 1}),
}
}
RETURN_TYPES = ("IMAGE", "MASK")
FUNCTION = "load_image"
CATEGORY = "image/loading"
def load_image(self, url, seed=0, timeout=10):
"""Load an image directly from a URL with cache busting.
Args:
url: The URL of the image to load
seed: Initial random seed for cache busting (0 = no cache busting)
timeout: Request timeout in seconds
Returns:
Tuple of (image_tensor, mask_tensor)
Raises:
Exception: If image loading fails after all retry attempts
"""
import requests
from io import BytesIO
from PIL import Image, ImageOps
import torch
import random
if not url:
raise comfy.model_management.InterruptProcessingException("Skipping workflow: URL is empty")
max_retries = 5
current_seed = seed
last_error = None
for attempt in range(max_retries):
try:
# Generate a new seed for each retry after the first attempt
if attempt > 0:
current_seed = random.randint(1, 2**32-1)
# Add cache-busting parameter if seed is provided
current_url = url
if current_seed > 0:
parsed = urlparse(current_url)
if parsed.query:
current_url = f"{current_url}&_cb={current_seed}"
else:
current_url = f"{current_url}?_cb={current_seed}"
# Set up session with cache-busting headers
session = requests.Session()
session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': '0'
})
# Download the image
response = session.get(current_url, timeout=timeout, stream=True)
response.raise_for_status()
content = response.content
content_hash = self._get_content_hash(content)
# Skip if we've already loaded this exact image
if content_hash in self._loaded_hashes:
raise Exception(f"Image with hash {content_hash} has already been loaded")
# Load image
img = Image.open(BytesIO(content)).convert("RGB")
# Convert to tensor
img = ImageOps.exif_transpose(img)
img = np.array(img).astype(np.float32) / 255.0
img = torch.from_numpy(img)[None,]
# Create a mask of ones (fully visible)
mask = torch.ones((64, 64), dtype=torch.float32, device="cpu")
# Add hash to loaded hashes and save to history
self._loaded_hashes.add(content_hash)
self._save_to_history(content_hash)
return (img, mask)
except Exception as e:
last_error = e
if attempt == max_retries - 1: # Last attempt
break
continue
# If we get here, all attempts failed
raise comfy.model_management.InterruptProcessingException(
f"Skipping workflow: Failed to load image from URL after {max_retries} attempts"
)
class SilverStringReplacer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"replacement_pattern": ("STRING", {"multiline": True}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "replace_strings"
CATEGORY = "silver_nodes/text"
def replace_strings(self, text, replacement_pattern):
if not replacement_pattern.strip():
return (text,)
# Create a mapping of search terms to their possible replacements
replacement_map = {}
for line in replacement_pattern.split('\n'):
parts = [p.strip() for p in line.split(':') if p.strip()]
if len(parts) >= 2:
replacement_map[parts[0]] = parts[1:]
if not replacement_map:
return (text,)
# Replace occurrences in the input text
result = text
for search_term, replacements in replacement_map.items():
if search_term in result:
replacement = random.choice(replacements)
result = result.replace(search_term, replacement)
return (result,)
NODE_CLASS_MAPPINGS = {
# ... your existing mappings
"SilverStringReplacer": SilverStringReplacer,
}
NODE_DISPLAY_NAME_MAPPINGS = {
# ... your existing mappings
"SilverStringReplacer": "Silver String Replacer",
}
NODE_CLASS_MAPPINGS = {
"SilverStringReplacer": SilverStringReplacer,
"SilverLoraModelLoader": SilverLoraModelLoader,
"SilverFolderImageLoader": SilverFolderImageLoader,
"SilverFolderVideoLoader": SilverFolderVideoLoader,
"SilverFolderFilePathLoader": SilverFolderFilePathLoader,
"SilverFileTextLoader": SilverFileTextLoader,
"SilverFlickrRandomImage": SilverFlickrRandomImage,
"SilverWebImageLoader": SilverWebImageLoader,
"SilverUrlImageLoader": SilverUrlImageLoader,
"SilverRandomFromList": SilverRandomFromList,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"SilverStringReplacer": "Silver String Replacer",
"SilverLoraModelLoader": "Silver Lora Model Loader",
"SilverFolderImageLoader": "Silver Folder Image Loader",
"SilverFolderVideoLoader": "Silver Folder Video Loader",
"SilverFolderFilePathLoader": "Silver Folder File Path Loader",
"SilverFileTextLoader": "Silver File Text Loader",
"SilverFlickrRandomImage": "Silver Flickr Random Image",
"SilverWebImageLoader": "Silver Load Largest Web Image",
"SilverUrlImageLoader": "Silver URL Image Loader",
"SilverRandomFromList": "Silver Random From List"
}