Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5536b7605 | ||
|
|
f695c67ff1 | ||
|
|
50af1e743d | ||
|
|
1750601338 | ||
|
|
6fa40684ac | ||
|
|
c6f6914488 |
@@ -2,6 +2,10 @@
|
||||
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
|
||||
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -1,8 +1,12 @@
|
||||
[project]
|
||||
name = "comfyui-craftkit"
|
||||
description = "Smart resize, batch resize, resolution multiplier and prompt controller nodes for ComfyUI"
|
||||
version = "1.0.3"
|
||||
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]
|
||||
|
||||
+179
-45
@@ -1,9 +1,18 @@
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
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."
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -135,22 +205,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, 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.")
|
||||
@@ -166,12 +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(
|
||||
@@ -185,41 +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}")
|
||||
skipped_count += 1
|
||||
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
|
||||
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
|
||||
finally:
|
||||
if pbar:
|
||||
pbar.update(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}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user