diff --git a/README.md b/README.md index 560854d..cea200b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,8 @@ Load **all images from a folder**, resize each one by longest side, and save wit Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images. +Processes and saves directly to disk with the **original filename preserved**. The output folder is automatically named with the resolution (e.g. `resized_1024`) so you always know what's inside. Filenames are also passed as outputs so you can use them further in your pipeline. + | Input | Type | Default | Description | |---|---|---|---| | input_folder | STRING | D:/images/input | Source folder path | diff --git a/__init__.py b/__init__.py index fd98c92..1beb5fd 100644 --- a/__init__.py +++ b/__init__.py @@ -9,3 +9,47 @@ NODE_CLASS_MAPPINGS = {**A, **B, **C, **D} NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES, **D_NAMES} __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] + +import asyncio +import concurrent.futures +from aiohttp import web +from server import PromptServer + +@PromptServer.instance.routes.get("/craftkit/browse_folder") +async def browse_folder(request): + import subprocess, sys + + def _open_dialog(): + if sys.platform != "win32": + return "" + ps = ( + "Add-Type -AssemblyName System.Windows.Forms;" + "$r='';" + "$o=New-Object System.Windows.Forms.Form;" + "$o.TopMost=$true;$o.ShowInTaskbar=$false;$o.FormBorderStyle='None';" + "$o.Width=1;$o.Height=1;$o.Opacity=0;$o.StartPosition='CenterScreen';" + "$o.Add_Shown({" + "$o.Activate();" + "$d=New-Object System.Windows.Forms.OpenFileDialog;" + "$d.Title='Select input folder';" + "$d.ValidateNames=$false;$d.CheckFileExists=$false;$d.CheckPathExists=$true;" + "$d.FileName='Folder Selection.';" + "$d.Filter='Folders|*.none';" + "if($d.ShowDialog($o) -eq 'OK'){$r=[System.IO.Path]::GetDirectoryName($d.FileName)};" + "$o.Close()});" + "$o.ShowDialog()|Out-Null;" + "$r" + ) + result = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps], + capture_output=True, text=True + ) + 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 folder and __import__("os").path.isdir(folder): + return web.json_response({"ok": True, "path": folder}) + return web.json_response({"ok": False, "cancelled": True}) diff --git a/js/smart_batch_resize.js b/js/smart_batch_resize.js new file mode 100644 index 0000000..0512a43 --- /dev/null +++ b/js/smart_batch_resize.js @@ -0,0 +1,110 @@ +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "Craftopia.SmartBatchResize", + + async nodeCreated(node) { + if (node.comfyClass !== "SmartBatchResize") return; + + const folderWidget = node.widgets?.find(w => w.name === "input_folder"); + if (!folderWidget) return; + + const btn = node.addWidget("button", "📁 Browse folder", null, async () => { + try { + const res = await fetch("/craftkit/browse_folder"); + const data = await res.json(); + if (data.ok && data.path) { + folderWidget.value = data.path; + node.setDirtyCanvas(true); + } + } catch (e) { + console.error("[SmartBatchResize] Browse failed:", e); + } + }); + + // Exclude button from serialization (saves undefined placeholder to keep index alignment) + btn.serializeValue = () => undefined; + + // Move Browse button to right after input_folder (index 1) + const folderIdx = node.widgets.indexOf(folderWidget); + const btnIdx = node.widgets.indexOf(btn); + if (btnIdx !== folderIdx + 1) { + node.widgets.splice(btnIdx, 1); + node.widgets.splice(folderIdx + 1, 0, btn); + } + + // Compact preset chips for longest_side. Start from a REAL button widget + // (so all of ComfyUI's layout/computeSize/sizing integration is correct) + // and only override its draw + mouse. A bare custom widget with our own + // computeSize forced the node width and broke the layout on click. + const longestSideWidget = node.widgets?.find(w => w.name === "longest_side"); + if (longestSideWidget) { + const PRESETS = [512, 768, 1024, 1536]; + + // Per-chip click handled in the button callback (buttons route clicks + // to their callback, not to .mouse). We read the pointer position from + // the canvas at click time and match it against the drawn chip rects. + const presetWidget = node.addWidget("button", "size_presets", null, (value, canvas, node) => { + const gm = (canvas || app.canvas)?.graph_mouse; + if (!gm) return; + const lx = gm[0] - node.pos[0]; + const ly = gm[1] - node.pos[1]; + for (const r of presetWidget._rects) { + if (lx >= r.x1 && lx <= r.x2 && ly >= r.y1 && ly <= r.y2) { + longestSideWidget.value = r.size; + longestSideWidget.callback?.(r.size, app.canvas, node); + node.setDirtyCanvas(true); + return; + } + } + }); + presetWidget.serializeValue = () => undefined; + presetWidget._rects = []; + + presetWidget.draw = function (ctx, node, widgetWidth, y, height) { + const margin = 14; + const gap = 5; + const innerW = Math.max(40, widgetWidth - margin * 2); + const n = PRESETS.length; + const cellW = Math.max(10, (innerW - gap * (n - 1)) / n); + const h = Math.min(height - 2, 18); + const top = y + (height - h) / 2; + const current = longestSideWidget.value; + this._rects = []; + + ctx.save(); + ctx.font = "11px sans-serif"; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + for (let i = 0; i < n; i++) { + const x = margin + i * (cellW + gap); + const active = current === PRESETS[i]; + + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(x, top, cellW, h, 4); + else ctx.rect(x, top, cellW, h); + ctx.fillStyle = active ? "#f28f41" : "#2a2a2a"; + ctx.fill(); + ctx.lineWidth = 1; + ctx.strokeStyle = active ? "#f28f41" : "#555"; + ctx.stroke(); + + ctx.fillStyle = active ? "#111" : "#ccc"; + ctx.fillText(String(PRESETS[i]), x + cellW / 2, top + h / 2 + 0.5); + + this._rects.push({ x1: x, x2: x + cellW, y1: top, y2: top + h, size: PRESETS[i] }); + } + ctx.restore(); + }; + + // Move preset row to right after longest_side + const lsIdx = node.widgets.indexOf(longestSideWidget); + const pwIdx = node.widgets.indexOf(presetWidget); + if (pwIdx !== lsIdx + 1) { + node.widgets.splice(pwIdx, 1); + node.widgets.splice(lsIdx + 1, 0, presetWidget); + } + } + }, +}); diff --git a/smart_batch_resize.py b/smart_batch_resize.py index 330d710..94a678b 100644 --- a/smart_batch_resize.py +++ b/smart_batch_resize.py @@ -41,7 +41,7 @@ class SmartBatchResize: "multiline": False, "tooltip": "Folder containing images to resize." }), - + # RESIZE SETTINGS "longest_side": ("INT", { "default": 1024, "min": 64, "max": 8192, "step": 64, @@ -54,7 +54,7 @@ class SmartBatchResize: "interpolation": (["lanczos", "bicubic", "bilinear", "nearest"], { "default": "lanczos" }), - + # OUTPUT NAMING "suffix_resolution": ("BOOLEAN", { "default": True, @@ -65,7 +65,7 @@ class SmartBatchResize: "multiline": False, "tooltip": "Custom text to add before resolution. Only used when suffix_resolution is on." }), - + # OUTPUT LOCATION "folder_resolution": ("BOOLEAN", { "default": False, @@ -76,7 +76,7 @@ class SmartBatchResize: "multiline": False, "tooltip": "Subfolder name. Resolution is appended if folder_resolution is on." }), - + # OPTIONS "skip_if_exists": ("BOOLEAN", { "default": True,