diff --git a/.gitignore b/.gitignore index 7e228ff..bbc3285 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/example_workflows/smart_batch_resize_demo.json b/example_workflows/smart_batch_resize_demo.json index 53fd8c7..0d0d58f 100644 --- a/example_workflows/smart_batch_resize_demo.json +++ b/example_workflows/smart_batch_resize_demo.json @@ -43,7 +43,10 @@ "resized", true, "_", - 32 + 32, + "keep_source", + "auto", + 95 ] }, { diff --git a/js/smart_batch_controller.js b/js/smart_batch_controller.js deleted file mode 100644 index 11e5204..0000000 --- a/js/smart_batch_controller.js +++ /dev/null @@ -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); - }; - }, -}); diff --git a/js/smart_profile_switch.js b/js/smart_profile_switch.js index 67abc3e..f191a05 100644 --- a/js/smart_profile_switch.js +++ b/js/smart_profile_switch.js @@ -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; diff --git a/js/smart_prompt_controller.js b/js/smart_prompt_controller.js index 3739db8..05fda89 100644 --- a/js/smart_prompt_controller.js +++ b/js/smart_prompt_controller.js @@ -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) { diff --git a/smart_batch_resize.py b/smart_batch_resize.py index 8632c1e..b55e0bc 100644 --- a/smart_batch_resize.py +++ b/smart_batch_resize.py @@ -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})") diff --git a/smart_profile_switch.py b/smart_profile_switch.py index fdabfc1..4de5444 100644 --- a/smart_profile_switch.py +++ b/smart_profile_switch.py @@ -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}"