Files

2351 lines
96 KiB
Python
Executable File

import io
import os
import sys
import re
import random
import time
from datetime import datetime
# Import the stock random image node
from .silver_random_image_node import SilverStockRandomImage
target_dir = sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..')))
import folder_paths
import torch
import numpy as np
import cv2
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
import comfy.sd
import comfy.utils
import scipy.io.wavfile
from pocket_tts import TTSModel
from collections.abc import Mapping
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": {
"model": ("MODEL",),
"clip": ("CLIP",),
"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}),
"strength_model": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"strength_clip": ("FLOAT", {"default": 1.0, "min": -100.0, "max": 100.0, "step": 0.01}),
"fetch_lora_info": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = (any_return, "STRING", "STRING", any_return, any_return, "MODEL", "CLIP")
RETURN_NAMES = ("COMBO", "STRING", "lora_tags", "example_prompts", "example_images", "MODEL", "CLIP")
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, model=None, clip=None, strength_model=1.0, strength_clip=1.0, 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, "", [""], [""], model, clip)}
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)
model_out = model
clip_out = clip
if strength_model != 0 or strength_clip != 0:
if model is not None or clip is not None:
lora_path = folder_paths.get_full_path("loras", next_model)
if lora_path is not None and os.path.exists(lora_path):
try:
lora_state_dict, lora_metadata = comfy.utils.load_torch_file(lora_path, safe_load=True, return_metadata=True)
model_out, clip_out = comfy.sd.load_lora_for_models(model, clip, lora_state_dict, strength_model, strength_clip, lora_metadata=lora_metadata)
except Exception as e:
print(f"Error loading lora {next_model}: {str(e)}")
model_out = model
clip_out = clip
return {"ui": {"next_model": [next_model], "next_repeat": [next_repeat]},
"result": (next_model, next_model, lora_tags, example_prompts, example_images, model_out, clip_out)}
class SilverFolderImageLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"folder_path": ("STRING", {"default": "",
"tooltip": "Folder containing the "
"images. Relative paths "
"resolve against the "
"ComfyUI base dir, so use "
"'output' for the ComfyUI "
"output folder (portable, "
"safe to share) or an "
"absolute path."}),
"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"}),
},
"optional": {
"load_companion_text": (["disable", "enable"], {
"default": "disable",
"tooltip": "enable = for every loaded image also read the "
"companion text file with the SAME basename + "
".txt (in the same folder) and output its "
"contents as the prompts STRING_LIST, aligned "
"1:1 with the loaded images. Missing files "
"become blank entries."}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "INT", "STRING_LIST", "STRING")
RETURN_NAMES = ("images", "filenames", "current_index", "prompts", "folder_path")
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_companion_text: str = 'disable'):
"""Load images from a specified folder with flexible index management."""
# Resolve relative paths against the ComfyUI base dir so portable
# values like "output" (ComfyUI's output folder) work when shared.
if not os.path.isabs(folder_path):
folder_path = os.path.join(folder_paths.base_path, folder_path)
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}")
# current_index is a BATCH index (which chunk of batch_size images
# to load), so the starting file offset is current_index * batch_size.
# Clamp to the last valid batch (allowing a partial final batch).
max_batch_idx = max(0, (len(image_files) - 1) // batch_size)
current_idx = max(0, min(current_index, max_batch_idx))
start_file = current_idx * batch_size
# Load images
loaded_images = []
filenames = []
for i in range(batch_size):
idx = (start_file + 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")
# Resize all images to match the first image's dimensions using center-crop and resize
target_h, target_w = loaded_images[0].shape[1], loaded_images[0].shape[2]
resized_images = []
for img in loaded_images:
if img.shape[1] == target_h and img.shape[2] == target_w:
resized_images.append(img)
else:
pil_img = Image.fromarray((img[0].numpy() * 255).astype(np.uint8))
src_w, src_h = pil_img.size
target_ratio = target_w / target_h
src_ratio = src_w / src_h
if src_ratio > target_ratio:
new_w = int(src_h * target_ratio)
left = (src_w - new_w) // 2
pil_img = pil_img.crop((left, 0, left + new_w, src_h))
else:
new_h = int(src_w / target_ratio)
top = (src_h - new_h) // 2
pil_img = pil_img.crop((0, top, src_w, top + new_h))
pil_img = pil_img.resize((target_w, target_h), Image.LANCZOS)
resized_images.append(self._process_image(pil_img))
images = torch.cat(resized_images, dim=0)
# Update index based on action. All references are batch indexes,
# so incrementing moves to the next batch (advances batch_size files).
if action == "increment":
next_index = (current_idx + 1) % (max_batch_idx + 1)
elif action == "decrement":
next_index = (current_idx - 1) if current_idx > 0 else max_batch_idx
elif action == "increment_wrap":
next_index = (current_idx + 1) % (max_batch_idx + 1)
else: # "fixed" or "reset"
next_index = current_idx
self.current_index = current_idx
self.next_index = next_index
# Companion text: read <basename>.txt next to each loaded image
# and emit its contents as the prompts STRING_LIST (1:1 with images).
prompts = []
if load_companion_text == "enable":
for fname in filenames:
txt_path = os.path.join(
folder_path, os.path.splitext(fname)[0] + ".txt")
try:
with open(txt_path, "r", encoding="utf-8",
errors="replace") as f:
prompts.append(f.read().strip())
except Exception as e:
print(f"[FolderImageLoader] No companion text for "
f"{fname}: {e}")
prompts.append("")
else:
prompts = [""] * len(filenames)
return {"ui": {"next_index": [next_index]},
"result": (images, filenames, next_index, prompts, folder_path)}
class LazyAudioMap(Mapping):
def __init__(self, file, start_time, duration):
self.file = file
self.start_time = start_time
self.duration = duration
self._dict = None
self.ffmpeg_path = "ffmpeg" # Make sure ffmpeg is in your PATH
def _get_audio(self):
import subprocess
import re
import torch
from torch import Tensor
from typing import Dict, Any
ENCODE_ARGS = ('utf-8', 'ignore')
args = [self.ffmpeg_path, "-i", self.file]
# Use higher precision for start time (supports milliseconds)
if self.start_time > 0:
# Format as seconds with 3 decimal places (milliseconds precision)
start_time = f"{self.start_time:.3f}"
args += ["-ss", start_time]
# Use higher precision for duration
if self.duration > 0:
# Format as seconds with 3 decimal places (milliseconds precision)
duration = f"{self.duration:.3f}"
args += ["-t", duration]
try:
print(f"[LazyAudioMap] Extracting audio with ffmpeg: {' '.join(args)}")
res = subprocess.run(
args + ["-f", "f32le", "-"],
capture_output=True,
check=True
)
# Convert the raw audio bytes to a PyTorch tensor
audio = torch.frombuffer(bytearray(res.stdout), dtype=torch.float32)
# Parse the audio metadata from stderr
stderr = res.stderr.decode(*ENCODE_ARGS)
match = re.search(r', (\d+) Hz, (\w+), ', stderr)
if match:
sample_rate = int(match.group(1))
channels = {"mono": 1, "stereo": 2}.get(match.group(2).lower(), 2)
else:
print("[LazyAudioMap] Could not determine audio format, using defaults")
sample_rate = 44100
channels = 2
# Reshape the audio tensor to match expected format: [channels, samples]
audio = audio.reshape((-1, channels)).transpose(0, 1).unsqueeze(0)
return {
'waveform': audio,
'sample_rate': sample_rate
}
except subprocess.CalledProcessError as e:
print(f"[LazyAudioMap] Error extracting audio: {e.stderr.decode(*ENCODE_ARGS)}")
return {'waveform': None, 'sample_rate': 44100}
except Exception as e:
print(f"[LazyAudioMap] Unexpected error: {str(e)}")
return {'waveform': None, 'sample_rate': 44100}
def __getitem__(self, key):
if self._dict is None:
self._dict = self._get_audio()
return self._dict.get(key, None)
def __iter__(self):
if self._dict is None:
self._dict = self._get_audio()
return iter(self._dict)
def __len__(self):
if self._dict is None:
self._dict = self._get_audio()
return len(self._dict)
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"
}),
"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', '.webm']
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 from video file with optional frame capping and frame rate control."""
try:
import cv2
# 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
# Create lazy audio map
duration = max_frames / fps if max_frames > 0 else 0
print(f"[_extract_frames] Created LazyAudioMap with duration: {duration:.2f}s")
audio = LazyAudioMap(video_path, start_frame / fps if start_frame > 0 else 0, duration)
print(f"[_extract_frames] Audio object type: {type(audio)}")
# 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
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, current_index: int = 0,
action: str = 'fixed', sort_by: str = 'name', sort_order: str = 'ascending'):
"""Load video frames 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
all_frames = []
filenames = []
idx = (current_idx) % 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)
filenames.append(video_path)
except Exception as e:
print(f"[FolderVideoLoader] Error processing video {video_path}: {e}")
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)
# 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, 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 an API key provided by the user via the node input.
"""
# Flickr API configuration
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"}),
"api_key": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
}
}
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, api_key=""):
"""Fetch a random image from Flickr with retry logic and duplicate prevention.
Args:
seed: Random seed for reproducible results. If 0, uses system time.
api_key: User-provided Flickr API key (required; the node will not work without it).
"""
if not api_key.strip():
raise Exception("Flickr API key is required. Please provide your Flickr API key in the node's 'api_key' input.")
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": 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 and compile regex pattern
exclude_keywords = {kw.strip().lower() for kw in exclude.split(',') if kw.strip()}
exclude_keywords.update({'sprite', 'thumbs'})
# Pre-compile pattern: match keyword surrounded by path delimiters or string boundaries
if exclude_keywords:
exclude_pattern = re.compile(
r'(?:^|[/._\-])(?:' + '|'.join(re.escape(kw) for kw in exclude_keywords) + r')(?:$|[/._\-])'
)
else:
exclude_pattern = None
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 (match whole path segments)
if exclude_pattern and exclude_pattern.search(size_info['url'].lower()):
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']
# Add Referer header for hotlink protection bypass
img_headers = headers.copy()
img_headers['Referer'] = url
# Retry with exponential backoff for connection issues
for img_attempt in range(3):
try:
img_response = session.get(img_url, headers=img_headers, timeout=timeout)
img_response.raise_for_status()
break
except (requests.exceptions.ConnectionError, requests.exceptions.RemoteDisconnected) as ce:
if img_attempt < 2:
time.sleep(2 ** img_attempt) # 1s, 2s, 4s
continue
raise
# 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)
img = Image.open(io.BytesIO(image_content))
# Handle WEBP and other modes with transparency by converting via RGBA
if img.mode in ('RGBA', 'P', 'LA', 'L'):
img = img.convert('RGBA').convert('RGB')
else:
img = img.convert('RGB')
return img
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 SilverCivitAILoader:
"""
A ComfyUI node that fetches a random image from CivitAI's public API.
"""
def __init__(self):
self.history_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'history')
os.makedirs(self.history_dir, exist_ok=True)
self.history_file = os.path.join(self.history_dir, 'civitai_history.txt')
self.loaded_hashes = set()
self._load_history()
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
"nsfw": (["none", "soft", "mature", "x"], {"default": "none"}),
"limit": ("INT", {"default": 100, "min": 1, "max": 1000}),
"download": (["disable", "enable"], {"default": "disable"}),
},
"optional": {
"force_refresh": (["disable", "enable"], {"default": "disable"}),
}
}
RETURN_TYPES = ("IMAGE", "STRING", "STRING")
RETURN_NAMES = ("image", "image_url", "prompt")
FUNCTION = "fetch_image"
CATEGORY = "image/loading"
def _load_history(self):
"""Load the history of loaded image hashes from file."""
if os.path.exists(self.history_file):
with open(self.history_file, 'r') as f:
self.loaded_hashes = set(line.strip() for line in f if line.strip())
def _save_to_history(self, content_hash):
"""Save a content hash to the history file with rolling history (max 1000 entries)."""
# Keep only the most recent 1000 entries to prevent the file from growing too large
history = list(self.loaded_hashes)
history = history[-999:] # Keep only the most recent 999 entries
with open(self.history_file, 'w') as f:
for h in history:
f.write(f"{h}\n")
def _get_random_image(self, limit=100, nsfw=None, max_retries=3):
"""Fetch a random image from CivitAI's API with retry logic."""
base_url = "https://civitai.com/api/v1/images"
params = {
'limit': min(limit, 100), # Max 100 items per page
'nsfw': nsfw if nsfw != 'none' else None
}
for attempt in range(max_retries):
try:
# Get the first page to understand pagination
response = requests.get(base_url, params=params)
response.raise_for_status()
data = response.json()
# Get total number of items and pages
total_items = data.get('metadata', {}).get('totalItems', 0)
if not total_items:
# If we can't get total items, use the items from the first page
items = data.get('items', [])
if not items:
continue
return random.choice(items)
# Calculate total pages
page_size = data.get('metadata', {}).get('pageSize', 100)
total_pages = (total_items + page_size - 1) // page_size
# Get a random page
random_page = random.randint(1, min(total_pages, 100)) # Limit to first 100 pages
params['page'] = random_page
# Get the random page
response = requests.get(base_url, params=params)
response.raise_for_status()
data = response.json()
# Select a random image from the page
items = data.get('items', [])
if not items:
continue
return random.choice(items)
except Exception as e:
print(f"Attempt {attempt + 1} failed: {str(e)}")
if attempt == max_retries - 1:
raise Exception(f"Failed to fetch image from CivitAI after {max_retries} attempts: {str(e)}")
time.sleep(1) # Wait a bit before retrying
def _download_image(self, url):
"""Download an image from a URL and return it as a PIL Image."""
response = requests.get(url, stream=True)
response.raise_for_status()
return Image.open(io.BytesIO(response.content)).convert("RGB")
def _process_image(self, img):
"""Process a PIL image into the format expected by ComfyUI."""
img = ImageOps.exif_transpose(img)
image = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(image)[None,]
def fetch_image(self, seed, nsfw, limit, download, force_refresh="disable"):
"""
Fetch a random image from CivitAI with the given parameters.
Args:
seed: Random seed for reproducibility (0 for random)
nsfw: NSFW filter level
limit: Maximum number of items to consider
download: Whether to download the image
force_refresh: Whether to force a refresh of the image
Returns:
Tuple of (image_tensor, image_url, prompt)
"""
if seed > 0:
random.seed(seed)
# Get a random image from CivitAI
image_data = self._get_random_image(limit=limit, nsfw=nsfw)
if not image_data:
raise comfy.model_management.InterruptProcessingException(f"ERROR: No images found or failed to fetch image from CivitAI")
# Get the image URL (use the largest available size)
image_url = image_data.get('url')
if not image_url:
raise comfy.model_management.InterruptProcessingException(f"ERROR: No image URL found in the response")
# Generate a unique hash for this image
content_hash = hashlib.md5(image_url.encode('utf-8')).hexdigest()
# Check if we've already loaded this image
if content_hash in self.loaded_hashes and force_refresh == "disable":
raise comfy.model_management.InterruptProcessingException(f"Image with hash {content_hash} has already been loaded. Enable 'force_refresh' to load it again.")
# Download and process the image
try:
img = self._download_image(image_url)
image_tensor = self._process_image(img)
# Add to history
self.loaded_hashes.add(content_hash)
self._save_to_history(content_hash)
# Get the prompt if available, with proper error handling
prompt = ''
try:
meta = image_data.get('meta')
if meta and isinstance(meta, dict):
prompt = meta.get('prompt', '')
except Exception as meta_error:
print(f"Warning: Could not extract prompt: {str(meta_error)}")
raise comfy.model_management.InterruptProcessingException(error_msg)
return (image_tensor, image_url, prompt)
except Exception as e:
error_msg = f"Skipping workflow: Failed to load image from CivitAI: {str(e)}"
print(error_msg)
raise comfy.model_management.InterruptProcessingException(error_msg)
class SilverApiTextLoader:
"""
A ComfyUI node that fetches text from an API URL.
Can return the entire response or a specific JSON tag.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
"json_tag": ("STRING", {"default": "", "multiline": False, "dynamicPrompts": False}),
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "fetch_api_text"
CATEGORY = "silver_nodes/loaders"
def fetch_api_text(self, url, json_tag, seed):
if not url:
return ("",)
try:
headers = {
"User-Agent": "ComfyUI-SilverNodes/1.0"
}
response = requests.get(url, headers=headers, timeout=10)
response.raise_for_status()
# If no tag is specified, return the raw text
if not json_tag.strip():
return (response.text,)
try:
data = response.json()
if isinstance(data, (dict, list)):
# Support nested tags like "data.prompt" or array indices like "0.text"
value = data
for part in json_tag.split('.'):
if isinstance(value, dict):
value = value.get(part)
elif isinstance(value, list):
try:
idx = int(part)
value = value[idx]
except (ValueError, IndexError):
value = None
break
else:
value = None
break
if value is not None:
return (str(value),)
else:
print(f"[SilverApiTextLoader] Tag '{json_tag}' not found in JSON response")
return (response.text,)
else:
print(f"[SilverApiTextLoader] Response is not a JSON object/list")
return (response.text,)
except ValueError:
# Not JSON, but a tag was requested
print(f"[SilverApiTextLoader] Response is not valid JSON, but tag '{json_tag}' was requested")
return (response.text,)
except Exception as e:
print(f"[SilverApiTextLoader] Error fetching from {url}: {e}")
return (f"Error: {str(e)}",)
class SilverStringReplacer:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"replacement_pattern": ("STRING", {"multiline": True}),
"case_sensitive": ("BOOLEAN", {"default": False}),
"match_mode": (["whole_word", "content", "word_edge"],),
}
}
RETURN_TYPES = ("STRING",)
FUNCTION = "replace_strings"
CATEGORY = "silver_nodes/text"
def replace_strings(self, text, replacement_pattern, case_sensitive=False, match_mode="whole_word"):
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'):
if not line.strip():
continue
# Split the line by colons
parts = line.split(':')
if len(parts) < 2: # Skip lines without at least one colon
continue
# First part is the search term (keep all spaces as-is)
search_term = parts[0]
# All remaining parts are possible replacements (keep empty strings for blank replacements)
replacements = parts[1:]
if search_term and replacements: # Only add if we have both search term and at least one replacement
replacement_map[search_term] = replacements
if not replacement_map:
return (text,)
# Replace occurrences in the input text
flags = 0 if case_sensitive else re.IGNORECASE
result = text
for search_term, replacements in replacement_map.items():
replacement = random.choice(replacements)
pattern = re.escape(search_term)
if match_mode == "whole_word":
pattern = r'\b' + pattern + r'\b'
elif match_mode == "word_edge":
pattern = r'(?<!\w)' + pattern + r'|' + pattern + r'(?!\w)'
result = re.sub(pattern, replacement, result, flags=flags)
return (result,)
class SilverPocketTTS:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True}),
"voice_file_path": ("STRING", {"default": ""}),
"output_filename": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("audio_path",)
FUNCTION = "generate_speech"
CATEGORY = "silver"
def generate_speech(self, text, voice_file_path, output_filename):
import tempfile
import os
import subprocess
import sys
import time
import torch
from pocket_tts import TTSModel
import scipy.io.wavfile
import numpy as np
tts_model = TTSModel.load_model()
text_to_speak = text
# Convert voice file to PCM format if necessary (pocket_tts uses wave module which doesn't support IEEE float WAV)
rate, data = scipy.io.wavfile.read(voice_file_path)
temp_path = None
if data.dtype in [np.float32, np.float64]:
data = (data * 32767).astype(np.int16)
temp_file = tempfile.NamedTemporaryFile(suffix='.wav', delete=False)
temp_path = temp_file.name
temp_file.close()
scipy.io.wavfile.write(temp_path, rate, data)
voice_file_path_to_use = temp_path
else:
voice_file_path_to_use = voice_file_path
voice_state = tts_model.get_state_for_audio_prompt(voice_file_path_to_use)
with torch.inference_mode(False):
audio_tensor = tts_model.generate_audio(voice_state, text_to_speak)
audio_tensor = audio_tensor.clone().detach().cpu()
audio_np = audio_tensor.numpy()
save_path = os.path.join(os.getcwd(), output_filename if output_filename else "generated_speech.wav")
scipy.io.wavfile.write(save_path, tts_model.sample_rate, audio_np)
output = f"{save_path}"
# Clean up temp file if created
if temp_path:
os.unlink(temp_path)
# Wait 1 second
time.sleep(1)
return (output,)
NODE_CLASS_MAPPINGS = {
"SilverStringReplacer": SilverStringReplacer,
"SilverLoraModelLoader": SilverLoraModelLoader,
"SilverFolderImageLoader": SilverFolderImageLoader,
"SilverFolderVideoLoader": SilverFolderVideoLoader,
"SilverFolderFilePathLoader": SilverFolderFilePathLoader,
"SilverFileTextLoader": SilverFileTextLoader,
"SilverFlickrRandomImage": SilverFlickrRandomImage,
"SilverWebImageLoader": SilverWebImageLoader,
"SilverUrlImageLoader": SilverUrlImageLoader,
"SilverRandomFromList": SilverRandomFromList,
"SilverCivitAILoader": SilverCivitAILoader,
"SilverApiTextLoader": SilverApiTextLoader,
"SilverPocketTTS": SilverPocketTTS,
"SilverStockRandomImage": SilverStockRandomImage
}
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",
"SilverCivitAILoader": "Silver CivitAI Random Image",
"SilverApiTextLoader": "Silver API Text Loader",
"SilverPocketTTS": "Silver Pocket TTS",
"SilverStockRandomImage": "Silver Stock Random Image"
}