fix: batch collapse, memory blowup, resize rounding, and 8 more Should-Fix issues from code review
- Reject use_original_name=False + use_counter=False (all files collapsed to one output name) - Add preview_limit to cap in-memory preview tensors (was holding entire batch as float32) - Recover from per-file failures instead of aborting the whole batch - Fix _calc_new_size overshoot past longest_side and off-by-one truncation - Add upscale_if_smaller to SmartBatchResize to match SmartResize - Clip pixel values before uint8 cast (was wrapping on overflow) - Support CUDA tensors and RGBA/grayscale images in SmartResize - Handle empty batches; derive width/height from the actual output tensor - Require POST + loopback/origin check on the browse_folder endpoint (CSRF) - Add subprocess timeout to the folder picker dialog - Isolate the browse_folder route registration so import failures don't take down all nodes - Validate folder_custom against path traversal and self-overwrite - Replace IS_CHANGED=NaN with a folder-listing hash so unrelated graph runs can still cache
This commit is contained in:
+1
Submodule .claude/worktrees/agent-a60baca8993c03f03 added at e7caae4a3e
@@ -2,6 +2,7 @@
|
||||
project-craftkit.md
|
||||
how-to-publish-comfyui-node.md
|
||||
fable-review-2026-07-02.md
|
||||
opus-review-craftkit-2026-07-02.md
|
||||
|
||||
# Local design source files
|
||||
assets/*.ai
|
||||
|
||||
+39
-22
@@ -11,19 +11,29 @@ NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES, **D_NAMES, **E_NA
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
try:
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
@PromptServer.instance.routes.get("/craftkit/browse_folder")
|
||||
async def browse_folder(request):
|
||||
import subprocess, sys
|
||||
async def browse_folder(request):
|
||||
import subprocess, sys
|
||||
|
||||
def _open_dialog():
|
||||
if sys.platform != "win32":
|
||||
return ""
|
||||
ps = r"""
|
||||
if request.remote not in ("127.0.0.1", "::1"):
|
||||
return web.json_response({"ok": False, "error": "forbidden"}, status=403)
|
||||
|
||||
origin = request.headers.get("Origin")
|
||||
if origin is not None:
|
||||
host = request.headers.get("Host", "")
|
||||
origin_host = origin.split("://", 1)[-1]
|
||||
if origin_host != host:
|
||||
return web.json_response({"ok": False, "error": "forbidden"}, status=403)
|
||||
|
||||
def _open_dialog():
|
||||
if sys.platform != "win32":
|
||||
return ""
|
||||
ps = r"""
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
Add-Type @"
|
||||
using System;
|
||||
@@ -107,16 +117,23 @@ $o.Add_Shown({
|
||||
$o.ShowDialog() | Out-Null
|
||||
$r
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
folder = await loop.run_in_executor(pool, _open_dialog)
|
||||
loop = asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
folder = await loop.run_in_executor(pool, _open_dialog)
|
||||
|
||||
if folder and __import__("os").path.isdir(folder):
|
||||
return web.json_response({"ok": True, "path": folder})
|
||||
return web.json_response({"ok": False, "cancelled": True})
|
||||
if folder and __import__("os").path.isdir(folder):
|
||||
return web.json_response({"ok": True, "path": folder})
|
||||
return web.json_response({"ok": False, "cancelled": True})
|
||||
|
||||
PromptServer.instance.routes.post("/craftkit/browse_folder")(browse_folder)
|
||||
except Exception as e:
|
||||
print(f"[CraftKit] Browse folder endpoint unavailable ({e}); paste input folder paths manually.")
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
1024,
|
||||
8,
|
||||
"lanczos",
|
||||
true,
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
@@ -41,7 +42,8 @@
|
||||
false,
|
||||
"resized",
|
||||
true,
|
||||
"_"
|
||||
"_",
|
||||
32
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,7 +11,7 @@ app.registerExtension({
|
||||
|
||||
const btn = node.addWidget("button", "📁 Browse folder", null, async () => {
|
||||
try {
|
||||
const res = await fetch("/craftkit/browse_folder");
|
||||
const res = await fetch("/craftkit/browse_folder", { method: "POST" });
|
||||
const data = await res.json();
|
||||
if (data.ok && data.path) {
|
||||
folderWidget.value = data.path;
|
||||
|
||||
+101
-37
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
from PIL import Image as PILImage, ImageOps
|
||||
@@ -15,17 +16,33 @@ INTERP_MAP = {
|
||||
SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"}
|
||||
|
||||
|
||||
def _calc_new_size(w, h, longest_side, multiple_of):
|
||||
def _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller):
|
||||
longest = max(w, h)
|
||||
if longest <= longest_side:
|
||||
scaled = longest > longest_side or upscale_if_smaller
|
||||
if not scaled:
|
||||
new_w, new_h = w, h
|
||||
else:
|
||||
scale = longest_side / longest
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
new_w = round(w * scale)
|
||||
new_h = round(h * scale)
|
||||
if multiple_of > 1:
|
||||
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of)
|
||||
raw_w, raw_h = new_w, new_h
|
||||
new_w = max(multiple_of, round(raw_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(raw_h / multiple_of) * multiple_of)
|
||||
if scaled and max(new_w, new_h) > longest_side:
|
||||
# Rounding to the nearest multiple pushed the longest side past the
|
||||
# target — floor that side to the multiple instead and rescale the
|
||||
# other side to match, so the requested cap is always honored.
|
||||
if raw_w >= raw_h:
|
||||
floored = max(multiple_of, (raw_w // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_w
|
||||
new_w = floored
|
||||
new_h = max(multiple_of, round((raw_h * other_scale) / multiple_of) * multiple_of)
|
||||
else:
|
||||
floored = max(multiple_of, (raw_h // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_h
|
||||
new_h = floored
|
||||
new_w = max(multiple_of, round((raw_w * other_scale) / multiple_of) * multiple_of)
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
@@ -77,6 +94,10 @@ class SmartBatchResize:
|
||||
"default": "lanczos",
|
||||
"tooltip": "Resampling method. Lanczos is sharpest for downscaling."
|
||||
}),
|
||||
"upscale_if_smaller": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Upscale images that are smaller than the target longest side. Turn off to only ever downscale, never upscale."
|
||||
}),
|
||||
|
||||
# OUTPUT NAMING
|
||||
"prefix": ("STRING", {
|
||||
@@ -122,6 +143,10 @@ class SmartBatchResize:
|
||||
"multiline": False,
|
||||
"tooltip": "Separator between filename parts. E.g. _ or -"
|
||||
}),
|
||||
"preview_limit": ("INT", {
|
||||
"default": 32, "min": 0, "max": 10000, "step": 1,
|
||||
"tooltip": "Max images to keep in memory for the preview output. All files are still processed and saved to disk; this only limits what's returned/previewed. 0 = no limit."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,22 +160,44 @@ class SmartBatchResize:
|
||||
@classmethod
|
||||
def IS_CHANGED(cls, input_folder, **kwargs):
|
||||
# Folder contents aren't a widget input, so ComfyUI can't see when files
|
||||
# are added/removed. Force re-execution every run; skip_if_exists keeps
|
||||
# already-processed files cheap.
|
||||
return float("nan")
|
||||
# are added/removed on its own. Hash the listing so unrelated graph runs
|
||||
# can still hit the cache, while an actual folder change invalidates it.
|
||||
folder = input_folder.strip()
|
||||
if not folder or not os.path.isdir(folder):
|
||||
return ""
|
||||
try:
|
||||
files = sorted(
|
||||
(f.name, f.stat().st_size, f.stat().st_mtime)
|
||||
for f in Path(folder).iterdir()
|
||||
if f.is_file() and f.suffix.lower() in SUPPORTED_EXT
|
||||
)
|
||||
except OSError:
|
||||
return float("nan")
|
||||
return hashlib.sha256(repr(files).encode()).hexdigest()
|
||||
|
||||
def run(self, input_folder, longest_side, multiple_of, interpolation,
|
||||
def run(self, input_folder, longest_side, multiple_of, interpolation, upscale_if_smaller,
|
||||
prefix, use_original_name, use_counter, counter_start, suffix_resolution,
|
||||
folder_resolution, folder_custom,
|
||||
skip_if_exists, delimiter):
|
||||
skip_if_exists, delimiter, preview_limit):
|
||||
|
||||
# Resolve output subfolder
|
||||
subfolder = folder_custom.strip()
|
||||
if subfolder != "" and (
|
||||
os.path.isabs(subfolder)
|
||||
or Path(subfolder).drive
|
||||
or ".." in Path(subfolder).parts
|
||||
or os.sep in subfolder
|
||||
or (os.altsep and os.altsep in subfolder)
|
||||
):
|
||||
raise ValueError(f"[SmartBatchResize] Invalid subfolder name: {folder_custom!r}. Use a plain folder name, not a path.")
|
||||
if subfolder == "":
|
||||
subfolder = str(longest_side) if folder_resolution else "resized"
|
||||
elif folder_resolution:
|
||||
subfolder = f"{subfolder}{delimiter}{longest_side}"
|
||||
|
||||
if not use_original_name and not use_counter:
|
||||
raise ValueError("[SmartBatchResize] Enable 'use_original_name' or 'use_counter' — otherwise all files would collapse to the same output name.")
|
||||
|
||||
input_folder = input_folder.strip()
|
||||
if not input_folder:
|
||||
raise ValueError("[SmartBatchResize] No folder selected. Enter a path or use the Browse button.")
|
||||
@@ -166,12 +213,18 @@ class SmartBatchResize:
|
||||
raise ValueError(f"[SmartBatchResize] No images found in: {input_folder}")
|
||||
|
||||
out_dir = Path(input_folder) / subfolder
|
||||
if out_dir.resolve() == Path(input_folder).resolve():
|
||||
raise ValueError("[SmartBatchResize] Output folder resolves to the input folder — refusing to overwrite originals.")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
interp = INTERP_MAP[interpolation]
|
||||
images_out = []
|
||||
counter_index = 0
|
||||
skipped_count = 0
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
total_count = 0
|
||||
keep_all = preview_limit == 0
|
||||
|
||||
for f in files:
|
||||
stem = _build_stem(
|
||||
@@ -187,39 +240,50 @@ class SmartBatchResize:
|
||||
)
|
||||
out_name = f"{stem}{f.suffix.lower()}"
|
||||
out_path = out_dir / out_name
|
||||
keep_preview = keep_all or total_count < preview_limit
|
||||
|
||||
if skip_if_exists and out_path.exists():
|
||||
print(f"[SmartBatchResize] Skipped (exists): {out_name}")
|
||||
skipped_count += 1
|
||||
try:
|
||||
if skip_if_exists and out_path.exists():
|
||||
print(f"[SmartBatchResize] Skipped (exists): {out_name}")
|
||||
skipped_count += 1
|
||||
total_count += 1
|
||||
counter_index += 1
|
||||
if keep_preview:
|
||||
existing = PILImage.open(out_path).convert("RGB")
|
||||
arr = np.array(existing).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
continue
|
||||
|
||||
img = PILImage.open(f)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
w, h = img.size
|
||||
new_w, new_h = _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller)
|
||||
|
||||
img_resized = img.resize((new_w, new_h), interp)
|
||||
|
||||
save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
|
||||
processed_count += 1
|
||||
total_count += 1
|
||||
counter_index += 1
|
||||
existing = PILImage.open(out_path).convert("RGB")
|
||||
arr = np.array(existing).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
continue
|
||||
if keep_preview:
|
||||
arr = np.array(img_resized).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
except Exception as e:
|
||||
print(f"[SmartBatchResize] Failed: {f.name}: {e}")
|
||||
failed_count += 1
|
||||
|
||||
img = PILImage.open(f)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = img.convert("RGB")
|
||||
w, h = img.size
|
||||
new_w, new_h = _calc_new_size(w, h, longest_side, multiple_of)
|
||||
|
||||
img_resized = img.resize((new_w, new_h), interp)
|
||||
|
||||
save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
|
||||
arr = np.array(img_resized).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
counter_index += 1
|
||||
|
||||
processed_count = len(images_out) - skipped_count
|
||||
if skipped_count:
|
||||
summary = f"✓ {processed_count} new, {skipped_count} already existed → {subfolder}/"
|
||||
else:
|
||||
summary = f"✓ {processed_count} images saved → {subfolder}/"
|
||||
return {"ui": {"text": [summary]}, "result": (images_out, len(images_out))}
|
||||
if failed_count:
|
||||
summary += f" ({failed_count} failed)"
|
||||
return {"ui": {"text": [summary]}, "result": (images_out, total_count)}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SmartBatchResize": SmartBatchResize}
|
||||
|
||||
+30
-11
@@ -13,15 +13,31 @@ INTERP_MAP = {
|
||||
|
||||
def _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller):
|
||||
longest = max(w, h)
|
||||
if longest <= longest_side and not upscale_if_smaller:
|
||||
scaled = longest > longest_side or upscale_if_smaller
|
||||
if not scaled:
|
||||
new_w, new_h = w, h
|
||||
else:
|
||||
scale = longest_side / longest
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
new_w = round(w * scale)
|
||||
new_h = round(h * scale)
|
||||
if multiple_of > 1:
|
||||
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of)
|
||||
raw_w, raw_h = new_w, new_h
|
||||
new_w = max(multiple_of, round(raw_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(raw_h / multiple_of) * multiple_of)
|
||||
if scaled and max(new_w, new_h) > longest_side:
|
||||
# Rounding to the nearest multiple pushed the longest side past the
|
||||
# target — floor that side to the multiple instead and rescale the
|
||||
# other side to match, so the requested cap is always honored.
|
||||
if raw_w >= raw_h:
|
||||
floored = max(multiple_of, (raw_w // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_w
|
||||
new_w = floored
|
||||
new_h = max(multiple_of, round((raw_h * other_scale) / multiple_of) * multiple_of)
|
||||
else:
|
||||
floored = max(multiple_of, (raw_h // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_h
|
||||
new_h = floored
|
||||
new_w = max(multiple_of, round((raw_w * other_scale) / multiple_of) * multiple_of)
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
@@ -60,9 +76,11 @@ class SmartResize:
|
||||
CATEGORY = "CraftKit"
|
||||
|
||||
def run(self, image, longest_side, multiple_of, interpolation, upscale_if_smaller):
|
||||
if image.shape[0] == 0:
|
||||
return (image, 0, 0)
|
||||
|
||||
interp = INTERP_MAP[interpolation]
|
||||
results = []
|
||||
last_w, last_h = 0, 0
|
||||
|
||||
for i in range(image.shape[0]):
|
||||
frame = image[i] # [H, W, C]
|
||||
@@ -72,15 +90,16 @@ class SmartResize:
|
||||
if new_w == w and new_h == h:
|
||||
results.append(frame)
|
||||
else:
|
||||
arr = (frame.numpy() * 255).astype("uint8")
|
||||
pil = PILImage.fromarray(arr, mode="RGB")
|
||||
channels = frame.shape[-1]
|
||||
pil_mode = {1: "L", 3: "RGB", 4: "RGBA"}.get(channels, "RGB")
|
||||
arr = np.clip(frame.detach().cpu().numpy() * 255, 0, 255).astype("uint8")
|
||||
pil = PILImage.fromarray(arr, mode=pil_mode)
|
||||
pil = pil.resize((new_w, new_h), interp)
|
||||
arr = np.array(pil).astype("float32") / 255.0
|
||||
results.append(torch.from_numpy(arr))
|
||||
|
||||
last_w, last_h = new_w, new_h
|
||||
|
||||
return (torch.stack(results, dim=0), last_w, last_h)
|
||||
stacked = torch.stack(results, dim=0)
|
||||
return (stacked, stacked.shape[2], stacked.shape[1])
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SmartResize": SmartResize}
|
||||
|
||||
Reference in New Issue
Block a user