Files
shootthesound-ComfyUI-Seque…/sequential_loader.py
T

275 lines
9.6 KiB
Python

"""Sequential Image Loader — load PNGs from a folder, one per queue.
Paste a directory path into the node; it scans for PNG files, sorts them
in natural order, and on each Queue press hands back the *next* image in
the sequence. A "Reset to start" button (frontend) rewinds to the first
file. Index advancing is driven by the JS queue hook; this module just
resolves index → file → tensors and exposes a listing route the UI uses
to show progress.
"""
import os
import numpy as np
import torch
from PIL import Image, ImageOps
# --- Shared listing helpers -----------------------------------------------
# Selectable filetypes -> the extensions each option matches. "jpg" and
# "jpeg" both match .jpg/.jpeg (same format) so either choice behaves as
# users expect. "all" matches every supported image extension.
FILETYPE_EXTS = {
"png": (".png",),
"jpg": (".jpg", ".jpeg"),
"jpeg": (".jpg", ".jpeg"),
"webp": (".webp",),
"bmp": (".bmp",),
"gif": (".gif",),
"tiff": (".tif", ".tiff"),
}
FILETYPE_CHOICES = ["png", "jpg", "jpeg", "webp", "bmp", "gif", "tiff", "all"]
_ALL_EXTS = tuple(sorted({e for exts in FILETYPE_EXTS.values() for e in exts}))
def _exts_for(filetype):
"""Resolve a filetype choice to the tuple of extensions to match."""
if not filetype or filetype == "all":
return _ALL_EXTS
return FILETYPE_EXTS.get(filetype, (".png",))
def _natural_key(name):
"""Sort key so 'img2.png' precedes 'img10.png' (numeric-aware)."""
import re
parts = re.split(r"(\d+)", name.lower())
return [int(p) if p.isdigit() else p for p in parts]
def list_images(directory, include_subfolders=False, filetype="png",
reverse=False):
"""Return image file paths under `directory`, natural-sorted.
`filetype` is one of FILETYPE_CHOICES (or "all"). With
include_subfolders the walk is recursive and entries are sorted by
their path relative to `directory` so ordering is stable/intuitive.
`reverse` flips the final order (so the sequence runs last->first).
Returns [] for a missing/empty/invalid directory (never raises).
"""
directory = (directory or "").strip().strip('"').strip("'")
if not directory or not os.path.isdir(directory):
return []
exts = _exts_for(filetype)
def _matches(name):
return name.lower().endswith(exts)
found = []
if include_subfolders:
for root, _dirs, files in os.walk(directory):
for f in files:
if _matches(f):
found.append(os.path.join(root, f))
found.sort(key=lambda p: _natural_key(os.path.relpath(p, directory)))
else:
try:
names = os.listdir(directory)
except OSError:
return []
names = [n for n in names if _matches(n)
and os.path.isfile(os.path.join(directory, n))]
names.sort(key=_natural_key)
found = [os.path.join(directory, n) for n in names]
if reverse:
found.reverse()
return found
def _load_image_tensors(path):
"""Mirror ComfyUI's LoadImage: -> (IMAGE [1,H,W,3], MASK [1,H,W])."""
img = Image.open(path)
img = ImageOps.exif_transpose(img)
rgb = img.convert("RGB")
arr = np.array(rgb).astype(np.float32) / 255.0
image = torch.from_numpy(arr)[None,]
if "A" in img.getbands():
alpha = np.array(img.getchannel("A")).astype(np.float32) / 255.0
mask = 1.0 - torch.from_numpy(alpha)
else:
mask = torch.zeros((rgb.height, rgb.width), dtype=torch.float32)
mask = mask[None,]
return image, mask
# --- Node ------------------------------------------------------------------
class SequentialImageLoader:
"""Load PNGs from a folder in order, advancing one image per queue."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"directory": ("STRING", {
"default": "",
"multiline": False,
"placeholder": "C:\\path\\to\\folder",
"tooltip": "Folder to scan for .png files.",
}),
"include_subfolders": ("BOOLEAN", {"default": False}),
# Mechanical: driven by the JS queue hook / Reset button.
"index": ("INT", {
"default": 0, "min": 0, "max": 0x7FFFFFFF, "step": 1,
}),
# Declared LAST (newest widgets) so older saved workflows keep
# their positional widget order; absent -> defaults below.
"filetype": (FILETYPE_CHOICES, {"default": "png"}),
"reverse": ("BOOLEAN", {
"default": False,
"label_on": "reverse (last→first)",
"label_off": "forward (first→last)",
}),
}
}
RETURN_TYPES = ("IMAGE", "MASK", "STRING", "INT", "INT")
RETURN_NAMES = ("image", "mask", "filename", "index", "total")
FUNCTION = "load"
CATEGORY = "image"
@classmethod
def IS_CHANGED(cls, directory, include_subfolders, index, filetype="png",
reverse=False):
# index changes every queue (JS hook), so this always reruns; keep
# directory in the key too so editing the path forces a fresh scan.
return f"{directory}|{include_subfolders}|{filetype}|{reverse}|{index}"
def load(self, directory, include_subfolders, index, filetype="png",
reverse=False):
files = list_images(directory, include_subfolders, filetype, reverse)
total = len(files)
if total == 0:
label = "image" if filetype == "all" else filetype.upper()
raise ValueError(
f"[Sequential Image Loader] No {label} files found in: "
f"{directory!r}"
)
pos = int(index) % total # wrap around at the end
path = files[pos]
filename = os.path.basename(path)
image, mask = _load_image_tensors(path)
return {
"ui": {"seqloader": [{
"filename": filename,
"index": pos,
"total": total,
}]},
"result": (image, mask, filename, pos, total),
}
# --- Directory browsing (server-side folder picker) -----------------------
def _list_roots():
"""Top-level entries: drive letters on Windows, '/' + home elsewhere."""
roots = []
if os.name == "nt":
import string
for c in string.ascii_uppercase:
d = f"{c}:\\"
if os.path.exists(d):
roots.append({"name": d, "path": d})
else:
roots.append({"name": "/", "path": "/"})
home = os.path.expanduser("~")
if os.path.isdir(home):
roots.append({"name": f"~ ({home})", "path": home})
return roots
def browse_dir(path, filetype="png"):
"""Resolve a folder for the picker UI.
Returns the current path, its parent ("" => the roots list), the
immediate subdirectories, and how many matching images live here.
An empty/invalid path returns the roots listing.
"""
path = (path or "").strip().strip('"').strip("'")
if not path or not os.path.isdir(path):
return {"cwd": "", "parent": None, "is_roots": True,
"dirs": _list_roots(), "image_count": 0}
norm = os.path.normpath(path)
parent = os.path.dirname(norm)
if parent == norm: # at a drive root -> "up" goes to the roots list
parent = ""
dirs = []
try:
for name in os.listdir(norm):
full = os.path.join(norm, name)
try:
if os.path.isdir(full):
dirs.append({"name": name, "path": full})
except OSError:
continue
except OSError:
pass
dirs.sort(key=lambda d: _natural_key(d["name"]))
image_count = len(list_images(norm, False, filetype, False))
return {"cwd": norm, "parent": parent, "is_roots": False,
"dirs": dirs, "image_count": image_count}
# --- Server routes (UI progress display + folder picker) ------------------
try:
from server import PromptServer
from aiohttp import web
@PromptServer.instance.routes.post("/seqloader/list")
async def _seqloader_list(request):
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
directory = (data or {}).get("directory", "")
include_subfolders = bool((data or {}).get("include_subfolders", False))
filetype = (data or {}).get("filetype", "png")
reverse = bool((data or {}).get("reverse", False))
files = list_images(directory, include_subfolders, filetype, reverse)
return web.json_response({
"count": len(files),
"files": [os.path.basename(p) for p in files],
})
@PromptServer.instance.routes.post("/seqloader/browse")
async def _seqloader_browse(request):
try:
data = await request.json()
except Exception:
return web.json_response({"error": "invalid JSON"}, status=400)
path = (data or {}).get("path", "")
filetype = (data or {}).get("filetype", "png")
try:
return web.json_response(browse_dir(path, filetype))
except Exception as e:
return web.json_response({"error": str(e)}, status=500)
except Exception as _e: # pragma: no cover - server optional
print(f"[Sequential Image Loader] routes not registered: {_e}")
NODE_CLASS_MAPPINGS = {"SequentialImageLoader": SequentialImageLoader}
NODE_DISPLAY_NAME_MAPPINGS = {
"SequentialImageLoader": "Sequential Image Loader (folder)",
}