diff --git a/.claude/worktrees/agent-a60baca8993c03f03 b/.claude/worktrees/agent-a60baca8993c03f03 new file mode 160000 index 0000000..e7caae4 --- /dev/null +++ b/.claude/worktrees/agent-a60baca8993c03f03 @@ -0,0 +1 @@ +Subproject commit e7caae4a3e50dde2ff6228ceef276bb2f25a5a38 diff --git a/.gitignore b/.gitignore index f687ae9..7e228ff 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/__init__.py b/__init__.py index f55fa26..e6cce84 100644 --- a/__init__.py +++ b/__init__.py @@ -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.") diff --git a/example_workflows/smart_batch_resize_demo.json b/example_workflows/smart_batch_resize_demo.json index bc592d4..53fd8c7 100644 --- a/example_workflows/smart_batch_resize_demo.json +++ b/example_workflows/smart_batch_resize_demo.json @@ -33,6 +33,7 @@ 1024, 8, "lanczos", + true, "", true, false, @@ -41,7 +42,8 @@ false, "resized", true, - "_" + "_", + 32 ] }, { diff --git a/js/smart_batch_resize.js b/js/smart_batch_resize.js index 2ab0e2d..f7379d3 100644 --- a/js/smart_batch_resize.js +++ b/js/smart_batch_resize.js @@ -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; diff --git a/smart_batch_resize.py b/smart_batch_resize.py index 5267e99..8632c1e 100644 --- a/smart_batch_resize.py +++ b/smart_batch_resize.py @@ -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} diff --git a/smart_resize.py b/smart_resize.py index 7fabd2c..5498d8f 100644 --- a/smart_resize.py +++ b/smart_resize.py @@ -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}