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
This commit is contained in:
@@ -4,6 +4,9 @@ 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
|
||||
|
||||
|
||||
@@ -43,7 +43,10 @@
|
||||
"resized",
|
||||
true,
|
||||
"_",
|
||||
32
|
||||
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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
+62
-7
@@ -46,6 +46,26 @@ def _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller):
|
||||
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 = []
|
||||
|
||||
@@ -136,7 +156,7 @@ class SmartBatchResize:
|
||||
# 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": "_",
|
||||
@@ -147,6 +167,20 @@ class SmartBatchResize:
|
||||
"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."
|
||||
}),
|
||||
|
||||
# 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)."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +212,7 @@ class SmartBatchResize:
|
||||
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, preview_limit):
|
||||
skip_if_exists, delimiter, preview_limit, output_format, alpha_mode, quality):
|
||||
|
||||
# Resolve output subfolder
|
||||
subfolder = folder_custom.strip()
|
||||
@@ -238,31 +272,52 @@ 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
|
||||
|
||||
try:
|
||||
can_skip = False
|
||||
if skip_if_exists and out_path.exists():
|
||||
print(f"[SmartBatchResize] Skipped (exists): {out_name}")
|
||||
# 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).convert("RGB")
|
||||
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 = img.convert("RGB")
|
||||
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)
|
||||
|
||||
save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {}
|
||||
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})")
|
||||
|
||||
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user