7 Commits
Author SHA1 Message Date
rubz b5536b7605 ```
fix: reorganize Smart Batch Resize into labeled sections; bump to 1.1.0

- Add section-header dividers (Resize/Filename/Output Location/Output
  Format/Options) to js/smart_batch_resize.js — the node grew to ~19
  widgets after the review fixes and needed grouping to stay scannable
- Move `delimiter` next to the other filename-building fields, and
  reorder Output Location/Output Format/Options into a more logical
  flow. This changes the node's widget order — workflows saved before
  this change will need the node re-placed once (values shift on load)
- Update README: new screenshot, full input table rewritten to match
  the new field order and the alpha_mode/output_format/quality/
  preview_limit/upscale_if_smaller additions, "Why these nodes?" bullet
  on the transparency handling
```
2026-07-28 00:23:31 +02:00
rubz f695c67ff1 chore: tighten error handling and dedupe a magic string from the nitpick pass
- Narrow the ProgressBar import guard to ImportError only, with a comment explaining why it stays (the module is imported standalone by the test suite outside a live ComfyUI)
- Extract the CRAFTKIT_DIALOG_ERROR: sentinel into a shared constant instead of three separate literals, use str.removeprefix
- Note that .avif/.heic can also fail on re-encode (with output_format=keep_source), not just decode
2026-07-27 21:51:08 +02:00
rubz 50af1e743d fix: nitpick cleanups from code review (N-23 through N-32)
- os module imported properly instead of __import__("os")
- browse_folder distinguishes a real COM/dialog failure from a user cancel
- fix Marshal.PtrToStringUni + leak-safe cleanup in the folder-picker COM call
- lock browse_folder so two concurrent clicks can't open two dialogs
- drop redundant int(round()) in SmartResolutionMultiplier
- unify "CraftKit —" docstring prefix across nodes
- comment OUTPUT_NODE=True on the status-label nodes so it isn't "cleaned up" later
- add .jfif/.avif/.heic to SmartBatchResize's supported extensions, document non-recursive scanning
- add a progress bar to SmartBatchResize (degrades gracefully outside a live ComfyUI)
- add readme/requires-python and a dependency-rationale comment to pyproject.toml
2026-07-27 21:40:18 +02:00
rubz 1750601338 fix: stale-skip detection, alpha/format controls, and remaining Minor issues from code review
- SmartBatchResize: only skip existing files if their actual dimensions still match current settings (was serving stale sizes after a settings change)
- SmartBatchResize: add alpha_mode (auto/flatten/keep) and output_format (keep_source/png/webp/jpg) with a quality control, replacing the undocumented silent alpha loss
- SmartProfileSwitch: warn when index is out of range instead of silently reading as slot 1 (catches an unconnected Smart Prompt Controller)
- SmartProfileSwitch JS: guard widget reorder against missing lookups, append rather than drop unrecognized widgets
- SmartPromptController JS: add the serialize override the other node JS files already have, preventing widget value drift
- Remove js/smart_batch_controller.js (dead code, referenced a node class that no longer exists)
- Untrack .claude/worktrees/* accidentally committed earlier; add .claude/ to .gitignore
2026-07-27 20:20:41 +02:00
rubz 6fa40684ac fix: stale-skip detection, alpha/format controls, and remaining Minor issues from code review
- SmartBatchResize: only skip existing files if their actual dimensions still match current settings (was serving stale sizes after a settings change)
- SmartBatchResize: add alpha_mode (auto/flatten/keep) and output_format (keep_source/png/webp/jpg) with a quality control, replacing the undocumented silent alpha loss
- SmartProfileSwitch: warn when index is out of range instead of silently reading as slot 1 (catches an unconnected Smart Prompt Controller)
- SmartProfileSwitch JS: guard widget reorder against missing lookups, append rather than drop unrecognized widgets
- SmartPromptController JS: add the serialize override the other node JS files already have, preventing widget value drift
- Remove js/smart_batch_controller.js (dead code, referenced a node class that no longer exists)
- Untrack .claude/worktrees/* accidentally committed earlier; add .claude/ to .gitignore
2026-07-27 20:15:24 +02:00
rubz c6f6914488 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
2026-07-27 18:42:54 +02:00
rubz e7caae4a3e fix: SmartBatchResize caching, EXIF rotation, skip-all crash, docstring; bump to 1.0.3
- add IS_CHANGED so new/removed files in input_folder are picked up without a widget change
- fix EXIF orientation ignored on load (phone photos were saved rotated)
- when all files are skipped, load existing resized output from disk instead of returning
  an empty list — fixes IndexError downstream (e.g. Preview Image) and removes the
  misleading "No images processed" error
- fix stale docstring in Smart Resolution Multiplier (SeedVR2's resolution input, not max_resolution)
2026-07-06 11:39:54 +02:00
16 changed files with 391 additions and 129 deletions
+5
View File
@@ -1,6 +1,11 @@
# Local Obsidian notes
project-craftkit.md
how-to-publish-comfyui-node.md
fable-review-2026-07-02.md
opus-review-craftkit-2026-07-02.md
# Claude Code session data
.claude/
# Local design source files
assets/*.ai
+11 -5
View File
@@ -85,13 +85,13 @@ Use this as a **pipeline node** — IMAGE in, IMAGE out. No files are saved to d
### 📁 Smart Batch Resize
<img src="assets/screenshot-smart-batch-resize.png" width="100%">
<img src="assets/screenshot-smart-batch-resize_v2.png" width="100%">
Load **all images from a folder**, resize each one by longest side, and save into a subfolder. Build clean dataset filenames from a prefix, the original name, and/or a sequential counter — combined with an optional resolution suffix.
Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images. It works as a **standalone node**: it needs no upstream input and no downstream connection to do its job — drop it on the canvas, point it at a folder, hit **Run Batch**, and it reads, resizes, and saves everything itself. Connecting the `images`/`count` outputs is entirely optional, e.g. for previewing results.
Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images, or a batch of frames/renders for After Effects where transparency needs to survive the resize. It works as a **standalone node**: it needs no upstream input and no downstream connection to do its job — drop it on the canvas, point it at a folder, hit **Run Batch**, and it reads, resizes, and saves everything itself. Connecting the `images`/`count` outputs is entirely optional, e.g. for previewing results.
Includes a **Browse folder** button to pick the input folder directly from the node, and quick presets (512 / 768 / 1024 / 1536) for the longest side — or type any custom value directly into the field.
Includes a **Browse folder** button to pick the input folder directly from the node, and quick presets (512 / 768 / 1024 / 1536) for the longest side — or type any custom value directly into the field. Widgets are grouped into labeled sections (Resize, Filename, Output Location, Output Format, Options) to keep the node scannable despite the number of settings.
| Input | Type | Default | Description |
|---|---|---|---|
@@ -99,15 +99,20 @@ Includes a **Browse folder** button to pick the input folder directly from the n
| Longest side (px) | INT | 1024 | Target size for longest side. Quick presets: 512 / 768 / 1024 / 1536 — or type any custom value directly into the field |
| Round to multiple of | INT | 8 | Snap dimensions to this multiple |
| Interpolation method | ENUM | lanczos | lanczos / bicubic / bilinear / nearest |
| Upscale if smaller | BOOLEAN | true | Upscale images smaller than the target longest side. Turn off to only ever downscale, never upscale |
| Filename prefix | STRING | — | Label prepended to filename — e.g. `headshot` → `headshot_photo_001_1024.jpg` |
| Keep original filename | BOOLEAN | true | Include the original filename in the output name |
| Add counter | BOOLEAN | false | Add a sequential 3-digit counter to each filename (001, 002, ...) |
| Counter start | INT | 1 | Starting number for the counter |
| Add resolution to filename | BOOLEAN | true | Append resolution to filename — e.g. `photo_1024.png` |
| Filename delimiter | STRING | _ | Separator between filename parts |
| Create resolution subfolder | BOOLEAN | false | Append resolution to subfolder name — e.g. `resized_1024` |
| Custom output subfolder | STRING | resized | Subfolder name inside the input folder |
| Skip existing files | BOOLEAN | true | Skip files that already exist in the output folder |
| Filename delimiter | STRING | _ | Separator between filename parts |
| Output format | ENUM | keep_source | keep_source / png / webp / jpg — keep_source preserves each file's original extension; jpg cannot store transparency |
| Alpha mode | ENUM | auto | auto: keep transparency only if the source has it · flatten: always composite onto white and output opaque RGB · keep: always output RGBA |
| Quality | INT | 95 | Compression quality for jpg/webp output (1–100). Ignored for png (always lossless) |
| Skip existing files | BOOLEAN | true | Skip files that already exist in the output folder **and** still match the current size settings — a settings change (e.g. a different longest_side) triggers a reprocess instead of silently serving a stale file |
| Preview limit | INT | 32 | Max images kept in memory for the preview output. All files are still processed and saved to disk regardless — this only caps what's returned for preview. 0 = no limit |
**Outputs:** Images (list), Count
@@ -148,6 +153,7 @@ Ready-to-load workflows are included in [`example_workflows/`](example_workflows
- ComfyUI's built-in `Resize Images by Longer Edge` [BETA] has no Lanczos and no `multiple_of` snapping
- `JWImageResizeByLongerSide` (comfyui-various) has no Lanczos in the official release
- No existing node combines batch folder loading + longest-side resize + original filename preservation
- Smart Batch Resize handles transparency deliberately instead of by accident — choose whether alpha is preserved, always kept, or flattened onto a solid background, and force a specific output format (png/webp/jpg) independent of the source files' own extensions
- No existing node outputs a ready-to-use `resolution` INT for SeedVR2
- No existing node cycles through multiple prompt lists with automatic list switching and an index
- No existing node switches label + width + height together per category in one place
+78 -30
View File
@@ -11,19 +11,37 @@ 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
import os
import threading
from aiohttp import web
from server import PromptServer
@PromptServer.instance.routes.get("/craftkit/browse_folder")
async def browse_folder(request):
import subprocess, sys
_dialog_lock = threading.Lock()
# Must match the literal 'CRAFTKIT_DIALOG_ERROR:' prefix written by the
# PowerShell catch block below (kept literal there to avoid brace-escaping
# a dense here-string).
_DIALOG_ERROR_PREFIX = "CRAFTKIT_DIALOG_ERROR:"
def _open_dialog():
if sys.platform != "win32":
return ""
ps = r"""
async def browse_folder(request):
import subprocess, sys
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;
@@ -75,18 +93,28 @@ public static class CraftKitFolderPicker
public static string ShowDialog(IntPtr owner, string title)
{
const uint ERROR_CANCELLED = 0x800704C7;
var dialog = (IFileDialog)new FileOpenDialogRCW();
dialog.SetOptions(0x20u | 0x40u); // FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM
if (!string.IsNullOrEmpty(title)) dialog.SetTitle(title);
int hr = dialog.Show(owner);
if (hr != 0) return null;
if (hr != 0)
{
if ((uint)hr == ERROR_CANCELLED) return null;
throw new System.Runtime.InteropServices.COMException("IFileDialog.Show failed", hr);
}
IShellItemLocal item;
dialog.GetResult(out item);
IntPtr pszPath;
item.GetDisplayName(0x80058000u, out pszPath); // SIGDN_FILESYSPATH
string path = Marshal.PtrToStringAuto(pszPath);
Marshal.FreeCoTaskMem(pszPath);
return path;
IntPtr pszPath = IntPtr.Zero;
try
{
item.GetDisplayName(0x80058000u, out pszPath); // SIGDN_FILESYSPATH
return Marshal.PtrToStringUni(pszPath);
}
finally
{
if (pszPath != IntPtr.Zero) Marshal.FreeCoTaskMem(pszPath);
}
}
}
"@
@@ -100,23 +128,43 @@ $o.Width = 1; $o.Height = 1; $o.Opacity = 0
$o.StartPosition = 'CenterScreen'
$o.Add_Shown({
$o.Activate()
$path = [CraftKitFolderPicker]::ShowDialog($o.Handle, 'Select input folder')
if ($path) { $script:r = $path }
try {
$path = [CraftKitFolderPicker]::ShowDialog($o.Handle, 'Select input folder')
if ($path) { $script:r = $path }
} catch {
# Must match _DIALOG_ERROR_PREFIX in __init__.py.
$script:r = 'CRAFTKIT_DIALOG_ERROR:' + $_.Exception.Message
}
$o.Close()
})
$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)
if not _dialog_lock.acquire(blocking=False):
return web.json_response({"ok": False, "error": "A folder dialog is already open."}, status=409)
try:
loop = asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor() as pool:
folder = await loop.run_in_executor(pool, _open_dialog)
finally:
_dialog_lock.release()
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.startswith(_DIALOG_ERROR_PREFIX):
return web.json_response({"ok": False, "error": folder.removeprefix(_DIALOG_ERROR_PREFIX)}, status=500)
if folder and 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.")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

@@ -33,6 +33,7 @@
1024,
8,
"lanczos",
true,
"",
true,
false,
@@ -41,7 +42,11 @@
false,
"resized",
true,
"_"
"_",
32,
"keep_source",
"auto",
95
]
},
{
-32
View File
@@ -1,32 +0,0 @@
import { app } from "../../scripts/app.js";
app.registerExtension({
name: "Craftopia.SmartBatchController",
async nodeCreated(node) {
if (node.comfyClass !== "SmartBatchController") return;
// Create status widget — empty name so no label shows
const widget = node.addWidget("text", "", "", () => {});
widget.serializeValue = () => undefined;
// Listen for execution results
const onExecuted = node.onExecuted;
node.onExecuted = function (output) {
// Store current size before update
const w = node.size[0];
const h = node.size[1];
onExecuted?.apply(this, arguments);
if (output?.text?.[0]) {
widget.value = output.text[0];
}
// Restore size so ComfyUI doesn't shrink the node
node.size[0] = Math.max(w, node.size[0]);
node.size[1] = Math.max(h, node.size[1]);
node.setDirtyCanvas(true);
};
},
});
+36 -1
View File
@@ -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;
@@ -106,6 +106,41 @@ app.registerExtension({
}
}
// Section-header dividers — visual only, not serialized. Standalone node
// with a lot of inputs, so grouping them keeps it scannable at a glance.
const addSectionDivider = (beforeWidgetName, label) => {
const target = node.widgets.find(w => w.name === beforeWidgetName);
if (!target) return;
const div = node.addWidget("button", `_div_${label}`, null, () => {}, { serialize: false });
div.serialize = false;
div.draw = function (ctx, node, widgetWidth, y, height) {
const lx = 14;
ctx.save();
ctx.font = "bold 10px sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillStyle = "#888";
ctx.fillText(label, lx, y + height / 2);
const lineX = lx + ctx.measureText(label).width + 8;
ctx.beginPath();
ctx.moveTo(lineX, y + height / 2);
ctx.lineTo(widgetWidth - 14, y + height / 2);
ctx.strokeStyle = "#333";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
};
const targetIdx = node.widgets.indexOf(target);
const divIdx = node.widgets.indexOf(div);
node.widgets.splice(divIdx, 1);
node.widgets.splice(targetIdx, 0, div);
};
addSectionDivider("longest_side", "RESIZE");
addSectionDivider("prefix", "FILENAME");
addSectionDivider("folder_resolution", "OUTPUT LOCATION");
addSectionDivider("skip_if_exists", "OPTIONS");
addSectionDivider("output_format", "OUTPUT FORMAT");
// Override serialize so non-serializable JS widgets (Browse, presets, status,
// run batch) are excluded from widgets_values in the saved workflow JSON.
// Without this, LiteGraph saves null slots for these widgets which then shift
+7 -1
View File
@@ -73,7 +73,13 @@ app.registerExtension({
node.widgets.find(w => w.name === `height_${i}`),
);
}
node.widgets = [indexWidget, ...slotWidgets, statusWidget];
// Guard against a lookup returning undefined (e.g. a widget converted to
// an input) and never drop a widget we didn't explicitly place — append
// anything unrecognized at the end instead of silently discarding it.
const orderedWidgets = [indexWidget, ...slotWidgets, statusWidget].filter(Boolean);
const orderedSet = new Set(orderedWidgets);
const leftoverWidgets = node.widgets.filter(w => !orderedSet.has(w));
node.widgets = [...orderedWidgets, ...leftoverWidgets];
// Serialize override — keeps non-serializable widgets out of widgets_values
const origSerialize = node.serialize;
+15
View File
@@ -39,6 +39,21 @@ app.registerExtension({
ctx.restore();
};
// Override serialize so non-serializable JS widgets (the result display)
// are excluded from widgets_values in the saved workflow JSON. Without this,
// LiteGraph saves null slots for these widgets which then shift all Python
// widget values on load (configure runs before nodeCreated).
const origSerialize = node.serialize;
node.serialize = function () {
const data = origSerialize.call(this);
if (data.widgets_values) {
data.widgets_values = this.widgets
.filter(w => w.serialize !== false && w.options?.serialize !== false)
.map(w => w.value);
}
return data;
};
// Intercept onExecuted: update our widget, hide ComfyUI's default textarea
const origOnExecuted = node.onExecuted;
node.onExecuted = function (output) {
+5 -1
View File
@@ -1,8 +1,12 @@
[project]
name = "comfyui-craftkit"
description = "Smart resize, batch resize, resolution multiplier and prompt controller nodes for ComfyUI"
version = "1.0.2"
version = "1.1.0"
readme = "README.md"
requires-python = ">=3.10"
license = { file = "LICENSE" }
# Pillow, NumPy, and PyTorch all ship with ComfyUI itself — intentionally not
# re-declared here to avoid version conflicts with ComfyUI's own pins.
dependencies = []
[project.urls]
+189 -40
View File
@@ -1,9 +1,18 @@
import hashlib
import os
from pathlib import Path
from PIL import Image as PILImage
from PIL import Image as PILImage, ImageOps
import numpy as np
import torch
try:
# Only unavailable when this module is imported standalone outside a live
# ComfyUI process (e.g. the test suite); ImportError is the only expected
# failure here, so anything else is left to surface normally.
from comfy.utils import ProgressBar
except ImportError:
ProgressBar = None
INTERP_MAP = {
"lanczos": PILImage.LANCZOS,
@@ -12,23 +21,62 @@ INTERP_MAP = {
"nearest": PILImage.NEAREST,
}
SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"}
# .avif/.heic decoding (and, with output_format=keep_source, re-encoding) depends on
# optional Pillow plugins that may not be installed; if unsupported, the per-file
# try/except below skips that file with a clear warning instead of crashing the batch.
SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".jfif", ".webp", ".bmp", ".tiff", ".tif", ".avif", ".heic"}
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
ALPHA_INCOMPATIBLE_EXT = {".jpg", ".jpeg", ".bmp"}
def _flatten_to_white(img):
img = img.convert("RGBA")
bg = PILImage.new("RGB", img.size, (255, 255, 255))
bg.paste(img, mask=img.split()[3])
return bg
def _apply_alpha_mode(img, alpha_mode):
has_alpha = img.mode in ("RGBA", "LA", "PA") or ("transparency" in img.info)
if alpha_mode == "flatten":
return _flatten_to_white(img) if has_alpha else img.convert("RGB")
if alpha_mode == "keep":
return img.convert("RGBA")
# auto
return img.convert("RGBA") if has_alpha else img.convert("RGB")
def _build_stem(original_stem, prefix, use_original_name, use_counter, counter_index, counter_start, suffix_resolution, longest_side, delimiter):
parts = []
@@ -61,7 +109,7 @@ class SmartBatchResize:
"input_folder": ("STRING", {
"default": "",
"multiline": False,
"tooltip": "Folder containing images to resize."
"tooltip": "Folder containing images to resize. Only files directly in this folder are scanned — subfolders are not included."
}),
# RESIZE SETTINGS
@@ -77,6 +125,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", {
@@ -100,6 +152,11 @@ class SmartBatchResize:
"default": True,
"tooltip": "Append resolution to filename. E.g. photo_1024.png"
}),
"delimiter": ("STRING", {
"default": "_",
"multiline": False,
"tooltip": "Separator between filename parts. E.g. _ or -"
}),
# OUTPUT LOCATION
"folder_resolution": ("BOOLEAN", {
@@ -112,15 +169,28 @@ class SmartBatchResize:
"tooltip": "Subfolder name. Resolution is appended if 'Create resolution subfolder' is on."
}),
# OUTPUT FORMAT
"output_format": (["keep_source", "png", "webp", "jpg"], {
"default": "keep_source",
"tooltip": "File format for saved images. 'keep_source' preserves each file's original extension. jpg cannot store transparency."
}),
"alpha_mode": (["auto", "flatten", "keep"], {
"default": "auto",
"tooltip": "auto: preserve transparency only if the source has it. flatten: always composite onto white and output opaque RGB. keep: always output RGBA. jpg/bmp output always flattens regardless (those formats can't store alpha)."
}),
"quality": ("INT", {
"default": 95, "min": 1, "max": 100, "step": 1,
"tooltip": "Compression quality for jpg/webp output. Ignored for png (always lossless)."
}),
# OPTIONS
"skip_if_exists": ("BOOLEAN", {
"default": True,
"tooltip": "Skip files that already exist in the output folder."
"tooltip": "Skip files that already exist in the output folder AND still match the current size settings. If longest_side/multiple_of/upscale_if_smaller changed since the file was made, it's reprocessed instead of skipped."
}),
"delimiter": ("STRING", {
"default": "_",
"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."
}),
}
}
@@ -132,18 +202,47 @@ class SmartBatchResize:
CATEGORY = "CraftKit"
OUTPUT_NODE = True
def run(self, input_folder, longest_side, multiple_of, interpolation,
@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 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, 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, output_format, alpha_mode, quality):
# 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.")
@@ -159,11 +258,19 @@ 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
pbar = ProgressBar(len(files)) if ProgressBar else None
for f in files:
stem = _build_stem(
@@ -177,34 +284,76 @@ class SmartBatchResize:
longest_side=longest_side,
delimiter=delimiter,
)
out_name = f"{stem}{f.suffix.lower()}"
out_ext = f.suffix.lower() if output_format == "keep_source" else f".{output_format}"
out_name = f"{stem}{out_ext}"
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}")
try:
can_skip = False
if skip_if_exists and out_path.exists():
# Only honor the skip if the existing file's actual dimensions
# still match what the current settings would produce — otherwise
# a lowered longest_side/multiple_of would silently keep serving
# a stale size from a previous run.
try:
src_w, src_h = PILImage.open(f).size
expected_w, expected_h = _calc_new_size(src_w, src_h, longest_side, multiple_of, upscale_if_smaller)
can_skip = PILImage.open(out_path).size == (expected_w, expected_h)
except Exception:
can_skip = False
if can_skip:
print(f"[SmartBatchResize] Skipped (exists, matches current settings): {out_name}")
skipped_count += 1
total_count += 1
counter_index += 1
if keep_preview:
existing = PILImage.open(out_path)
existing = _apply_alpha_mode(existing, alpha_mode)
arr = np.array(existing).astype("float32") / 255.0
images_out.append(torch.from_numpy(arr).unsqueeze(0))
continue
elif skip_if_exists and out_path.exists():
print(f"[SmartBatchResize] Reprocessing (existing file doesn't match current settings): {out_name}")
img = PILImage.open(f)
img = ImageOps.exif_transpose(img)
img = _apply_alpha_mode(img, alpha_mode)
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)
if img_resized.mode == "RGBA" and out_ext in ALPHA_INCOMPATIBLE_EXT:
img_resized = _flatten_to_white(img_resized)
print(f"[SmartBatchResize] {f.name}: flattened transparency for {out_ext} (format doesn't support alpha)")
save_kwargs = {"quality": quality} if out_ext in (".jpg", ".jpeg", ".webp") 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
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
finally:
if pbar:
pbar.update(1)
img = PILImage.open(f).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
if not images_out:
raise ValueError("[SmartBatchResize] No images processed.")
summary = f"✓ {len(images_out)} images saved → {subfolder}/"
return {"ui": {"text": [summary]}, "result": (images_out, len(images_out))}
if skipped_count:
summary = f"✓ {processed_count} new, {skipped_count} already existed → {subfolder}/"
else:
summary = f"✓ {processed_count} images saved → {subfolder}/"
if failed_count:
summary += f" ({failed_count} failed)"
return {"ui": {"text": [summary]}, "result": (images_out, total_count)}
NODE_CLASS_MAPPINGS = {"SmartBatchResize": SmartBatchResize}
+3 -1
View File
@@ -37,7 +37,7 @@ class SmartProfileSwitch:
RETURN_NAMES = ("label", "width", "height")
FUNCTION = "route"
CATEGORY = "CraftKit"
OUTPUT_NODE = True
OUTPUT_NODE = True # renders a status label on the node; must always execute, don't prune this
def route(self, index,
label_1, width_1, height_1,
@@ -50,6 +50,8 @@ class SmartProfileSwitch:
(label_3, width_3, height_3),
(label_4, width_4, height_4),
]
if not (1 <= index <= 4):
print(f"[SmartProfileSwitch] index={index} is out of range (expected 1-4); clamping to slot {max(1, min(4, index))}. Check that Smart Prompt Controller is connected and has prompts.")
idx = max(1, min(4, index)) - 1
label, width, height = slots[idx]
status = f"Slot {idx + 1}: {label} | {width} × {height}"
+2 -2
View File
@@ -1,6 +1,6 @@
class SmartPromptController:
"""
Craftopia — Smart Prompt Controller 📋
CraftKit — Smart Prompt Controller 📋
Cycles through up to 4 prompt lists using a single incrementing counter.
Counts lines automatically and outputs the selected prompt + list number.
"""
@@ -31,7 +31,7 @@ class SmartPromptController:
RETURN_NAMES = ("prompt", "active_list")
FUNCTION = "control"
CATEGORY = "CraftKit"
OUTPUT_NODE = True
OUTPUT_NODE = True # renders a status label on the node; must always execute, don't prune this
def _get_lines(self, text):
"""Return list of non-empty lines from a text block."""
+30 -11
View File
@@ -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}
+4 -4
View File
@@ -2,7 +2,7 @@ class SmartResolutionMultiplier:
"""
Multiplies image dimensions by a factor.
Outputs width, height, and resolution (longest side) as INT.
Use resolution output directly with SeedVR max_resolution.
Use resolution output directly with SeedVR2's resolution input.
"""
@classmethod
@@ -10,7 +10,7 @@ class SmartResolutionMultiplier:
return {
"required": {
"image": ("IMAGE", {
"tooltip": "Source image. Its current width/height are read directly — no separate Get Image Size node needed."
"tooltip": "Source image. Its current width/height are read directly, no separate Get Image Size node needed."
}),
"multiplier": ("FLOAT", {
"default": 2.0,
@@ -38,8 +38,8 @@ class SmartResolutionMultiplier:
def run(self, image, multiplier, multiple_of):
h, w = image.shape[1], image.shape[2]
new_w = int(round(w * multiplier))
new_h = int(round(h * multiplier))
new_w = round(w * multiplier)
new_h = round(h * multiplier)
if multiple_of > 1:
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)