Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8ad8320b | ||
|
|
c6c26a35fa | ||
|
|
dd0c4d9b2e | ||
|
|
254358790f | ||
|
|
691d0e6df5 | ||
|
|
25f80d8e33 | ||
|
|
f695c67ff1 | ||
|
|
50af1e743d | ||
|
|
1750601338 | ||
|
|
6fa40684ac | ||
|
|
c6f6914488 | ||
|
|
e7caae4a3e | ||
|
|
4b85129857 | ||
|
|
c5ccc7799d | ||
|
|
155144a018 | ||
|
|
6a3dbaea98 | ||
|
|
c0609cfc85 | ||
|
|
f54122f895 | ||
|
|
4231615d52 | ||
|
|
2008eccd71 | ||
|
|
9bf268bf1b | ||
|
|
e218330869 | ||
|
|
fd90def305 | ||
|
|
cb7da507e4 | ||
|
|
cf94f2aa8e | ||
|
|
7b49cbe603 |
@@ -0,0 +1,20 @@
|
||||
name: Publish to Comfy registry
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "pyproject.toml"
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v4
|
||||
- name: Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
||||
@@ -1,3 +1,15 @@
|
||||
# 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
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[codz]
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 CraftopiaStudio
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,15 +1,24 @@
|
||||
# ComfyUI-Craftopia
|
||||
<img src="assets/banner.svg" width="100%" alt="ComfyUI-CraftKit">
|
||||
|
||||
Custom nodes for ComfyUI — image resizing, dataset prep & prompt automation for LoRA training workflows.
|
||||
# ComfyUI-CraftKit
|
||||
|
||||
All nodes appear under the **Craftopia** category in the node menu.
|
||||
Custom nodes for ComfyUI - image resizing, dataset prep & prompt automation for LoRA training workflows.
|
||||
|
||||
All nodes appear under the **CraftKit** category in the node menu.
|
||||
|
||||
Every node renders correctly in both classic and **Nodes 2.0** (ComfyUI's newer Vue-based node UI):
|
||||
|
||||
<img src="assets/screenshot-nodes2-compat.png" width="60%">
|
||||
|
||||
---
|
||||
|
||||
## Nodes
|
||||
|
||||
### 🎛️ Smart Prompt Controller
|
||||
Cycle through up to 4 prompt lists using a single incrementing counter. Counts lines automatically, selects the right prompt, and outputs which list is active — ideal for driving Switch nodes that control aspect ratio, latent size, or other per-category settings.
|
||||
### 📋 Smart Prompt Controller
|
||||
|
||||
<img src="assets/screenshot-smart-prompt-controller.png" width="100%">
|
||||
|
||||
Cycle through up to 4 prompt lists using a single incrementing index. Counts lines automatically, selects the right prompt, and outputs which list is active - ideal for driving Switch nodes that control aspect ratio, latent size, or other per-category settings.
|
||||
|
||||
**Typical usecases:**
|
||||
- LoRA dataset generation with multiple pose categories (headshots, halfbody, fullbody, tall portrait), each with their own prompt list and aspect ratio
|
||||
@@ -17,79 +26,129 @@ Cycle through up to 4 prompt lists using a single incrementing counter. Counts l
|
||||
|
||||
| Input | Type | Description |
|
||||
|---|---|---|
|
||||
| counter | INT | Auto-incrementing counter (1-indexed) |
|
||||
| prompt_list_1 | STRING | First prompt list — one prompt per line (forceInput) |
|
||||
| prompt_list_2 | STRING | Second prompt list (optional, forceInput) |
|
||||
| prompt_list_3 | STRING | Third prompt list (optional, forceInput) |
|
||||
| prompt_list_4 | STRING | Fourth prompt list (optional, forceInput) |
|
||||
| Index | INT | Current position across all lists (1-indexed). Set "control after generate" to `increment` to auto-advance every run |
|
||||
| Prompt list 1 | STRING | First prompt list, one prompt per line |
|
||||
| Prompt list 2 | STRING | Second prompt list (optional) |
|
||||
| Prompt list 3 | STRING | Third prompt list (optional) |
|
||||
| Prompt list 4 | STRING | Fourth prompt list (optional) |
|
||||
|
||||
**Outputs:** `prompt` (selected line as STRING), `list_index` (active list number as INT → feed to Switch nodes)
|
||||
**Outputs:** Prompt (selected line as STRING), Active list (active list number as INT → feed to Smart Profile Switch or Switch nodes)
|
||||
|
||||
The node displays a status label showing the current position: `List 1 — 4/5 | total: 8`
|
||||
|
||||
<img src="assets/screenshot-smart-prompt-controller.png" width="700">
|
||||
---
|
||||
|
||||
### 🧩 Smart Profile Switch
|
||||
|
||||
<img src="assets/screenshot-smart-profile-switch.png" width="100%">
|
||||
|
||||
Maps an active index (1–4) to a label, width, and height. Pairs directly with Smart Prompt Controller's `active_list` output to switch resolution and filename label per category in one node - no separate Aspect Ratio + Primitive + Switch combo needed.
|
||||
|
||||
**Typical usecases:**
|
||||
- Driving `EmptyLatentImage` / `EmptySD3LatentImage` width/height per prompt category
|
||||
- Feeding a filename suffix into `SaveImageExtended` or similar so each category's output is labeled automatically
|
||||
- Replacing a CR Aspect Ratio + 4× Primitive + LatentSwitch chain with a single node
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| Index | INT | 1 | Active profile (1–4). Connect to Active list from Smart Prompt Controller |
|
||||
| Label 1 … Label 4 | STRING | Headshots / Halfbody / Fullbody / Tall | Label for each profile, used as filename suffix |
|
||||
| Width 1 … Width 4 | INT | 1024 / 832 / 768 / 640 | Width for each profile |
|
||||
| Height 1 … Height 4 | INT | 1024 / 1216 / 1344 / 1536 | Height for each profile |
|
||||
|
||||
**Outputs:** Label (STRING), Width (INT), Height (INT)
|
||||
|
||||
The node displays a status label showing the active slot: `Slot 2: Halfbody | 832 × 1216`
|
||||
|
||||
---
|
||||
|
||||
### 📐 Smart Resize
|
||||
|
||||
<img src="assets/screenshot-smart-resize.png" width="100%">
|
||||
|
||||
Resize any image (or batch) so the **longest side** equals `longest_side`, with aspect ratio preserved.
|
||||
|
||||
Use this as a **pipeline node** — IMAGE in, IMAGE out. No files are saved to disk.
|
||||
Use this as a **pipeline node** - IMAGE in, IMAGE out. No files are saved to disk.
|
||||
|
||||
**Typical usecases:**
|
||||
- Downscale a SeedVR (or other upscaler) result back to your target training size — the upscaler adds fine detail, Smart Resize brings it to the right dimensions for LoRA training
|
||||
- Downscale a SeedVR2 (or other upscaler) result back to your target training size - the upscaler adds fine detail, Smart Resize brings it to the right dimensions for LoRA training
|
||||
- Any workflow step that needs a clean resize before the next node
|
||||
- Normalize mixed-resolution batches to a consistent size
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| image | IMAGE | — | Single image or batch |
|
||||
| longest_side | INT | 1536 | Target size for longest side |
|
||||
| multiple_of | INT | 8 | Snap dimensions to this multiple (8 = SD/Flux compatible) |
|
||||
| interpolation | ENUM | lanczos | lanczos / bicubic / bilinear / nearest |
|
||||
| upscale_if_smaller | BOOLEAN | false | Also upscale images already smaller than longest_side |
|
||||
| Image | IMAGE | - | Single image or batch |
|
||||
| Longest side (px) | INT | 1536 | 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 (8 = SD/Flux compatible) |
|
||||
| Interpolation method | ENUM | lanczos | lanczos / bicubic / bilinear / nearest |
|
||||
| Upscale if smaller | BOOLEAN | true | Upscale images smaller than longest_side. Turn off to only ever downscale, never upscale |
|
||||
|
||||
**Outputs:** `image`, `width`, `height`
|
||||
|
||||
<img src="assets/screenshot-smart-resize.png" width="700">
|
||||
|
||||
---
|
||||
|
||||
### 📁 Smart Batch Resize
|
||||
Load **all images from a folder**, resize each one by longest side, and save with the original filename + a suffix into a subfolder. Original filenames are always preserved.
|
||||
|
||||
Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| input_folder | STRING | D:/images/input | Source folder path |
|
||||
| longest_side | INT | 1536 | Target size for longest side |
|
||||
| filename_suffix | STRING | _Small | Appended to each output filename before the extension |
|
||||
| output_subfolder | STRING | resized | Subfolder created inside input_folder |
|
||||
| multiple_of | INT | 8 | Snap dimensions to this multiple |
|
||||
| interpolation | ENUM | lanczos | lanczos / bicubic / bilinear / nearest |
|
||||
| skip_if_exists | BOOLEAN | false | Skip files that already exist in output |
|
||||
|
||||
**Outputs:** `images` (list), `filenames` (list), `output_paths` (list), `count`
|
||||
**Outputs:** Image, Width, Height
|
||||
|
||||
---
|
||||
|
||||
### 📏 Smart Resolution Multiplier
|
||||
|
||||
<img src="assets/screenshot-smart-resolution-multiplier.png" width="100%">
|
||||
|
||||
Multiply image dimensions by a factor and output the results as integers.
|
||||
|
||||
SeedVR takes a single `resolution` INT (the longest side) — not a width and height separately. Standard math nodes give you a FLOAT or require multiple steps to get there. This node does it cleanly in one step: give it your image and a multiplier, and it outputs `width`, `height`, and `resolution` (longest side) ready to connect directly to SeedVR's `max_resolution` input.
|
||||
SeedVR2 takes a single `resolution` INT (the longest side) - not a width and height separately. Standard math nodes give you a FLOAT or require multiple steps to get there. This node does it cleanly in one step: give it your image and a multiplier, and it outputs `width`, `height`, and `resolution` (longest side) ready to connect directly to SeedVR2's `resolution` input.
|
||||
|
||||
Also useful for LoRA dataset prep — using mixed resolutions in your training set generally produces better results than training on a single fixed size. Smart Resolution Multiplier makes it easy to dynamically calculate the right target size per image rather than hardcoding a value.
|
||||
Also useful for LoRA dataset prep - using mixed resolutions in your training set generally produces better results than training on a single fixed size. Smart Resolution Multiplier makes it easy to dynamically calculate the right target size per image rather than hardcoding a value.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| image | IMAGE | — | Source image |
|
||||
| multiplier | FLOAT | 2.0 | Multiply width and height by this factor |
|
||||
| multiple_of | INT | 8 | Snap dimensions to this multiple |
|
||||
| Image | IMAGE | - | Source image - its current width/height are read directly, no separate Get Image Size node needed |
|
||||
| Multiplier | FLOAT | 2.0 | Multiply width and height by this factor |
|
||||
| Round to multiple of | INT | 8 | Snap dimensions to this multiple |
|
||||
|
||||
**Outputs:** `width`, `height`, `resolution` (longest side as INT → directly into SeedVR)
|
||||
**Outputs:** Width, Height, Resolution (longest side as INT → directly into SeedVR2)
|
||||
|
||||
<img src="assets/screenshot-smart-resolution-multiplier.png" width="700">
|
||||
---
|
||||
|
||||
### 📁 Smart Batch Resize
|
||||
|
||||
<img src="assets/screenshot-smart-batch-resize_v4.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, 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. 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 |
|
||||
|---|---|---|---|
|
||||
| Input folder | STRING | - | Source folder path (use Browse button or paste manually) |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
---
|
||||
|
||||
## Example workflows
|
||||
|
||||
Ready-to-load workflows are included in [`example_workflows/`](example_workflows):
|
||||
|
||||
- **`smart_prompt_controller_demo.json`** - Smart Prompt Controller + Smart Profile Switch driving 4 prompt categories with auto-switching resolution and filename label
|
||||
- **`seedvr2_smartresize_demo.json`** - Smart Resolution Multiplier → SeedVR2 upscale → Smart Resize, a clean pattern for upscaling without hardcoding target resolution
|
||||
- **`smart_batch_resize_demo.json`** - minimal Smart Batch Resize setup with a Note explaining outputs and settings
|
||||
|
||||
---
|
||||
|
||||
@@ -98,28 +157,32 @@ Also useful for LoRA dataset prep — using mixed resolutions in your training s
|
||||
- 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
|
||||
- No existing node outputs a ready-to-use `resolution` INT for SeedVR
|
||||
- No existing node cycles through multiple prompt lists with automatic list switching and counter
|
||||
- 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
|
||||
- Smart Prompt Controller + Smart Profile Switch handle **uneven category sizes automatically** - 5 headshot prompts and 10 fullbody prompts cycle correctly without padding lists to match or manually tracking where one category ends and the next begins
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
**ComfyUI Manager:** search for `ComfyUI-Craftopia` and install.
|
||||
**ComfyUI Manager:** search for `ComfyUI-CraftKit` and install.
|
||||
|
||||
**Manual:**
|
||||
```bash
|
||||
cd ComfyUI/custom_nodes
|
||||
git clone https://github.com/CraftopiaStudio/ComfyUI-Craftopia
|
||||
git clone https://github.com/CraftopiaStudio/ComfyUI-CraftKit
|
||||
```
|
||||
Restart ComfyUI. All nodes appear under **Craftopia** in the node menu:
|
||||
Restart ComfyUI. All nodes appear under **CraftKit** in the node menu:
|
||||
|
||||
- `Craftopia` → **Smart Prompt Controller 🎛️**
|
||||
- `Craftopia` → **Smart Resize 📐**
|
||||
- `Craftopia` → **Smart Batch Resize 📁**
|
||||
- `Craftopia` → **Smart Resolution Multiplier 📏**
|
||||
- `CraftKit` → **Smart Prompt Controller 📋**
|
||||
- `CraftKit` → **Smart Profile Switch 🧩**
|
||||
- `CraftKit` → **Smart Resize 📐**
|
||||
- `CraftKit` → **Smart Resolution Multiplier 📏**
|
||||
- `CraftKit` → **Smart Batch Resize 📁**
|
||||
|
||||
---
|
||||
|
||||
## Requirements
|
||||
Pillow, NumPy, PyTorch — all included with ComfyUI. No extra dependencies.
|
||||
Pillow, NumPy, PyTorch - all included with ComfyUI. No extra dependencies.
|
||||
|
||||
@@ -1,11 +1,170 @@
|
||||
WEB_DIRECTORY = "./js"
|
||||
|
||||
from .smart_resize import NODE_CLASS_MAPPINGS as A, NODE_DISPLAY_NAME_MAPPINGS as A_NAMES
|
||||
from .smart_batch_resize import NODE_CLASS_MAPPINGS as B, NODE_DISPLAY_NAME_MAPPINGS as B_NAMES
|
||||
from .smart_resolution_multiplier import NODE_CLASS_MAPPINGS as C, NODE_DISPLAY_NAME_MAPPINGS as C_NAMES
|
||||
from .smart_prompt_controller import NODE_CLASS_MAPPINGS as D, NODE_DISPLAY_NAME_MAPPINGS as D_NAMES
|
||||
from .smart_prompt_controller import NODE_CLASS_MAPPINGS as A, NODE_DISPLAY_NAME_MAPPINGS as A_NAMES
|
||||
from .smart_profile_switch import NODE_CLASS_MAPPINGS as B, NODE_DISPLAY_NAME_MAPPINGS as B_NAMES
|
||||
from .smart_resize import NODE_CLASS_MAPPINGS as C, NODE_DISPLAY_NAME_MAPPINGS as C_NAMES
|
||||
from .smart_batch_resize import NODE_CLASS_MAPPINGS as D, NODE_DISPLAY_NAME_MAPPINGS as D_NAMES
|
||||
from .smart_resolution_multiplier import NODE_CLASS_MAPPINGS as E, NODE_DISPLAY_NAME_MAPPINGS as E_NAMES
|
||||
|
||||
NODE_CLASS_MAPPINGS = {**A, **B, **C, **D}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES, **D_NAMES}
|
||||
NODE_CLASS_MAPPINGS = {**A, **B, **C, **D, **E}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES, **D_NAMES, **E_NAMES}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
try:
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import os
|
||||
import threading
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
_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:"
|
||||
|
||||
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;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class CraftKitFolderPicker
|
||||
{
|
||||
[ComImport, Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7")]
|
||||
private class FileOpenDialogRCW { }
|
||||
|
||||
[ComImport, Guid("42f85136-db7e-439c-85f1-e4075d135fc8"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IFileDialog
|
||||
{
|
||||
[PreserveSig] int Show(IntPtr parent);
|
||||
void SetFileTypes(uint cFileTypes, IntPtr rgFilterSpec);
|
||||
void SetFileTypeIndex(uint iFileType);
|
||||
void GetFileTypeIndex(out uint piFileType);
|
||||
void Advise(IntPtr pfde, out uint pdwCookie);
|
||||
void Unadvise(uint dwCookie);
|
||||
void SetOptions(uint fos);
|
||||
void GetOptions(out uint fos);
|
||||
void SetDefaultFolder(IntPtr psi);
|
||||
void SetFolder(IntPtr psi);
|
||||
void GetFolder(out IntPtr ppsi);
|
||||
void GetCurrentSelection(out IntPtr ppsi);
|
||||
void SetFileName(string pszName);
|
||||
void GetFileName(out IntPtr pszName);
|
||||
void SetTitle(string pszTitle);
|
||||
void SetOkButtonLabel(string pszText);
|
||||
void SetFileNameLabel(string pszLabel);
|
||||
void GetResult(out IShellItemLocal ppsi);
|
||||
void AddPlace(IntPtr psi, uint fdap);
|
||||
void SetDefaultExtension(string pszDefaultExtension);
|
||||
void Close(int hr);
|
||||
void SetClientGuid(ref Guid guid);
|
||||
void ClearClientData();
|
||||
void SetFilter(IntPtr pFilter);
|
||||
}
|
||||
|
||||
[ComImport, Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
private interface IShellItemLocal
|
||||
{
|
||||
void BindToHandler(IntPtr pbc, ref Guid bhid, ref Guid riid, out IntPtr ppv);
|
||||
void GetParent(out IShellItemLocal ppsi);
|
||||
void GetDisplayName(uint sigdnName, out IntPtr ppszName);
|
||||
void GetAttributes(uint sfgaoMask, out uint psfgaoAttribs);
|
||||
void Compare(IShellItemLocal psi, uint hint, out int piOrder);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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 = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
item.GetDisplayName(0x80058000u, out pszPath); // SIGDN_FILESYSPATH
|
||||
return Marshal.PtrToStringUni(pszPath);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pszPath != IntPtr.Zero) Marshal.FreeCoTaskMem(pszPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
$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()
|
||||
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
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, text=True, timeout=300
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
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.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.")
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1198 318">
|
||||
<!-- Generator: Adobe Illustrator 30.6.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 109) -->
|
||||
<defs>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #7c7c82;
|
||||
font-size: 18px;
|
||||
letter-spacing: .11em;
|
||||
}
|
||||
|
||||
.st0, .st1, .st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st0, .st2 {
|
||||
font-family: Inter-Medium, Inter;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #e6e6e9;
|
||||
font-family: SegoeUI-Bold, 'Segoe UI';
|
||||
font-size: 69px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0em;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #252527;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #473333;
|
||||
}
|
||||
|
||||
.st5 {
|
||||
fill: url(#linear-gradient1);
|
||||
}
|
||||
|
||||
.st5, .st6 {
|
||||
stroke: #3a3a3d;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.st6 {
|
||||
fill: url(#linear-gradient);
|
||||
}
|
||||
|
||||
.st7 {
|
||||
fill: #f28f41;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
fill: #c9c9cc;
|
||||
font-size: 27px;
|
||||
}
|
||||
</style>
|
||||
<linearGradient id="linear-gradient" x1="293.25" y1="555.26" x2="293.25" y2="554.26" gradientTransform="translate(-350133 175462) scale(1196 -316)" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#1e1e20"/>
|
||||
<stop offset="1" stop-color="#141415"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="linear-gradient1" x1="293.6" x2="293.6" xlink:href="#linear-gradient"/>
|
||||
</defs>
|
||||
<g id="Layer_2">
|
||||
<rect class="st6" x="1" y="1" width="1196" height="316" rx="20" ry="20"/>
|
||||
</g>
|
||||
<g id="Layer_3">
|
||||
<g>
|
||||
<path class="st5" d="M1015.83,43.69c-63.58,0-115.31,51.73-115.31,115.31s51.73,115.31,115.31,115.31,115.31-51.73,115.31-115.31-51.73-115.31-115.31-115.31Z"/>
|
||||
<path class="st3" d="M1178.92,159c0-75.96-52.19-139.95-122.6-158h-80.98c-70.41,18.05-122.6,82.04-122.6,158s52.19,139.95,122.6,158h80.98c70.4-18.05,122.6-82.05,122.6-158ZM1015.83,274.31c-63.58,0-115.31-51.73-115.31-115.31s51.73-115.31,115.31-115.31,115.31,51.73,115.31,115.31-51.73,115.31-115.31,115.31Z"/>
|
||||
</g>
|
||||
<g>
|
||||
<path class="st4" d="M1006.29,92.19v-33.47c-47.89,4.39-86.19,42.43-90.95,90.21h33.76c4.6-29.27,27.84-52.37,57.19-56.74Z"/>
|
||||
<path class="st4" d="M948.94,169.07h-33.59c4.76,47.78,43.06,85.82,90.95,90.21v-32.41c-29.7-4.43-53.16-28.03-57.35-57.8Z"/>
|
||||
</g>
|
||||
<path class="st4" d="M1006.29,53.56V20.1c-68.91,4.93-124.13,59.99-129.32,128.83h33.21c4.8-50.62,45.38-90.95,96.11-95.37Z"/>
|
||||
<path class="st4" d="M910.18,169.07h-33.3c4.7,69.34,60.14,124.94,129.4,129.9v-34.52c-50.73-4.42-91.31-44.75-96.11-95.37Z"/>
|
||||
<g>
|
||||
<path class="st7" d="M1026.42,259.13c35.79-3.86,66-26.54,80.55-57.91h-32.11c8.4-11.77,13.34-26.16,13.34-41.69s-4.95-29.92-13.34-41.69h32.59c-14.35-31.92-44.84-55.07-81.03-58.97v29.43c-3.32-.47-6.71-.72-10.16-.72-39.67,0-71.95,32.27-71.95,71.95s32.27,71.95,71.95,71.95c3.45,0,6.84-.25,10.16-.72v28.37ZM1005.05,94.5c-2.69,3.65-5.12,8.71-7.22,15.12-1.44,4.38-2.66,9.23-3.66,14.43h-16.67c6.17-14.47,15.99-25.25,27.55-29.55ZM991.04,157.1h-20.44c.25-10.12,2.03-19.67,5.01-28.18h17.71c-1.38,8.73-2.16,18.28-2.27,28.18ZM993.31,190.14h-17.71c-2.98-8.5-4.76-18.06-5.01-28.18h20.44c.11,9.9.89,19.45,2.27,28.18ZM990.37,97.65c-3.66,3.11-7.07,6.9-10.15,11.34-3.16,4.56-5.83,9.62-8,15.06h-12.87c7.32-11.7,18.15-21,31.01-26.4ZM956.59,128.92h13.87c-2.88,8.74-4.5,18.29-4.73,28.18h-16.49c.36-10.12,2.97-19.67,7.35-28.18ZM949.23,161.97h16.49c.23,9.89,1.85,19.44,4.73,28.18h-13.87c-4.38-8.5-6.99-18.06-7.35-28.18ZM959.36,195.01h12.87c2.17,5.44,4.84,10.5,8,15.06,3.07,4.44,6.48,8.23,10.15,11.34-12.86-5.4-23.69-14.7-31.01-26.4ZM977.5,195.01h16.67c1,5.2,2.22,10.05,3.66,14.43,2.1,6.41,4.53,11.47,7.22,15.12-11.56-4.3-21.38-15.08-27.55-29.55ZM1013.83,226.03c-5.62-2.6-11.27-13.94-14.69-31.03h14.69v31.03ZM1013.83,190.14h-15.58c-1.37-8.33-2.23-17.82-2.34-28.18h17.92v28.18ZM1013.83,157.1h-17.92c.12-10.36.97-19.84,2.34-28.18h15.58v28.18ZM1013.83,124.05h-14.69c3.42-17.09,9.07-28.43,14.69-31.03v31.03ZM1060.3,195.01h12.87c-7.32,11.7-18.15,21-31.01,26.4,3.66-3.11,7.07-6.9,10.15-11.34,3.16-4.56,5.83-9.62,8-15.06ZM1056.92,190.14h-17.71c1.38-8.73,2.16-18.28,2.27-28.18h20.44c-.25,10.12-2.03,19.67-5.01,28.18ZM1041.48,157.1c-.11-9.9-.89-19.45-2.27-28.18h17.71c2.98,8.5,4.76,18.06,5.01,28.18h-20.44ZM1060.3,124.05c-2.17-5.44-4.84-10.5-8-15.06-3.07-4.44-6.48-8.23-10.15-11.34,12.86,5.4,23.69,14.69,31.01,26.4h-12.87ZM1055.03,124.05h-16.67c-1-5.2-2.22-10.05-3.66-14.43-2.1-6.41-4.53-11.47-7.22-15.12,11.56,4.3,21.38,15.08,27.55,29.55ZM1055.03,195.01c-6.17,14.47-15.99,25.26-27.55,29.55,2.69-3.65,5.12-8.71,7.22-15.12,1.44-4.38,2.66-9.23,3.66-14.43h16.67ZM1075.94,190.14h-13.87c2.88-8.74,4.5-18.29,4.73-28.18h16.49c-.36,10.12-2.97,19.67-7.35,28.18ZM1083.29,157.1h-16.49c-.23-9.89-1.85-19.44-4.73-28.18h13.87c4.38,8.5,6.99,18.06,7.35,28.18ZM1018.7,93.03c5.62,2.6,11.27,13.94,14.69,31.03h-14.69v-31.03ZM1018.7,128.92h15.58c1.37,8.33,2.23,17.82,2.34,28.18h-17.92v-28.18ZM1018.7,161.97h17.92c-.12,10.36-.97,19.84-2.34,28.18h-15.58v-28.18ZM1018.7,226.03v-31.03h14.69c-3.42,17.09-9.07,28.43-14.69,31.03Z"/>
|
||||
<path class="st7" d="M1113.1,117.84h36.74c-16.82-53.73-65.25-93.6-123.42-97.76v33.61c39.08,4,71.93,29.35,86.68,64.16Z"/>
|
||||
<path class="st7" d="M1026.42,264.31v34.67c58.16-4.16,106.6-44.03,123.42-97.76h-37.22c-14.96,34.26-47.52,59.13-86.19,63.09Z"/>
|
||||
</g>
|
||||
<path class="st3" d="M805.08,159c0-62.85,27.66-119.35,71.44-158h-59.95c-35.65,44.79-55.02,99.9-55.02,158s19.37,113.21,55.02,158h59.95c-43.78-38.65-71.44-95.15-71.44-158Z"/>
|
||||
<g>
|
||||
<path class="st3" d="M1197,51.44v-30.44c0-11.05-8.95-20-20-20h-21.86c16.43,14.5,30.58,31.51,41.86,50.44Z"/>
|
||||
<path class="st3" d="M1155.14,317h21.86c11.05,0,20-8.95,20-20v-30.44c-11.28,18.92-25.43,35.93-41.86,50.44Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Layer_1">
|
||||
<text class="st1" transform="translate(77 187)"><tspan x="0" y="0">CraftKit</tspan></text>
|
||||
<text class="st2" transform="translate(81 237)"><tspan x="0" y="0">Smart resize, batch processing & prompt control for LoRA workflows</tspan></text>
|
||||
<text class="st0" transform="translate(81 279)"><tspan x="0" y="0">SMART RESIZE · BATCH RESIZE · RESOLUTION · PROMPT CONTROLLER</tspan></text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 156 KiB |
|
Before Width: | Height: | Size: 751 KiB After Width: | Height: | Size: 179 KiB |
|
Before Width: | Height: | Size: 665 KiB After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,721 @@
|
||||
# CraftKit Nodes 2.0 Widget Compatibility — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make every custom-drawn JS widget across ComfyUI-CraftKit's 4 nodes (section dividers, status boxes, longest-side preset chips) render correctly under both classic LiteGraph canvas rendering and ComfyUI's "Nodes 2.0" (Modern Node Design / Vue) renderer, without regressing classic-mode layout or widget-value save/reload.
|
||||
|
||||
**Architecture:** ComfyUI's Nodes 2.0 Vue layer looks up how to render each widget by matching `widget.type` against a registry of known component names (`button`, `string`, `toggle`, `combo`, ...). CraftKit currently (ab)uses `type: "button"` for widgets that actually paint custom canvas content via a `draw()` override — Vue's registry intercepts `"button"`, renders its own generic `<button>` showing the raw internal widget name, and never calls our `draw()`. The fix has two parts:
|
||||
1. **Non-interactive widgets** (dividers, status boxes): switch from `node.addWidget("button", ...)` to `node.addCustomWidget({type: "custom", ...})`. `"custom"` is not a recognized Vue registry name, so Vue falls through to `WidgetLegacy.vue`, which creates its own canvas and calls our `draw(ctx, node, widgetWidth, y, height)` unmodified — this works automatically in both renderers with one implementation, **provided** `computeSize(width)` returns `[width, height]` (passing the given width through) rather than a hardcoded value, which is confirmed by a real, currently-working reference implementation (ComfyUI-Pixaroma's Preview Image button row) and by CraftKit's own prior history (a code comment in `smart_resize.js` documents that an earlier bare-custom-widget attempt with a broken `computeSize` "forced the node width and broke the layout on click" — this plan avoids repeating that mistake by copying Pixaroma's verified working shape exactly).
|
||||
2. **The interactive size-preset chip picker** (click-to-select, needs hit-testing + hover): dual-path, exactly mirroring Pixaroma's own solution to the identical problem — a canvas `addCustomWidget` with a `mouse(event, pos, node)` handler in classic mode, and a real `addDOMWidget` with actual `<button>` elements in Nodes 2.0 mode, chosen once at node-creation time via an `isVueNodes()` check.
|
||||
|
||||
**Tech Stack:** Vanilla JS ComfyUI frontend extensions (`app.registerExtension`), LiteGraph `LGraphNode.addCustomWidget`/`addDOMWidget` APIs, no build step (raw ES modules loaded via `WEB_DIRECTORY = "./js"`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Do not change any Python file (`INPUT_TYPES`, `run()`/`route()`/`control()` signatures) — this is a frontend-only fix, backend node behavior is unaffected.
|
||||
- Do not change `widget.name` for any existing real (serialized) Python widget — only touches the JS-added decorative widgets (dividers, status, preset picker), which are already `serialize: false` and excluded from `widgets_values`.
|
||||
- Every widget factory must keep the exact same visual appearance in classic mode as it has today (same colors `#888`/`#333`/`#f28f41`/`#2a2a2a`/`#111`/`#666`/`#ccc`/`#3a3a3a`, same fonts, same layout) — this is a compatibility fix, not a redesign.
|
||||
- Preserve the existing "Python widgets must stay in their original relative order in `this.widgets`" invariant documented in each file's serialize-override comment — the splice-to-reposition logic must still work after switching widget-creation calls.
|
||||
- No automated JS test harness exists in this repo. Verification is manual, via the Claude Browser MCP tool: add the node fresh, screenshot in classic mode, toggle Settings → Comfy → "Modern Node Design (Nodes 2.0)" on, reload, screenshot again, confirm no raw internal names (`_div_...`, `_sbr_status`, `size_presets`) are visible as literal button labels in either mode.
|
||||
- ComfyUI must be running locally at `http://127.0.0.1:8188` for verification steps (start via `Start ComfyUI.bat` in `D:\AI\ComfyUI-Easy-Install`, or ask the user to start it — the project's `restart_comfyui` MCP tool has a known path bug on this machine, see `project_roadmap.md` memory).
|
||||
- Commit after each task per the user's standing workflow (small, reviewable commits; user commits via VS Code, not this plan's `git commit` steps — treat the "Commit" step in each task as "ready to hand off for the user to commit in VS Code" rather than actually running `git commit`).
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create:** `js/shared/nodes2.mjs` — one helper, `isVueNodes()`, used to branch the preset-picker widget between classic and DOM implementations.
|
||||
- **Create:** `js/shared/canvas_widgets.mjs` — two factories, `createDividerWidget(label)` and `createStatusWidget(placeholderText)`, both draw-only `addCustomWidget`-based widgets that work unmodified in both renderers. Replaces the hand-rolled, duplicated divider/status code currently living separately in `smart_batch_resize.js`, `smart_profile_switch.js`, and `smart_prompt_controller.js`.
|
||||
- **Create:** `js/shared/preset_picker_widget.mjs` — one factory, `createPresetPickerWidget(targetWidget, presets)`, dual-path (classic canvas vs Nodes 2.0 DOM). Replaces the near-identical, duplicated preset-chip code currently hand-rolled separately in `smart_batch_resize.js` and `smart_resize.js`.
|
||||
- **Modify:** `js/smart_batch_resize.js` — use all three shared factories; remove the local `addSectionDivider`, the local status-widget block, and the local preset-chip block.
|
||||
- **Modify:** `js/smart_resize.js` — use `createPresetPickerWidget`; remove the local preset-chip block.
|
||||
- **Modify:** `js/smart_profile_switch.js` — use `createDividerWidget` (called once per slot with `"SLOT 1"`..`"SLOT 4"` as the label) and `createStatusWidget`; remove the local divider-array and status-widget blocks.
|
||||
- **Modify:** `js/smart_prompt_controller.js` — use `createStatusWidget`; remove the local status-widget block.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `isVueNodes()` helper
|
||||
|
||||
**Files:**
|
||||
- Create: `js/shared/nodes2.mjs`
|
||||
- Test: manual (Task 8 covers live verification; this task's own check is a syntax/console check)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `export function isVueNodes(): boolean` — used by Task 3 (`preset_picker_widget.mjs`).
|
||||
|
||||
- [x] **Step 1: Write the helper**
|
||||
|
||||
```js
|
||||
// js/shared/nodes2.mjs
|
||||
// Detects whether ComfyUI's "Modern Node Design (Nodes 2.0)" Vue renderer is
|
||||
// active, so widgets that need genuinely different implementations per
|
||||
// renderer (anything with hit-testing/hover, not just a draw() override) can
|
||||
// pick the right one at node-creation time.
|
||||
export function isVueNodes() {
|
||||
return !!window.LiteGraph?.vueNodesMode;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Verify it loads with no console errors**
|
||||
|
||||
In the Claude Browser MCP tool: navigate to `http://127.0.0.1:8188/`, open devtools console (`mcp__Claude_Browser__read_console_messages` with `onlyErrors: true`), confirm no new errors referencing `nodes2.mjs`. (The file isn't imported anywhere yet at this point, so this step mainly confirms ComfyUI itself is still healthy before continuing — the real import-error check happens in Task 4.)
|
||||
|
||||
- [x] **Step 3: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/shared/nodes2.mjs
|
||||
git commit -m "feat: add isVueNodes() helper for Nodes 2.0 widget branching"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Shared divider + status widget factories
|
||||
|
||||
**Files:**
|
||||
- Create: `js/shared/canvas_widgets.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing (pure LiteGraph API usage).
|
||||
- Produces:
|
||||
- `export function createDividerWidget(label: string): object` — a widget descriptor ready to pass to `node.addCustomWidget(...)`. Visual output identical to the current `_div_${label}` widgets in `smart_batch_resize.js`/`smart_profile_switch.js`.
|
||||
- `export function createStatusWidget(placeholderText: string): object` — a widget descriptor ready to pass to `node.addCustomWidget(...)`. Has a mutable `._text` property the caller sets in `onExecuted` (same as the existing `statusWidget._text` pattern). Visual output identical to the current `_sbr_status`/`_sps_status`/`_spc_result` widgets.
|
||||
|
||||
- [x] **Step 1: Write `createDividerWidget`**
|
||||
|
||||
```js
|
||||
// js/shared/canvas_widgets.mjs
|
||||
// Draw-only widgets (no mouse/click handling) shared across CraftKit nodes.
|
||||
// type: "custom" is deliberate — it is not a name ComfyUI's Nodes 2.0 Vue
|
||||
// widget registry recognizes, so Vue falls through to its WidgetLegacy
|
||||
// bridge, which calls our draw() unmodified in both classic LiteGraph and
|
||||
// Nodes 2.0. computeSize MUST return [width, height] (passing width through)
|
||||
// rather than a hardcoded value — a fixed/wrong computeSize previously broke
|
||||
// node layout on click when this was tried without care (see smart_resize.js
|
||||
// git history / the comment on the preset-chip widget in that file).
|
||||
|
||||
const DIVIDER_HEIGHT = 20;
|
||||
|
||||
export function createDividerWidget(label) {
|
||||
return {
|
||||
name: `_div_${label}`,
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
computeSize(width) {
|
||||
return [width, DIVIDER_HEIGHT];
|
||||
},
|
||||
draw(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();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Write `createStatusWidget`**
|
||||
|
||||
```js
|
||||
const STATUS_HEIGHT = 24;
|
||||
|
||||
export function createStatusWidget(placeholderText) {
|
||||
return {
|
||||
name: "_status",
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
_text: "",
|
||||
computeSize(width) {
|
||||
return [width, STATUS_HEIGHT];
|
||||
},
|
||||
draw(ctx, node, widgetWidth, y, height) {
|
||||
const margin = 14;
|
||||
const innerW = widgetWidth - margin * 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(margin, y + 2, innerW, height - 4, 4);
|
||||
else ctx.rect(margin, y + 2, innerW, height - 4);
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = this._text ? "#3a3a3a" : "#222";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = this._text ? "#f28f41" : "#666";
|
||||
ctx.fillText(this._text || placeholderText, widgetWidth / 2, y + height / 2);
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Verify no console errors on load**
|
||||
|
||||
Same check as Task 1 Step 2 — file isn't imported yet, this just confirms it's syntactically valid by checking it parses (open it directly: `mcp__Claude_Browser__navigate` to `http://127.0.0.1:8188/extensions/ComfyUI-CraftKit/shared/canvas_widgets.mjs` and confirm the browser shows the raw JS text, not a 404 or a syntax-highlighted error page).
|
||||
|
||||
- [x] **Step 4: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/shared/canvas_widgets.mjs
|
||||
git commit -m "feat: add shared divider/status widget factories for Nodes 2.0 compat"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Shared preset-picker widget factory (dual-path)
|
||||
|
||||
**Files:**
|
||||
- Create: `js/shared/preset_picker_widget.mjs`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `isVueNodes()` from `js/shared/nodes2.mjs` (Task 1).
|
||||
- Produces: `export function createPresetPickerWidget(node, targetWidget, presets: number[]): object` — returns a widget already added to `node` via `addCustomWidget` (classic) or `addDOMWidget` (Nodes 2.0). The caller is responsible for repositioning it in `node.widgets` (same splice pattern the call sites already use) and does NOT need to call `node.addCustomWidget`/`addDOMWidget` itself — this factory does that internally, since the two paths need different LiteGraph APIs.
|
||||
|
||||
- [x] **Step 1: Write the classic-mode implementation**
|
||||
|
||||
```js
|
||||
// js/shared/preset_picker_widget.mjs
|
||||
import { isVueNodes } from "./nodes2.mjs";
|
||||
|
||||
const PRESET_ROW_HEIGHT = 22;
|
||||
|
||||
function createClassicPresetWidget(node, targetWidget, presets) {
|
||||
const widget = {
|
||||
name: "size_presets",
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
_rects: [],
|
||||
computeSize(width) {
|
||||
return [width, PRESET_ROW_HEIGHT];
|
||||
},
|
||||
draw(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 = targetWidget.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 = Number(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();
|
||||
},
|
||||
mouse(event, pos, node) {
|
||||
if (event.type !== "pointerdown" && event.type !== "mousedown") return false;
|
||||
const [lx, ly] = pos;
|
||||
for (const r of this._rects) {
|
||||
if (lx >= r.x1 && lx <= r.x2 && ly >= r.y1 && ly <= r.y2) {
|
||||
targetWidget.value = r.size;
|
||||
targetWidget.callback?.(r.size, app.canvas, node);
|
||||
node.setDirtyCanvas(true, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
return node.addCustomWidget(widget);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 2: Write the Nodes 2.0 DOM implementation**
|
||||
|
||||
```js
|
||||
function createVuePresetWidget(node, targetWidget, presets) {
|
||||
const root = document.createElement("div");
|
||||
root.style.display = "flex";
|
||||
root.style.gap = "5px";
|
||||
root.style.padding = "0 14px";
|
||||
root.style.width = "100%";
|
||||
root.style.boxSizing = "border-box";
|
||||
|
||||
const buttons = presets.map((size) => {
|
||||
const btn = document.createElement("button");
|
||||
btn.textContent = String(size);
|
||||
btn.style.flex = "1";
|
||||
btn.style.padding = "4px 0";
|
||||
btn.style.borderRadius = "4px";
|
||||
btn.style.border = "1px solid #555";
|
||||
btn.style.background = "#2a2a2a";
|
||||
btn.style.color = "#ccc";
|
||||
btn.style.fontSize = "11px";
|
||||
btn.style.fontFamily = "sans-serif";
|
||||
btn.style.cursor = "pointer";
|
||||
const syncActive = () => {
|
||||
const active = Number(targetWidget.value) === size;
|
||||
btn.style.background = active ? "#f28f41" : "#2a2a2a";
|
||||
btn.style.borderColor = active ? "#f28f41" : "#555";
|
||||
btn.style.color = active ? "#111" : "#ccc";
|
||||
};
|
||||
btn.addEventListener("click", () => {
|
||||
targetWidget.value = size;
|
||||
targetWidget.callback?.(size, app.canvas, node);
|
||||
for (const b of buttons) b.syncActive?.();
|
||||
node.setDirtyCanvas(true, true);
|
||||
});
|
||||
btn.syncActive = syncActive;
|
||||
syncActive();
|
||||
root.appendChild(btn);
|
||||
return btn;
|
||||
});
|
||||
|
||||
const widget = node.addDOMWidget("size_presets", "craftkit_preset_picker", root, {
|
||||
serialize: false,
|
||||
hideOnZoom: false,
|
||||
});
|
||||
// Fixed-height row, not a flexible grower — same reasoning as Pixaroma's
|
||||
// button-row DOM widget (see js/shared/nodes2.mjs comment).
|
||||
widget.computeLayoutSize = undefined;
|
||||
return widget;
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Write the dispatcher**
|
||||
|
||||
```js
|
||||
export function createPresetPickerWidget(node, targetWidget, presets) {
|
||||
return isVueNodes()
|
||||
? createVuePresetWidget(node, targetWidget, presets)
|
||||
: createClassicPresetWidget(node, targetWidget, presets);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 4: Add the `app` import needed by both paths**
|
||||
|
||||
Add at the top of the file: `import { app } from "../../../scripts/app.js";` (three levels up from `js/shared/` to reach ComfyUI's `scripts/` — verify this relative path against how `smart_batch_resize.js` imports it today, `import { app } from "../../scripts/app.js";`, and adjust the level count for the extra `shared/` nesting: `js/shared/preset_picker_widget.mjs` → `scripts/app.js` is `../../../scripts/app.js`).
|
||||
|
||||
- [x] **Step 5: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/shared/preset_picker_widget.mjs
|
||||
git commit -m "feat: add dual-path (classic/Nodes 2.0) preset-picker widget factory"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewire `smart_batch_resize.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/smart_batch_resize.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createDividerWidget`, `createStatusWidget` (Task 2), `createPresetPickerWidget` (Task 3).
|
||||
|
||||
- [x] **Step 1: Add imports**
|
||||
|
||||
At the top of `js/smart_batch_resize.js`, after the existing `import { app } from "../../scripts/app.js";`:
|
||||
|
||||
```js
|
||||
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs";
|
||||
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs";
|
||||
```
|
||||
|
||||
- [x] **Step 2: Replace the local preset-chip block**
|
||||
|
||||
Find the block starting `const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");` through the `if (pwIdx !== lsIdx + 1) { ... }` reposition block that follows it (this is the whole "Compact preset chips for longest_side" section). Replace the widget-creation part (everything between finding `longestSideWidget` and the "Move preset row" comment) with:
|
||||
|
||||
```js
|
||||
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
|
||||
if (longestSideWidget) {
|
||||
const PRESETS = [512, 768, 1024, 1536];
|
||||
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Replace the local `addSectionDivider` function and its 5 call sites**
|
||||
|
||||
Delete the `addSectionDivider` function definition (the block from `const addSectionDivider = (beforeWidgetName, label) => {` through its closing `};`) and the 5 calls below it. Replace with:
|
||||
|
||||
```js
|
||||
const addSectionDivider = (beforeWidgetName, label) => {
|
||||
const target = node.widgets.find(w => w.name === beforeWidgetName);
|
||||
if (!target) return;
|
||||
const div = createDividerWidget(label);
|
||||
node.addCustomWidget(div);
|
||||
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("output_format", "OUTPUT FORMAT");
|
||||
addSectionDivider("skip_if_exists", "OPTIONS");
|
||||
```
|
||||
|
||||
(Note the 5th call's target changed from `"skip_if_exists"` to match the current on-disk section order — verify against the live file before applying, since Task order in this plan was written after the RESIZE→FILENAME→OUTPUT LOCATION→OUTPUT FORMAT→OPTIONS reorder; read the file first and match whatever the current 5 calls actually are, only changing the widget-creation mechanism, not the section order/labels.)
|
||||
|
||||
- [x] **Step 4: Replace the local status widget block**
|
||||
|
||||
Find `const statusWidget = node.addWidget("button", "_sbr_status", null, () => {}, { serialize: false });` through the end of its `statusWidget.draw = function (...) {...};` block. Replace with:
|
||||
|
||||
```js
|
||||
const statusWidget = createStatusWidget("— RUN TO PROCESS —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
```
|
||||
|
||||
- [x] **Step 5: Verify `onExecuted` still works unmodified**
|
||||
|
||||
Confirm the existing `node.onExecuted` override (later in the file) still reads `statusWidget._text = output.text[0];` — no change needed there since `createStatusWidget`'s returned object still exposes a mutable `._text` property.
|
||||
|
||||
- [x] **Step 6: Manual verification — classic mode**
|
||||
|
||||
Using the Claude Browser MCP tool: navigate to `http://127.0.0.1:8188/`, confirm Settings → Comfy → "Modern Node Design (Nodes 2.0)" is OFF, add a fresh Smart Batch Resize node, screenshot. Confirm: 5 section labels (RESIZE/FILENAME/OUTPUT LOCATION/OUTPUT FORMAT/OPTIONS) draw with the label+line style (not as buttons), the 4 preset chips (512/768/1024/1536) draw as a colored chip row and clicking one updates `longest_side`, the status box shows "— RUN TO PROCESS —" placeholder text.
|
||||
|
||||
- [x] **Step 7: Manual verification — Nodes 2.0 mode**
|
||||
|
||||
Toggle Settings → Comfy → "Modern Node Design (Nodes 2.0)" ON, reload the page, add a fresh Smart Batch Resize node, screenshot. Confirm: same 5 section labels draw correctly (not literal `_div_RESIZE` button text), the preset row now shows as real `<button>` elements and clicking one updates `longest_side` and highlights the clicked button, the status box still shows correctly.
|
||||
|
||||
- [x] **Step 8: Save/reload regression check (both modes)**
|
||||
|
||||
In whichever mode is currently active, change `longest_side` to a non-default value (e.g. click the "768" preset), save the workflow (Ctrl+S), reload the page, confirm the node reopens with `longest_side = 768` and every other widget value in its correct position (no value-shift — the serialize-override logic in this file is unchanged by this task, so this step is a regression check, not a new fix).
|
||||
|
||||
- [x] **Step 9: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/smart_batch_resize.js
|
||||
git commit -m "fix: render Smart Batch Resize's dividers/status/presets correctly under Nodes 2.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rewire `smart_resize.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/smart_resize.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createPresetPickerWidget` (Task 3).
|
||||
|
||||
- [x] **Step 1: Add import**
|
||||
|
||||
After `import { app } from "../../scripts/app.js";`:
|
||||
|
||||
```js
|
||||
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs";
|
||||
```
|
||||
|
||||
- [x] **Step 2: Replace the local preset-chip block**
|
||||
|
||||
Replace the entire block from `const presetWidget = node.addWidget("button", "size_presets", ...)` through the end of `presetWidget.draw = function (...) {...};` with:
|
||||
|
||||
```js
|
||||
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
||||
```
|
||||
|
||||
Keep the `const PRESETS = [512, 768, 1024, 1536];` line above it, and keep the "Move preset row to right after longest_side" reposition block below it unchanged (it already operates on `presetWidget` by reference/position, not by how it was created).
|
||||
|
||||
- [x] **Step 3: Manual verification — classic mode**
|
||||
|
||||
Same procedure as Task 4 Step 6, but for Smart Resize: add a fresh node, confirm the preset chips draw and work.
|
||||
|
||||
- [x] **Step 4: Manual verification — Nodes 2.0 mode**
|
||||
|
||||
Same procedure as Task 4 Step 7, but for Smart Resize.
|
||||
|
||||
- [x] **Step 5: Save/reload regression check**
|
||||
|
||||
Same procedure as Task 4 Step 8, but for Smart Resize (change `longest_side` via a preset, save, reload, confirm no value-shift across `longest_side`, `multiple_of`, `interpolation`, `upscale_if_smaller`).
|
||||
|
||||
- [x] **Step 6: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/smart_resize.js
|
||||
git commit -m "fix: render Smart Resize's preset picker correctly under Nodes 2.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Rewire `smart_profile_switch.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/smart_profile_switch.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createDividerWidget`, `createStatusWidget` (Task 2).
|
||||
|
||||
- [x] **Step 1: Add import**
|
||||
|
||||
After `import { app } from "../../scripts/app.js";`:
|
||||
|
||||
```js
|
||||
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs";
|
||||
```
|
||||
|
||||
- [x] **Step 2: Replace the local divider-array block**
|
||||
|
||||
Find the block that builds `const dividers = [];` with a `for (let i = 1; i <= 4; i++) { ... }` loop creating `_div_${i}` widgets with a per-slot `draw` closure. Replace with:
|
||||
|
||||
```js
|
||||
const dividers = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const div = createDividerWidget(`SLOT ${i}`);
|
||||
node.addCustomWidget(div);
|
||||
dividers.push(div);
|
||||
}
|
||||
```
|
||||
|
||||
- [x] **Step 3: Replace the local status widget block**
|
||||
|
||||
Find `const statusWidget = node.addWidget("button", "_sps_status", null, () => {}, { serialize: false });` through the end of its `statusWidget.draw = function (...) {...};` block. Replace with:
|
||||
|
||||
```js
|
||||
const statusWidget = createStatusWidget("— RUN TO SEE RESULT —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
```
|
||||
|
||||
(Confirm the exact current placeholder text in the live file before applying — copy it verbatim rather than assuming, since this plan is written from a description of the file, not a fresh read of it at execution time.)
|
||||
|
||||
- [x] **Step 4: Verify the existing reorder logic is unaffected**
|
||||
|
||||
The file's `node.widgets = [indexWidget, ...slotWidgets, statusWidget]`-style reorder logic (with the null-guard/leftover-append fix from M-20) already operates on `dividers[i-1]` and `statusWidget` by reference — no change needed there since `createDividerWidget`/`createStatusWidget` still return plain objects usable the same way.
|
||||
|
||||
- [x] **Step 5: Manual verification — classic mode**
|
||||
|
||||
Add a fresh Smart Profile Switch node with Nodes 2.0 OFF, screenshot, confirm all 4 "SLOT N" dividers and the status box draw correctly (not as buttons).
|
||||
|
||||
- [x] **Step 6: Manual verification — Nodes 2.0 mode**
|
||||
|
||||
Toggle Nodes 2.0 ON, reload, add a fresh Smart Profile Switch node, screenshot, confirm the same.
|
||||
|
||||
- [x] **Step 7: Save/reload regression check**
|
||||
|
||||
Change a `label_n`/`width_n`/`height_n` value, save, reload, confirm all 13 real widget values land in their correct slots in both modes.
|
||||
|
||||
- [x] **Step 8: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/smart_profile_switch.js
|
||||
git commit -m "fix: render Smart Profile Switch's SLOT dividers/status correctly under Nodes 2.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Rewire `smart_prompt_controller.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `js/smart_prompt_controller.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `createStatusWidget` (Task 2).
|
||||
|
||||
- [x] **Step 1: Add import**
|
||||
|
||||
After `import { app } from "../../scripts/app.js";`:
|
||||
|
||||
```js
|
||||
import { createStatusWidget } from "./shared/canvas_widgets.mjs";
|
||||
```
|
||||
|
||||
- [x] **Step 2: Replace the local status widget block**
|
||||
|
||||
Find `const statusWidget = node.addWidget("button", "_spc_result", null, () => {}, { serialize: false });` through the end of its `statusWidget.draw = function (...) {...};` block. Replace with:
|
||||
|
||||
```js
|
||||
const statusWidget = createStatusWidget("— RUN TO SEE RESULT —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
```
|
||||
|
||||
(Again, copy the live file's exact current placeholder text rather than assuming it matches Task 6's.)
|
||||
|
||||
- [x] **Step 3: Verify the serialize override added for M-19 still applies correctly**
|
||||
|
||||
This file's serialize override (added for M-19, filters `w.serialize !== false && w.options?.serialize !== false`) already excludes this widget via its `serialize: false` property — confirm `createStatusWidget`'s returned object still has `serialize: false` set (it does, per Task 2 Step 2) so no change is needed to the override itself.
|
||||
|
||||
- [x] **Step 4: Manual verification — classic mode**
|
||||
|
||||
Add a fresh Smart Prompt Controller node with Nodes 2.0 OFF, screenshot, confirm the status box draws correctly.
|
||||
|
||||
- [x] **Step 5: Manual verification — Nodes 2.0 mode**
|
||||
|
||||
Toggle Nodes 2.0 ON, reload, add a fresh Smart Prompt Controller node, screenshot, confirm the same.
|
||||
|
||||
- [x] **Step 6: Save/reload regression check**
|
||||
|
||||
Change `index` and `control_after_generate`, save, reload, confirm both land correctly in both modes (this is the exact M-19 bug this file was fixed for earlier in the session — re-verify it still holds after this widget-creation change).
|
||||
|
||||
- [x] **Step 7: Ready for commit**
|
||||
|
||||
```bash
|
||||
git add js/smart_prompt_controller.js
|
||||
git commit -m "fix: render Smart Prompt Controller's status box correctly under Nodes 2.0"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full cross-mode verification pass + cleanup
|
||||
|
||||
**Files:** none (verification only, plus a final consistency check across all 4 files)
|
||||
|
||||
- [x] **Step 1: Grep for any remaining `addWidget("button", "_` or `addWidget("button", "size_presets"` calls**
|
||||
|
||||
```bash
|
||||
grep -rn 'addWidget("button", "_\|addWidget("button", "size_presets"' js/
|
||||
```
|
||||
|
||||
Expected: no matches (everything decorative/custom-drawn now goes through `addCustomWidget`/`addDOMWidget` via the shared factories; only genuinely clickable, single-purpose buttons like "Browse folder" and "Run Batch" should still use `addWidget("button", ...)`, and those are unaffected by this plan).
|
||||
|
||||
- [x] **Step 2: Confirm genuine functional buttons are untouched**
|
||||
|
||||
```bash
|
||||
grep -n 'addWidget("button"' js/smart_batch_resize.js
|
||||
```
|
||||
|
||||
Expected: exactly 2 matches — "📁 Browse folder" and "▶ Run Batch" — both real, single-click-triggers-an-action buttons that should keep using LiteGraph's native `ButtonWidget` (they render correctly as real buttons in both classic and Nodes 2.0 already, since `"button"` is a Vue-recognized type that Vue renders as an actual functional button — this is only a problem for widgets that abuse `"button"` to paint something else via `draw()`).
|
||||
|
||||
- [x] **Step 3: Full-node screenshot sweep, both modes**
|
||||
|
||||
With Nodes 2.0 OFF: place all 5 CraftKit nodes (Smart Prompt Controller, Smart Profile Switch, Smart Resize, Smart Batch Resize, Smart Resolution Multiplier) on one canvas, screenshot.
|
||||
|
||||
Toggle Nodes 2.0 ON, reload, repeat: place all 5 fresh nodes on one canvas, screenshot.
|
||||
|
||||
Compare the two screenshots side by side — confirm no node shows a literal internal widget name (`_div_...`, `_status`, `_sbr_status`, `_sps_status`, `_spc_result`, `size_presets`) as visible button text in either mode.
|
||||
|
||||
- [x] **Step 4: Update the project roadmap memory (no git action — this file lives outside the repo)**
|
||||
|
||||
Edit `C:\Users\rubzm\.claude\projects\D--AI-ComfyUI-Easy-Install-ComfyUI-custom-nodes-ComfyUI-CraftKit\memory\project_roadmap.md`, add a line under "Afgerond" noting Nodes 2.0 compatibility was added for all custom-drawn widgets, dated with today's date, mentioning the `js/shared/` module split as a new architectural pattern other future CraftKit JS work should reuse. This is a memory-file edit, not a repo file — no `git add`/`git commit` for this step.
|
||||
|
||||
- [x] **Step 5: Ready for the final repo commit**
|
||||
|
||||
All of Tasks 1–7 already produced their own commits. If any uncommitted repo changes remain at this point (e.g. a fix made during Task 8's verification sweep), stage and commit those:
|
||||
|
||||
```bash
|
||||
git status --short
|
||||
git add <any remaining changed files>
|
||||
git commit -m "fix: final Nodes 2.0 compatibility touch-ups from the verification sweep"
|
||||
```
|
||||
|
||||
If `git status --short` is empty, there is nothing to commit — Tasks 1–7's commits already cover everything.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes (from plan authoring)
|
||||
|
||||
- **Spec coverage:** all 4 JS files with custom-drawn widgets are covered (Tasks 4–7); the shared-factory extraction (Tasks 1–3) removes the duplication between `smart_batch_resize.js`'s and `smart_resize.js`'s near-identical preset-chip code, and between the divider/status code duplicated across 3 files — this was flagged as a Reuse-category improvement opportunity earlier in the session and is folded in here since this plan already touches all the relevant call sites.
|
||||
- **Known risk flagged explicitly:** Task 3's classic-mode `computeSize` must return `[width, height]`, not a hardcoded value — this repo has direct prior history (a comment in `smart_resize.js`) of this exact mistake breaking node layout. Every `computeSize` in this plan follows the verified-working Pixaroma pattern.
|
||||
- **Out of scope, deliberately:** `js/shared/nodes2.mjs`'s `applyAdaptiveCanvasOnly` helper (used by Pixaroma to control Parameters-sidebar-tab visibility per renderer) is NOT included — none of CraftKit's decorative widgets currently set `options.canvasOnly`, and adding sidebar-visibility control is a separate, optional cosmetic refinement, not required to fix the "widgets render as raw button text" bug. Flag as a possible follow-up if the decorative widgets turn out to clutter the Nodes 2.0 Parameters sidebar tab during Task 8's verification — if so, that's a good candidate for a small follow-up task, not a blocker for this plan.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Log (2026-08-07)
|
||||
|
||||
Executed inline (not subagent-driven), autonomously while the user slept, then jointly debugged one regression with the user once they were back. Summary of what actually happened, including one deviation from the plan as originally written.
|
||||
|
||||
### What went exactly as planned
|
||||
|
||||
- Tasks 1–2 (`js/shared/nodes2.mjs`, `js/shared/canvas_widgets.mjs`): implemented verbatim as specified. `node --check` syntax-clean on first try.
|
||||
- Task 4–7 rewiring: all 4 node JS files updated to use the shared factories. Dividers and status boxes (the `addCustomWidget`/`type:"custom"` path) worked correctly in both classic and Nodes 2.0 rendering on the **first live test**, with zero further changes needed — the `computeSize(width) → [width, height]` pattern copied from Pixaroma was exactly right.
|
||||
- Environment friction, not a code bug: ComfyUI's static file server for custom-node `WEB_DIRECTORY` content does **not** pick up newly-created files (including brand-new subdirectories) without a full process restart — confirmed by testing both a new nested file and a new flat top-level file, both 404 until restart, while edits to already-existing files serve live with no restart needed. Worth remembering for any future new-file additions to this repo's `js/`.
|
||||
- Mid-session environment mixup (not a code issue): partway through, port 8188 was unexpectedly being served by an unrelated second ComfyUI installation (`D:\AI\ComfyUI-AGAIN`) the user had set up separately to work around an earlier database-corruption issue. All the "files don't exist" symptoms during that window were because of testing against the wrong install, not a real serving bug. Resolved once the user pointed the real `ComfyUI-Easy-Install` instance back at port 8188.
|
||||
|
||||
### The regression found during verification (Task 8) — and its fix
|
||||
|
||||
**Symptom:** on Smart Batch Resize and Smart Resize specifically (the two nodes with the preset-chip picker), saving a workflow and reloading it under Nodes 2.0 showed every widget value shifted one row up relative to its label (e.g. "Round to multiple of" displaying `interpolation`'s value, "Interpolation method" displaying `upscale_if_smaller`'s value, and so on down the node). Smart Profile Switch and Smart Prompt Controller (dividers/status only, no preset picker) reloaded with every value correct.
|
||||
|
||||
**First, ruled out:** the saved workflow JSON itself. Inspected the actual `widgets_values` array in the saved `.json` file directly — it was `[1536, 8, "lanczos", true]` for Smart Resize, i.e. completely correct, right length, right order. This proved the `serialize()` override (which excludes non-serializable JS widgets from the saved array) was working correctly, and the bug was purely in how the Nodes 2.0 Vue layer re-associated the correctly-loaded values with widget rows after `nodeCreated()` ran.
|
||||
|
||||
**Root cause:** `createPresetPickerWidget`'s Nodes 2.0 path uses `node.addDOMWidget(...)`, and both call sites then repositioned it via `node.widgets.splice(...)` to sit directly after `longest_side` — the same reposition pattern already used safely for classic-mode's canvas-drawn preset widget and for all the `addCustomWidget`-based dividers. That pattern is safe for `addCustomWidget` widgets (bridged through `WidgetLegacy.vue`, which reads each widget's own `.value` directly) but **not** for `addDOMWidget` widgets under Nodes 2.0: splicing a DOM widget into the middle of `node.widgets` *after* `configure()` has already loaded the saved values onto the pre-existing widget objects desyncs whatever positional/reactive binding Vue uses for the widget list, shifting every widget after the splice point by one row in the display — even though each widget's underlying `.value` property is untouched and correct. This is consistent with the observed data: only nodes with a mid-list `addDOMWidget` insertion were affected; nodes with only `addCustomWidget` insertions were not.
|
||||
|
||||
**Fix:** in `js/smart_batch_resize.js` and `js/smart_resize.js`, the "move preset row to right after longest_side" reposition step is now skipped entirely when `isVueNodes()` is true — the DOM widget is left wherever `addDOMWidget` placed it (empirically, the end of `node.widgets`, after all real Python widgets, the status box, and the Run button). Classic mode is unaffected: its canvas-drawn preset widget still repositions normally, since that path never showed the bug.
|
||||
|
||||
**Verified fix, both nodes, full save → reload cycle under Nodes 2.0:**
|
||||
- Smart Batch Resize: changed `longest_side` to 768 via a preset click, saved, reloaded — every one of the 18 real widgets read back correct (spot-verified via the sidebar Parameters panel plus the node's own canvas text), preset chips still rendered as real `<button>` elements at their new position (after `preview_limit`, before the status box and Run Batch button) and were confirmed clickable.
|
||||
- Smart Resize: same check, `longest_side` read back correctly along with `multiple_of`/`interpolation`/`upscale_if_smaller`.
|
||||
- Smart Profile Switch and Smart Prompt Controller: re-verified with a changed `index` value (7) through the same save → reload cycle as a negative control, confirming they were never affected (no `addDOMWidget` in either file) — both read back correct.
|
||||
|
||||
**Visual trade-off accepted:** under Nodes 2.0 specifically, the preset-chip row no longer sits directly under "Longest side (px)" — it's now at the bottom of the node, after all other settings. Classic mode keeps the original, nicer placement. Revisiting this (e.g. finding a way to splice a DOM widget safely, or moving to a different widget-list-diffing strategy) is a possible future refinement, not required for correctness.
|
||||
|
||||
### Net effect vs. the original plan
|
||||
|
||||
Tasks 1, 2, 4, 5, 6, 7 landed as specified. Task 3 (`createPresetPickerWidget`) needed one addition beyond what was originally planned: callers must guard the reposition-splice with `!isVueNodes()`. This wasn't anticipated by the original research (which correctly identified *that* a dual-path widget was needed, but not this specific interaction between splice-repositioning and Vue's post-`configure()` reactivity). Task 8's manual verification sweep is exactly what caught it — this is the reason the plan's "no automated JS test harness, verify manually" constraint insisted on an actual save → reload round-trip check per node, not just a visual glance at a freshly-added node.
|
||||
- **WorkflowOrganizer is explicitly out of scope** per the user's instruction ("workflow organiser doen we later checken") — this plan touches only ComfyUI-CraftKit.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Log (2026-08-13) — simplification + the widget.width staleness bug
|
||||
|
||||
Picks up after the (2026-08-07) log above. Two follow-up rounds: first a deliberate architecture simplification, then a hard-to-reproduce click bug that needed a second opinion from a fresh model to actually crack.
|
||||
|
||||
### Simplification: dropped the dual-path DOM widget entirely
|
||||
|
||||
The (2026-08-07) log accepted a visual trade-off: under Nodes 2.0 the preset-chip row sat at the *bottom* of the node instead of directly under "Longest side (px)", because repositioning the `addDOMWidget`-based Nodes-2.0 variant mid-list desynced Vue's save/reload binding. The user flagged this as bad UX once they actually saw it side by side with classic mode.
|
||||
|
||||
Fix: dropped the `addDOMWidget`/`isVueNodes()` dual-path in `preset_picker_widget.mjs` entirely, keeping only the single canvas-drawn (`type: "custom"`) implementation in both renderers. This is safe because the preset picker is click-only (no hover state was ever implemented), and canvas `"custom"` widgets were already proven immune to the reposition/reload bug (unlike `addDOMWidget`). `js/shared/nodes2.mjs` (the `isVueNodes()` helper) became unused and was deleted. Reposition-splice guards were made unconditional again in both `smart_batch_resize.js` and `smart_resize.js`.
|
||||
|
||||
Net result: preset chips now sit in the same, correct position in both renderers, with one shared draw/hit-test implementation instead of two.
|
||||
|
||||
### Widget-visibility helper, and the real WidgetLegacy.vue render-trigger model
|
||||
|
||||
Separately, "Counter start" (shown/hidden based on the "Add counter" toggle) and the customtext output widget that all 4 nodes hide after execution both used a `computeSize = () => [0,-4]` collapse trick. This works in classic mode (canvas layout calls `computeSize()` every frame) but silently does nothing under Nodes 2.0 — confirmed by reading `useProcessedWidgets.ts`'s `computeProcessedWidgets()`: Nodes 2.0's widget list is a Vue `shallowReactive` array (see `useGraphNodeManager.ts`, `extractVueNodeData()`) that only tracks the array's own indices/length as reactive dependencies, never properties nested inside each widget object (like `widget.options.hidden`) — so mutating those never triggers a re-render.
|
||||
|
||||
Fix, in new `js/shared/widget_visibility.mjs` (`setWidgetVisible(node, widget, visible)`): set `widget.hidden`/`widget.options.hidden` *and* perform a structural no-op mutation on `node.widgets` (push a dummy widget, immediately splice it back out) to force Vue's shallowReactive array to actually notice something changed and recompute. Verified this exact mechanism via live console testing before writing the real fix — a plain `widget.options.hidden = true` + `node.setDirtyCanvas()` provably did nothing in Nodes 2.0 (confirmed via `Object.getOwnPropertyDescriptor(node, 'widgets')` showing the Vue-installed getter/setter), while the push+splice nudge worked immediately.
|
||||
|
||||
Along the way, also discovered (via reading `WidgetLegacy.vue`'s actual source, extracted from the frontend package's sourcemap) that **`node.setDirtyCanvas()` does not repaint a Nodes-2.0 `WidgetLegacy`-bridged widget at all** — only calling the widget's own `triggerDraw()` (a function `WidgetLegacy.vue` assigns onto the widget object once mounted) does. This explained two more stale-visual bugs: the active/orange preset chip not updating after a click, and the status box text not updating after a run, in Nodes 2.0 specifically. Fixed by calling `this.triggerDraw?.()` (in `mouse()`) / `statusWidget.triggerDraw?.()` (in `onExecuted`) alongside the existing `setDirtyCanvas()` calls, everywhere this pattern occurred across all 4 node JS files.
|
||||
|
||||
Also found and fixed: the section-divider line (`createDividerWidget` in `canvas_widgets.mjs`) was being drawn correctly but was visually near-invisible under Nodes 2.0 — its `#333` stroke color survived classic mode's native-resolution canvas fine, but got softened into invisibility by Nodes 2.0's extra 2x-backing-store + CSS-zoom-transform rendering path. Fixed by brightening to `#555` and centering the label with flanking lines on both sides (`── LABEL ──`) instead of label-left/line-right, per user request.
|
||||
|
||||
### The click-reliability bug that needed a second opinion
|
||||
|
||||
After the above fixes, the user reported the preset chips "still not good" in classic mode specifically (Nodes 2.0 was fine): clicks on the right-hand chips of a *wide* node (they'd resized one to ~580px) either landed on the wrong chip or did nothing at all — worst on the rightmost ("1536") chip. Extensive live-tested hypotheses (screenshot-timing artifacts, `getWidgetOnPos` hit-detection, `_rects` staleness after `setSize()`, node-selection-order effects, `HIT_PAD`/chip-size tuning) all failed to nail a reproducible root cause through iterative testing — some rounds even made it look *worse* after a "bigger chips" fix landed, which the user called out immediately as suspicious.
|
||||
|
||||
Spawned a fresh Opus subagent as a genuine second opinion, with full context (exact symptom history, files, and everything already ruled out) but explicit instructions to re-derive from the actual source rather than trust the existing narrative. It found the real root cause on the first pass by reading `WidgetLegacy.vue` and `LGraphNode.ts` (both extracted via the frontend package's sourcemaps) side by side:
|
||||
|
||||
- `WidgetLegacy.vue` (Nodes 2.0's canvas-draw bridge) permanently stamps `widgetInstance.width = width` (its own, possibly-narrower container width) onto the **shared widget object** on every mount — and its `onBeforeUnmount` only resets `triggerDraw`, never `width`.
|
||||
- Classic LiteGraph's own click hit-test, `getWidgetOnPos` (`LGraphNode.ts` ~line 2281), reads `widget.width || nodeWidth` to decide the clickable X range — **not** `node.size[0]`.
|
||||
- Net effect: once a node has ever been rendered under Nodes 2.0 (even briefly, even in a since-closed tab), classic mode's click detection for that widget stays capped at whatever (possibly narrow) width Nodes 2.0 last stamped, forever, regardless of the node's actual current width, until the widget object itself is recreated.
|
||||
- This also explained the "made it worse" observation precisely: an earlier fix (`c6c26a3`, this project's commit reading live `node.size[0]` in `draw()` instead of the stale `widgetWidth` param) fixed the *drawing* to be full-width, but the *hit-test* stayed capped at the stale stamped width — so chips started being drawn wider than they were actually clickable, which is strictly worse UX than being uniformly-too-narrow-but-consistent.
|
||||
|
||||
Fix, in `preset_picker_widget.mjs`'s `draw()`: `this.width = w;` (writing the live, correct width back onto the widget object) on every single draw call, in both renderers — so a stale Nodes-2.0 stamp gets self-corrected on the very next classic-mode repaint, and vice versa. Verified by deliberately reproducing the exact failure mode (stamp `widget.width = 300` on a 580px-wide node, confirm a normal redraw restores it to 580, confirm a click on the far-right chip then registers correctly) rather than just re-testing the original vague symptom.
|
||||
|
||||
**Lesson for future Nodes 2.0 compat work in this repo:** any widget property that Nodes 2.0's `WidgetLegacy.vue` bridge writes onto the shared widget object (`.width`, `.y`, `.triggerDraw` are the three confirmed so far) must be treated as **owned by whichever renderer painted last**, not as a stable/cached value — always recompute and reassign it from the live `node`/DOM state at the top of every `draw()`, never trust it surviving between renderer switches. When a live-tested bug resists several iterations of hypothesis-and-retest without a clean repro, escalating to a fresh model with full context and explicit "don't trust the existing narrative, re-derive from source" instructions found the answer in one pass where continued iteration likely wouldn't have — worth doing sooner next time a bug proves this sticky, rather than as a last resort.
|
||||
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 7,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Note",
|
||||
"pos": [0, -240],
|
||||
"size": {"0": 1840, "1": 190},
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["CraftKit × SeedVR2 — Smart Upscale\n\nPattern:\n1. SmartResolutionMultiplier calculates the 2× target resolution from the input image automatically\n2. ImageScaleBy pre-downscales the input (0.75×) so SeedVR2 has room to work\n3. SeedVR2 upscales to the computed target resolution (connected from SmartResolutionMultiplier → resolution)\n4. SmartResize normalizes the output to a consistent longest side\n\nChange multiplier on SmartResolutionMultiplier to control the upscale factor. Change longest_side on SmartResize to control output size."],
|
||||
"color": "#2a3a2a",
|
||||
"bgcolor": "#1a2a1a"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "LoadImage",
|
||||
"pos": [0, -10],
|
||||
"size": {"0": 280, "1": 320},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "COMBO", "widget": {"name": "image"}, "link": null},
|
||||
{"name": "upload", "type": "IMAGEUPLOAD", "widget": {"name": "upload"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [1, 2], "slot_index": 0},
|
||||
{"name": "MASK", "type": "MASK", "links": null, "slot_index": 1}
|
||||
],
|
||||
"properties": {"cnr_id": "comfy-core", "Node name for S&R": "LoadImage"},
|
||||
"widgets_values": ["example.png", "image"]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "SmartResolutionMultiplier",
|
||||
"pos": [320, -10],
|
||||
"size": {"0": 320, "1": 150},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "IMAGE", "link": 1}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "width", "type": "INT", "links": null, "slot_index": 0},
|
||||
{"name": "height", "type": "INT", "links": null, "slot_index": 1},
|
||||
{"name": "resolution", "type": "INT", "links": [3], "slot_index": 2}
|
||||
],
|
||||
"properties": {"cnr_id": "comfyui-craftkit", "Node name for S&R": "SmartResolutionMultiplier"},
|
||||
"widgets_values": [2.0, 8]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "ImageScaleBy",
|
||||
"pos": [320, 180],
|
||||
"size": {"0": 270, "1": 130},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "IMAGE", "link": 2}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [4], "slot_index": 0}
|
||||
],
|
||||
"title": "Pre-downscale (0.75×)",
|
||||
"properties": {"cnr_id": "comfy-core", "Node name for S&R": "ImageScaleBy"},
|
||||
"widgets_values": ["lanczos", 0.75]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "SeedVR2LoadDiTModel",
|
||||
"pos": [680, -10],
|
||||
"size": {"0": 380, "1": 260},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "torch_compile_args", "shape": 7, "type": "TORCH_COMPILE_ARGS", "link": null},
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "device", "type": "COMBO", "widget": {"name": "device"}, "link": null},
|
||||
{"name": "blocks_to_swap", "shape": 7, "type": "INT", "widget": {"name": "blocks_to_swap"}, "link": null},
|
||||
{"name": "swap_io_components", "shape": 7, "type": "BOOLEAN", "widget": {"name": "swap_io_components"}, "link": null},
|
||||
{"name": "offload_device", "shape": 7, "type": "COMBO", "widget": {"name": "offload_device"}, "link": null},
|
||||
{"name": "cache_model", "shape": 7, "type": "BOOLEAN", "widget": {"name": "cache_model"}, "link": null},
|
||||
{"name": "attention_mode", "shape": 7, "type": "COMBO", "widget": {"name": "attention_mode"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "SEEDVR2_DIT", "type": "SEEDVR2_DIT", "links": [5], "slot_index": 0}
|
||||
],
|
||||
"properties": {"cnr_id": "seedvr2_videoupscaler", "Node name for S&R": "SeedVR2LoadDiTModel"},
|
||||
"widgets_values": ["seedvr2_ema_3b_fp8_e4m3fn.safetensors", "cuda:0", 0, false, "none", false, "sdpa"]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SeedVR2LoadVAEModel",
|
||||
"pos": [680, 300],
|
||||
"size": {"0": 380, "1": 300},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "torch_compile_args", "shape": 7, "type": "TORCH_COMPILE_ARGS", "link": null},
|
||||
{"name": "model", "type": "COMBO", "widget": {"name": "model"}, "link": null},
|
||||
{"name": "device", "type": "COMBO", "widget": {"name": "device"}, "link": null},
|
||||
{"name": "encode_tiled", "shape": 7, "type": "BOOLEAN", "widget": {"name": "encode_tiled"}, "link": null},
|
||||
{"name": "encode_tile_size", "shape": 7, "type": "INT", "widget": {"name": "encode_tile_size"}, "link": null},
|
||||
{"name": "encode_tile_overlap", "shape": 7, "type": "INT", "widget": {"name": "encode_tile_overlap"}, "link": null},
|
||||
{"name": "decode_tiled", "shape": 7, "type": "BOOLEAN", "widget": {"name": "decode_tiled"}, "link": null},
|
||||
{"name": "decode_tile_size", "shape": 7, "type": "INT", "widget": {"name": "decode_tile_size"}, "link": null},
|
||||
{"name": "decode_tile_overlap", "shape": 7, "type": "INT", "widget": {"name": "decode_tile_overlap"}, "link": null},
|
||||
{"name": "tile_debug", "shape": 7, "type": "COMBO", "widget": {"name": "tile_debug"}, "link": null},
|
||||
{"name": "offload_device", "shape": 7, "type": "COMBO", "widget": {"name": "offload_device"}, "link": null},
|
||||
{"name": "cache_model", "shape": 7, "type": "BOOLEAN", "widget": {"name": "cache_model"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "SEEDVR2_VAE", "type": "SEEDVR2_VAE", "links": [6], "slot_index": 0}
|
||||
],
|
||||
"properties": {"cnr_id": "seedvr2_videoupscaler", "Node name for S&R": "SeedVR2LoadVAEModel"},
|
||||
"widgets_values": ["ema_vae_fp16.safetensors", "cuda:0", true, 1024, 128, true, 1024, 128, "false", "cpu", false]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SeedVR2VideoUpscaler",
|
||||
"pos": [1100, -10],
|
||||
"size": {"0": 380, "1": 560},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "IMAGE", "link": 4},
|
||||
{"name": "dit", "type": "SEEDVR2_DIT", "link": 5},
|
||||
{"name": "vae", "type": "SEEDVR2_VAE", "link": 6},
|
||||
{"name": "seed", "type": "INT", "widget": {"name": "seed"}, "link": null},
|
||||
{"name": "resolution", "type": "INT", "widget": {"name": "resolution"}, "link": 3},
|
||||
{"name": "max_resolution", "type": "INT", "widget": {"name": "max_resolution"}, "link": null},
|
||||
{"name": "batch_size", "type": "INT", "widget": {"name": "batch_size"}, "link": null},
|
||||
{"name": "uniform_batch_size", "type": "BOOLEAN", "widget": {"name": "uniform_batch_size"}, "link": null},
|
||||
{"name": "color_correction", "type": "COMBO", "widget": {"name": "color_correction"}, "link": null},
|
||||
{"name": "temporal_overlap", "shape": 7, "type": "INT", "widget": {"name": "temporal_overlap"}, "link": null},
|
||||
{"name": "prepend_frames", "shape": 7, "type": "INT", "widget": {"name": "prepend_frames"}, "link": null},
|
||||
{"name": "input_noise_scale", "shape": 7, "type": "FLOAT", "widget": {"name": "input_noise_scale"}, "link": null},
|
||||
{"name": "latent_noise_scale", "shape": 7, "type": "FLOAT", "widget": {"name": "latent_noise_scale"}, "link": null},
|
||||
{"name": "offload_device", "shape": 7, "type": "COMBO", "widget": {"name": "offload_device"}, "link": null},
|
||||
{"name": "enable_debug", "shape": 7, "type": "BOOLEAN", "widget": {"name": "enable_debug"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "IMAGE", "type": "IMAGE", "links": [7], "slot_index": 0}
|
||||
],
|
||||
"properties": {"cnr_id": "seedvr2_videoupscaler", "Node name for S&R": "SeedVR2VideoUpscaler"},
|
||||
"widgets_values": [42, "fixed", 0, 1, false, "lab", 0, 0, 0.0, 0.0, "cpu", false]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SmartResize",
|
||||
"pos": [1520, -10],
|
||||
"size": {"0": 320, "1": 240},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "image", "type": "IMAGE", "link": 7}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "image", "type": "IMAGE", "links": [], "slot_index": 0},
|
||||
{"name": "width", "type": "INT", "links": [], "slot_index": 1},
|
||||
{"name": "height", "type": "INT", "links": [], "slot_index": 2}
|
||||
],
|
||||
"properties": {"cnr_id": "comfyui-craftkit", "Node name for S&R": "SmartResize"},
|
||||
"widgets_values": [1024, 8, "lanczos", false]
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "Note",
|
||||
"pos": [1520, 260],
|
||||
"size": {"0": 320, "1": 310},
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Outputs:\n\n→ image (SmartResize)\n connect to PreviewImage or SaveImage\n\nTweak:\n• multiplier on SmartResolutionMultiplier\n 2.0 = double, 1.5 = 50% bigger\n• scale_by on Pre-downscale\n lower = more room for SeedVR2 to\n reconstruct detail\n• longest_side on SmartResize\n target output size after upscale\n\nModel options:\n• 3b fp8 — fast, less VRAM\n• 7b fp16 — best quality, high VRAM"],
|
||||
"color": "#2a3a2a",
|
||||
"bgcolor": "#1a2a1a"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 2, 0, 3, 0, "IMAGE"],
|
||||
[2, 2, 0, 4, 0, "IMAGE"],
|
||||
[3, 3, 2, 7, 4, "INT"],
|
||||
[4, 4, 0, 7, 0, "IMAGE"],
|
||||
[5, 5, 0, 7, 1, "SEEDVR2_DIT"],
|
||||
[6, 6, 0, 7, 2, "SEEDVR2_VAE"],
|
||||
[7, 7, 0, 8, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Input",
|
||||
"bounding": [-20, -30, 640, 390],
|
||||
"color": "#4a2d5a",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "SeedVR2 Models",
|
||||
"bounding": [660, -30, 420, 640],
|
||||
"color": "#1e3d5a",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Upscale + Output",
|
||||
"bounding": [1080, -30, 800, 630],
|
||||
"color": "#1e4a4a",
|
||||
"font_size": 24
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {"scale": 0.6, "offset": [-20, 230]}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"last_node_id": 4,
|
||||
"last_link_id": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "SmartBatchResize",
|
||||
"pos": [60, 80],
|
||||
"size": [340, 366],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": [1],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "count",
|
||||
"type": "INT",
|
||||
"links": [],
|
||||
"slot_index": 1
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SmartBatchResize"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
1024,
|
||||
8,
|
||||
"lanczos",
|
||||
true,
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
1,
|
||||
true,
|
||||
false,
|
||||
"resized",
|
||||
true,
|
||||
"_",
|
||||
32,
|
||||
"keep_source",
|
||||
"auto",
|
||||
95
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "PreviewImage",
|
||||
"pos": [460, 80],
|
||||
"size": [320, 280],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 1
|
||||
}
|
||||
],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
},
|
||||
"widgets_values": []
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "Note",
|
||||
"pos": [460, 390],
|
||||
"size": [340, 400],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"text": "Smart Batch Resize — how it works\n\nThe node ALWAYS writes resized images to disk — connecting the outputs is optional.\n\nOutputs:\n📷 images → batch tensor, feed into PreviewImage or any IMAGE input\n🔢 count → number of images actually processed\n\nKey settings:\n📁 Input folder — pick with Browse folder, or paste a path\n📐 Longest side — target size, aspect ratio is always preserved\n🔢 Round to multiple of — snap dimensions (use 8 for SD/Flux)\n🏷️ Filename prefix / Keep original filename / Add counter — build the output filename\n📂 Create resolution subfolder / Custom output subfolder — where resized files land (default: input_folder/resized/)\n⏭️ Skip existing files — don't reprocess files already in the output folder\n\n▶️ Run Batch = same as the regular Run button — it just runs this node. No separate output folder needed: results are saved inside the input folder itself (in the resized/ subfolder).\n\nTip: leave the prefix empty and 'Keep original filename' on to preserve names, or turn it off and use a prefix + counter for a clean renumbered batch."
|
||||
},
|
||||
"widgets_values": [
|
||||
"Smart Batch Resize — how it works\n\nThe node ALWAYS writes resized images to disk — connecting the outputs is optional.\n\nOutputs:\n📷 images → batch tensor, feed into PreviewImage or any IMAGE input\n🔢 count → number of images actually processed\n\nKey settings:\n📁 Input folder — pick with Browse folder, or paste a path\n📐 Longest side — target size, aspect ratio is always preserved\n🔢 Round to multiple of — snap dimensions (use 8 for SD/Flux)\n🏷️ Filename prefix / Keep original filename / Add counter — build the output filename\n📂 Create resolution subfolder / Custom output subfolder — where resized files land (default: input_folder/resized/)\n⏭️ Skip existing files — don't reprocess files already in the output folder\n\n▶️ Run Batch = same as the regular Run button — it just runs this node. No separate output folder needed: results are saved inside the input folder itself (in the resized/ subfolder).\n\nTip: leave the prefix empty and 'Keep original filename' on to preserve names, or turn it off and use a prefix + counter for a clean renumbered batch."
|
||||
],
|
||||
"color": "#2a3a2a",
|
||||
"bgcolor": "#1a2a1a"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 1, 0, 2, 0, "IMAGE"]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"last_node_id": 10,
|
||||
"last_link_id": 7,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "Note",
|
||||
"pos": [0, -240],
|
||||
"size": {"0": 1090, "1": 180},
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["CraftKit — Smart Prompt Controller + Smart Profile Switch\n\nSet index to 1 and control_after_generate to 'increment' — cycles through all 16 prompts automatically, one per generation.\n\nSmartPromptController picks the right prompt line and tells SmartProfileSwitch which profile is active (1–4).\nSmartProfileSwitch switches the resolution and label to match. Connect the outputs on the right to your sampler pipeline."],
|
||||
"color": "#2a3a2a",
|
||||
"bgcolor": "#1a2a1a"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [0, -20],
|
||||
"size": {"0": 340, "1": 210},
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "STRING", "widget": {"name": "value"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "STRING", "type": "STRING", "links": [1], "slot_index": 0}
|
||||
],
|
||||
"title": "Headshot Prompts",
|
||||
"properties": {"cnr_id": "comfy-core", "ver": "0.17.0", "Node name for S&R": "PrimitiveStringMultiline"},
|
||||
"widgets_values": ["01 headshot portrait, face and neck only, neutral expression, soft studio lighting, clean background\n02 headshot portrait, slight smile, diffused window light, plain white background\n03 headshot portrait, professional look, direct gaze, high-key studio lighting\n04 headshot portrait, relaxed expression, warm natural daylight, simple neutral background"],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [360, -20],
|
||||
"size": {"0": 340, "1": 210},
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "STRING", "widget": {"name": "value"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "STRING", "type": "STRING", "links": [2], "slot_index": 0}
|
||||
],
|
||||
"title": "Halfbody Prompts",
|
||||
"properties": {"cnr_id": "comfy-core", "ver": "0.17.0", "Node name for S&R": "PrimitiveStringMultiline"},
|
||||
"widgets_values": ["05 half-body portrait, shoulders and chest visible, neutral expression, studio lighting, grey background\n06 half-body portrait, slight smile, arms relaxed at sides, soft window light from left\n07 half-body portrait, confident pose, looking slightly to the side, clean background\n08 half-body portrait, arms crossed, composed expression, even studio lighting"],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [0, 210],
|
||||
"size": {"0": 340, "1": 210},
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "STRING", "widget": {"name": "value"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "STRING", "type": "STRING", "links": [3], "slot_index": 0}
|
||||
],
|
||||
"title": "Fullbody Prompts",
|
||||
"properties": {"cnr_id": "comfy-core", "ver": "0.17.0", "Node name for S&R": "PrimitiveStringMultiline"},
|
||||
"widgets_values": ["09 full-body portrait, standing straight, neutral pose, studio lighting, clean background\n10 full-body portrait, casual relaxed pose, hands in pockets, soft outdoor light\n11 full-body portrait, mid-stride walking pose, urban setting, natural daylight\n12 full-body portrait, dynamic confident pose, weight on one leg, plain backdrop"],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "PrimitiveStringMultiline",
|
||||
"pos": [360, 210],
|
||||
"size": {"0": 340, "1": 210},
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "value", "type": "STRING", "widget": {"name": "value"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "STRING", "type": "STRING", "links": [4], "slot_index": 0}
|
||||
],
|
||||
"title": "Tall Portrait Prompts",
|
||||
"properties": {"cnr_id": "comfy-core", "ver": "0.17.0", "Node name for S&R": "PrimitiveStringMultiline"},
|
||||
"widgets_values": ["13 tall portrait, full figure head to toe, neutral standing pose, studio lighting, clean background\n14 tall portrait, elegant standing pose, one hand on hip, soft diffused light\n15 tall portrait, outdoor setting, warm golden hour rim light, natural relaxed pose\n16 tall portrait, indoor setting, leaning against wall, soft directional light"],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SmartPromptController",
|
||||
"pos": [740, -20],
|
||||
"size": {"0": 300, "1": 290},
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "prompt_list_1", "type": "STRING", "link": 1},
|
||||
{"name": "prompt_list_2", "type": "STRING", "link": 2},
|
||||
{"name": "prompt_list_3", "type": "STRING", "link": 3},
|
||||
{"name": "prompt_list_4", "type": "STRING", "link": 4},
|
||||
{"name": "index", "type": "INT", "widget": {"name": "index"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "prompt", "type": "STRING", "links": [], "slot_index": 0},
|
||||
{"name": "active_list", "type": "INT", "links": [7], "slot_index": 1}
|
||||
],
|
||||
"properties": {"cnr_id": "comfyui-craftkit", "Node name for S&R": "SmartPromptController"},
|
||||
"widgets_values": [1, "increment"],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SmartProfileSwitch",
|
||||
"pos": [740, 310],
|
||||
"size": {"0": 300, "1": 530},
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{"name": "index", "type": "INT", "widget": {"name": "index"}, "link": 7},
|
||||
{"name": "label_1", "type": "STRING", "widget": {"name": "label_1"}, "link": null},
|
||||
{"name": "width_1", "type": "INT", "widget": {"name": "width_1"}, "link": null},
|
||||
{"name": "height_1", "type": "INT", "widget": {"name": "height_1"}, "link": null},
|
||||
{"name": "label_2", "type": "STRING", "widget": {"name": "label_2"}, "link": null},
|
||||
{"name": "width_2", "type": "INT", "widget": {"name": "width_2"}, "link": null},
|
||||
{"name": "height_2", "type": "INT", "widget": {"name": "height_2"}, "link": null},
|
||||
{"name": "label_3", "type": "STRING", "widget": {"name": "label_3"}, "link": null},
|
||||
{"name": "width_3", "type": "INT", "widget": {"name": "width_3"}, "link": null},
|
||||
{"name": "height_3", "type": "INT", "widget": {"name": "height_3"}, "link": null},
|
||||
{"name": "label_4", "type": "STRING", "widget": {"name": "label_4"}, "link": null},
|
||||
{"name": "width_4", "type": "INT", "widget": {"name": "width_4"}, "link": null},
|
||||
{"name": "height_4", "type": "INT", "widget": {"name": "height_4"}, "link": null}
|
||||
],
|
||||
"outputs": [
|
||||
{"name": "label", "type": "STRING", "links": [], "slot_index": 0},
|
||||
{"name": "width", "type": "INT", "links": [], "slot_index": 1},
|
||||
{"name": "height", "type": "INT", "links": [], "slot_index": 2}
|
||||
],
|
||||
"properties": {"cnr_id": "comfyui-craftkit", "Node name for S&R": "SmartProfileSwitch"},
|
||||
"widgets_values": [1, "Headshots", 1024, 1024, "Halfbody", 832, 1216, "Fullbody", 768, 1344, "Tall Portrait", 640, 1536],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"type": "Note",
|
||||
"pos": [1080, -20],
|
||||
"size": {"0": 300, "1": 380},
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {},
|
||||
"widgets_values": ["Connect to your pipeline:\n\n→ prompt (SmartPromptController)\n positive CLIP text encoder\n\n→ label (SmartProfileSwitch)\n filename prefix or suffix\n e.g. SaveImageExtended filename_keys\n\n→ width / height (SmartProfileSwitch)\n EmptyLatentImage or EmptySD3LatentImage\n\n─────────────────\n\nTip: label auto-switches per category every generation — no manual filename changes needed."],
|
||||
"color": "#2a3a2a",
|
||||
"bgcolor": "#1a2a1a"
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[1, 3, 0, 7, 0, "STRING"],
|
||||
[2, 4, 0, 7, 1, "STRING"],
|
||||
[3, 5, 0, 7, 2, "STRING"],
|
||||
[4, 6, 0, 7, 3, "STRING"],
|
||||
[7, 7, 1, 8, 0, "INT"]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Prompt Lists",
|
||||
"bounding": [-20, -40, 720, 470],
|
||||
"color": "#2d5a3a",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "CraftKit Control",
|
||||
"bounding": [720, -40, 340, 880],
|
||||
"color": "#1e3d5a",
|
||||
"font_size": 24
|
||||
},
|
||||
{
|
||||
"title": "Connect to Your Pipeline",
|
||||
"bounding": [1060, -40, 350, 440],
|
||||
"color": "#1e4a4a",
|
||||
"font_size": 24
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {"scale": 0.7, "offset": [-30, 230]}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Draw-only widgets (no mouse/click handling) shared across CraftKit nodes.
|
||||
// type: "custom" is deliberate — it is not a name ComfyUI's Nodes 2.0 Vue
|
||||
// widget registry recognizes, so Vue falls through to its WidgetLegacy
|
||||
// bridge, which calls our draw() unmodified in both classic LiteGraph and
|
||||
// Nodes 2.0. computeSize MUST return [width, height] (passing width through)
|
||||
// rather than a hardcoded value — a fixed/wrong computeSize previously broke
|
||||
// node layout on click when this was tried without care (see the comment on
|
||||
// the preset-chip widget in smart_resize.js's git history).
|
||||
//
|
||||
// draw() intentionally ignores its own `widgetWidth` parameter and reads
|
||||
// `node.size[0]` instead. LiteGraph caches a `.width` on custom-typed widget
|
||||
// objects the first time it lays them out and reuses that cached value on
|
||||
// later draws — for a widget added before the node reaches its final size
|
||||
// (e.g. before later Python widgets are added, or before an explicit
|
||||
// node.setSize() call), that cache goes stale and draw() keeps receiving the
|
||||
// old, too-narrow width forever, making the divider line/status box stop
|
||||
// partway across the node instead of spanning it. node.size[0] is always
|
||||
// live, so reading it directly sidesteps the stale cache entirely.
|
||||
|
||||
const DIVIDER_HEIGHT = 20;
|
||||
|
||||
export function createDividerWidget(label) {
|
||||
return {
|
||||
name: `_div_${label}`,
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
computeSize(width) {
|
||||
return [width, DIVIDER_HEIGHT];
|
||||
},
|
||||
draw(ctx, node, widgetWidth, y, height) {
|
||||
const w = node?.size?.[0] || widgetWidth;
|
||||
const margin = 14;
|
||||
const gap = 8;
|
||||
ctx.save();
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "center";
|
||||
const cy = y + height / 2;
|
||||
const cx = w / 2;
|
||||
const textW = ctx.measureText(label).width;
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.fillText(label, cx, cy);
|
||||
// #555 rather than a more subtle #333 — Nodes 2.0 renders this canvas
|
||||
// at 2x internal scale and then CSS-scales it again with the graph
|
||||
// zoom, which softens a thin line enough that #333 became invisible
|
||||
// there (classic mode draws at native resolution and stayed sharp).
|
||||
ctx.strokeStyle = "#555";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(margin, cy);
|
||||
ctx.lineTo(cx - textW / 2 - gap, cy);
|
||||
ctx.moveTo(cx + textW / 2 + gap, cy);
|
||||
ctx.lineTo(w - margin, cy);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_HEIGHT = 24;
|
||||
|
||||
export function createStatusWidget(placeholderText) {
|
||||
return {
|
||||
name: "_status",
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
_text: "",
|
||||
computeSize(width) {
|
||||
return [width, STATUS_HEIGHT];
|
||||
},
|
||||
draw(ctx, node, widgetWidth, y, height) {
|
||||
const w = node?.size?.[0] || widgetWidth;
|
||||
const margin = 14;
|
||||
const innerW = w - margin * 2;
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
if (ctx.roundRect) ctx.roundRect(margin, y + 2, innerW, height - 4, 4);
|
||||
else ctx.rect(margin, y + 2, innerW, height - 4);
|
||||
ctx.fillStyle = "#111";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = this._text ? "#3a3a3a" : "#222";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.fillStyle = this._text ? "#f28f41" : "#666";
|
||||
ctx.fillText(this._text || placeholderText, w / 2, y + height / 2);
|
||||
ctx.restore();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
// A single canvas-drawn implementation, used in both classic LiteGraph and
|
||||
// Nodes 2.0 (via the WidgetLegacy.vue bridge — type: "custom" isn't a
|
||||
// recognized Vue registry name, so it falls through to the bridge, same as
|
||||
// the divider/status widgets in canvas_widgets.mjs). No separate DOM-widget
|
||||
// path: an earlier version used addDOMWidget for Nodes 2.0, but repositioning
|
||||
// that DOM widget into the middle of node.widgets (to sit under longest_side
|
||||
// instead of at the end) desynced Vue's value-to-row binding for every widget
|
||||
// after it on save/reload. Canvas "custom" widgets don't have that problem —
|
||||
// they've been confirmed to survive reposition + reload fine — so there's no
|
||||
// need for the dual-path/DOM version this node's click-only (no hover)
|
||||
// interaction never actually required.
|
||||
const PRESET_ROW_HEIGHT = 26;
|
||||
// Hit-test area is padded a few px beyond the visible chip on every side —
|
||||
// forgiving actual mouse precision, which drifts a little between press and
|
||||
// release, unlike pixel-perfect synthetic clicks.
|
||||
const HIT_PAD = 3;
|
||||
|
||||
export function createPresetPickerWidget(node, targetWidget, presets) {
|
||||
const widget = {
|
||||
name: "size_presets",
|
||||
type: "custom",
|
||||
value: null,
|
||||
serialize: false,
|
||||
options: {},
|
||||
_rects: [],
|
||||
computeSize(width) {
|
||||
return [width, PRESET_ROW_HEIGHT];
|
||||
},
|
||||
draw(ctx, node, widgetWidth, y, height) {
|
||||
// Read the live node width rather than the (possibly stale-cached)
|
||||
// widgetWidth parameter — see the comment in canvas_widgets.mjs.
|
||||
const w = node?.size?.[0] || widgetWidth;
|
||||
// Nodes 2.0's WidgetLegacy bridge permanently stamps widget.width
|
||||
// from its own container onto this widget object (and never
|
||||
// clears it on unmount). Classic LiteGraph's own click hit-test
|
||||
// (getWidgetOnPos) reads widget.width — not node.size[0] — to
|
||||
// decide the clickable X range, so once this node has ever been
|
||||
// painted under Nodes 2.0, classic mode silently loses clicks on
|
||||
// the right side of a wide node forever. Keep width in sync on
|
||||
// every draw so classic's hit-test always agrees with reality.
|
||||
this.width = w;
|
||||
const margin = 14;
|
||||
const gap = 7;
|
||||
const innerW = Math.max(40, w - margin * 2);
|
||||
const n = presets.length;
|
||||
const cellW = Math.max(10, (innerW - gap * (n - 1)) / n);
|
||||
const h = Math.min(height - 2, 22);
|
||||
const top = y + (height - h) / 2;
|
||||
const current = targetWidget.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 = Number(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();
|
||||
},
|
||||
mouse(event, pos, node) {
|
||||
if (event.type !== "pointerdown" && event.type !== "mousedown") return false;
|
||||
const [lx, ly] = pos;
|
||||
for (const r of this._rects) {
|
||||
if (lx >= r.x1 - HIT_PAD && lx <= r.x2 + HIT_PAD && ly >= r.y1 - HIT_PAD && ly <= r.y2 + HIT_PAD) {
|
||||
targetWidget.value = r.size;
|
||||
targetWidget.callback?.(r.size, app.canvas, node);
|
||||
node.setDirtyCanvas(true, true);
|
||||
// Nodes 2.0's WidgetLegacy bridge only repaints its canvas
|
||||
// via the widget's own triggerDraw() (set on it once mounted),
|
||||
// not via setDirtyCanvas — without this the newly-active chip
|
||||
// never highlights even though the value did change.
|
||||
this.triggerDraw?.();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
};
|
||||
return node.addCustomWidget(widget);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Dynamically show/hide a widget (e.g. a field that only makes sense when
|
||||
// another toggle is on) in a way that works in BOTH classic LiteGraph and
|
||||
// Nodes 2.0.
|
||||
//
|
||||
// Classic mode collapses a row by giving it computeSize() => [0, -4] — the
|
||||
// canvas layout loop calls computeSize() every frame, so this is enough on
|
||||
// its own there.
|
||||
//
|
||||
// Nodes 2.0 never calls computeSize() at all; its widget list is a Vue
|
||||
// `shallowReactive` array (see ComfyUI's useGraphNodeManager.ts) that only
|
||||
// tracks the array's own indices/length, not properties nested inside each
|
||||
// widget (like widget.options.hidden). So setting widget.hidden/options.hidden
|
||||
// is necessary but NOT sufficient — nothing tells Vue to re-run the computed
|
||||
// that reads it. A push-then-splice-it-back no-op on node.widgets forces a
|
||||
// real structural array mutation, which Vue's reactive array DOES notify on,
|
||||
// causing the widget list to re-evaluate and pick up the new hidden state.
|
||||
export function setWidgetVisible(node, widget, visible) {
|
||||
// Widgets don't always define their own computeSize (many rely on
|
||||
// LiteGraph's built-in default row sizing), so it can legitimately be
|
||||
// undefined — guard capture with a separate flag rather than truthiness,
|
||||
// or a later call would "capture" the already-collapsed [0,-4] function.
|
||||
if (!widget._visibilityDefaultCaptured) {
|
||||
widget._defaultComputeSize = widget.computeSize;
|
||||
widget._visibilityDefaultCaptured = true;
|
||||
}
|
||||
widget.computeSize = visible ? widget._defaultComputeSize : () => [0, -4];
|
||||
widget.hidden = !visible;
|
||||
if (!widget.options) widget.options = {};
|
||||
widget.options.hidden = !visible;
|
||||
|
||||
const nudge = { name: "__visibility_nudge__", type: "custom", value: null, computeSize: () => [0, 0], draw() {} };
|
||||
node.widgets.push(nudge);
|
||||
node.widgets.splice(node.widgets.indexOf(nudge), 1);
|
||||
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs?v=3";
|
||||
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs?v=6";
|
||||
import { setWidgetVisible } from "./shared/widget_visibility.mjs?v=2";
|
||||
|
||||
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", { method: "POST" });
|
||||
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);
|
||||
}
|
||||
}, { serialize: false });
|
||||
|
||||
btn.serialize = false;
|
||||
|
||||
// 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.
|
||||
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
|
||||
if (longestSideWidget) {
|
||||
const PRESETS = [512, 768, 1024, 1536];
|
||||
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 = createDividerWidget(label);
|
||||
node.addCustomWidget(div);
|
||||
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
|
||||
// all Python widget values on load (configure runs before nodeCreated).
|
||||
// Invariant: Python widgets must stay in their original relative order in
|
||||
// this.widgets — JS widgets may be spliced in between, but never reorder
|
||||
// the Python ones, or the positional value assignment on load will silently
|
||||
// misalign.
|
||||
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;
|
||||
};
|
||||
|
||||
// Canvas-drawn status display
|
||||
const statusWidget = createStatusWidget("— RUN TO PROCESS —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
|
||||
// Run Batch button
|
||||
const runBtn = node.addWidget("button", "▶ Run Batch", null, () => {
|
||||
app.queuePrompt(0, 1).catch(e => console.error("[SmartBatchResize] Queue failed:", e));
|
||||
}, { serialize: false });
|
||||
runBtn.serialize = false;
|
||||
|
||||
// Show/hide counter_start based on use_counter toggle
|
||||
const useCounterWidget = node.widgets?.find(w => w.name === "use_counter");
|
||||
const counterStartWidget = node.widgets?.find(w => w.name === "counter_start");
|
||||
if (useCounterWidget && counterStartWidget) {
|
||||
const updateCounterVisibility = () => {
|
||||
setWidgetVisible(node, counterStartWidget, useCounterWidget.value);
|
||||
};
|
||||
const origCallback = useCounterWidget.callback;
|
||||
useCounterWidget.callback = function (...args) {
|
||||
origCallback?.call(this, ...args);
|
||||
updateCounterVisibility();
|
||||
};
|
||||
const origOnConfigure = node.onConfigure;
|
||||
node.onConfigure = function (...args) {
|
||||
origOnConfigure?.call(this, ...args);
|
||||
updateCounterVisibility();
|
||||
};
|
||||
updateCounterVisibility();
|
||||
}
|
||||
|
||||
// Hide any customtext widget already present (e.g. from a previously run workflow)
|
||||
const hideCustomText = () => {
|
||||
for (const w of node.widgets) {
|
||||
if (w.type === "customtext") setWidgetVisible(node, w, false);
|
||||
}
|
||||
};
|
||||
hideCustomText();
|
||||
|
||||
// Intercept onExecuted to update status and hide the default customtext output
|
||||
const origOnExecuted = node.onExecuted;
|
||||
node.onExecuted = function (output) {
|
||||
origOnExecuted?.call(this, output);
|
||||
if (output?.text?.[0]) {
|
||||
statusWidget._text = output.text[0];
|
||||
hideCustomText();
|
||||
// setDirtyCanvas alone doesn't repaint Nodes 2.0's canvas bridge —
|
||||
// see the comment in preset_picker_widget.mjs.
|
||||
statusWidget.triggerDraw?.();
|
||||
}
|
||||
node.setDirtyCanvas(true);
|
||||
};
|
||||
|
||||
// Force a full size/layout recompute now that every widget has been
|
||||
// added and repositioned. Without this, the preset-chip row's very
|
||||
// first paint can use a stale/undersized widgetWidth (from before the
|
||||
// node's final layout settled), rendering uneven or clipped chips
|
||||
// until the next unrelated redraw fixes it.
|
||||
node.setSize(node.computeSize());
|
||||
node.setDirtyCanvas(true, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createDividerWidget, createStatusWidget } from "./shared/canvas_widgets.mjs?v=3";
|
||||
import { setWidgetVisible } from "./shared/widget_visibility.mjs?v=2";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Craftopia.SmartProfileSwitch",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "SmartProfileSwitch") return;
|
||||
|
||||
// Slot header dividers — visual only, not serialized
|
||||
const dividers = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const div = createDividerWidget(`SLOT ${i}`);
|
||||
node.addCustomWidget(div);
|
||||
dividers.push(div);
|
||||
}
|
||||
|
||||
// Status widget — shows active slot after execution
|
||||
const statusWidget = createStatusWidget("— RUN TO SEE RESULT —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
|
||||
// Reorder: index, [div1, label_1, w_1, h_1], ..., [div4, label_4, w_4, h_4], status
|
||||
const indexWidget = node.widgets.find(w => w.name === "index");
|
||||
const slotWidgets = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
slotWidgets.push(
|
||||
dividers[i - 1],
|
||||
node.widgets.find(w => w.name === `label_${i}`),
|
||||
node.widgets.find(w => w.name === `width_${i}`),
|
||||
node.widgets.find(w => w.name === `height_${i}`),
|
||||
);
|
||||
}
|
||||
// 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;
|
||||
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;
|
||||
};
|
||||
|
||||
// onExecuted — update status, hide default customtext textarea
|
||||
const origOnExecuted = node.onExecuted;
|
||||
node.onExecuted = function (output) {
|
||||
origOnExecuted?.call(this, output);
|
||||
if (output?.text?.[0]) {
|
||||
statusWidget._text = output.text[0];
|
||||
for (const w of node.widgets) {
|
||||
if (w !== statusWidget && w.type === "customtext") {
|
||||
setWidgetVisible(node, w, false);
|
||||
}
|
||||
}
|
||||
// setDirtyCanvas alone doesn't repaint Nodes 2.0's canvas bridge —
|
||||
// see the comment in preset_picker_widget.mjs.
|
||||
statusWidget.triggerDraw?.();
|
||||
}
|
||||
node.setDirtyCanvas(true);
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,6 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createStatusWidget } from "./shared/canvas_widgets.mjs?v=3";
|
||||
import { setWidgetVisible } from "./shared/widget_visibility.mjs?v=2";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Craftopia.SmartPromptController",
|
||||
@@ -6,24 +8,44 @@ app.registerExtension({
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "SmartPromptController") return;
|
||||
|
||||
// Status widget — no visible label name
|
||||
const widget = node.addWidget("text", "", "", () => {});
|
||||
widget.serializeValue = () => undefined;
|
||||
// Canvas-drawn result display — clearly an output, not an input field
|
||||
const statusWidget = createStatusWidget("— RUN TO SEE RESULT —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
|
||||
const onExecuted = node.onExecuted;
|
||||
// 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) {
|
||||
const w = node.size[0];
|
||||
const h = node.size[1];
|
||||
|
||||
onExecuted?.apply(this, arguments);
|
||||
origOnExecuted?.call(this, output);
|
||||
|
||||
if (output?.text?.[0]) {
|
||||
widget.value = output.text[0];
|
||||
statusWidget._text = output.text[0];
|
||||
|
||||
// Hide the default customtext widget ComfyUI adds for ui.text
|
||||
for (const w of node.widgets) {
|
||||
if (w !== statusWidget && w.type === "customtext") {
|
||||
setWidgetVisible(node, w, false);
|
||||
}
|
||||
}
|
||||
// setDirtyCanvas alone doesn't repaint Nodes 2.0's canvas bridge —
|
||||
// see the comment in preset_picker_widget.mjs.
|
||||
statusWidget.triggerDraw?.();
|
||||
}
|
||||
|
||||
// Preserve node size
|
||||
node.size[0] = Math.max(w, node.size[0]);
|
||||
node.size[1] = Math.max(h, node.size[1]);
|
||||
node.setDirtyCanvas(true);
|
||||
};
|
||||
},
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs?v=6";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Craftopia.SmartResize",
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "SmartResize") return;
|
||||
|
||||
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
|
||||
if (!longestSideWidget) return;
|
||||
|
||||
const PRESETS = [512, 768, 1024, 1536];
|
||||
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
||||
|
||||
// Override serialize to exclude the non-serializable preset widget from
|
||||
// widgets_values — same drift fix as SmartBatchResize (see that file for details).
|
||||
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;
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Force a full size/layout recompute now that the preset widget has
|
||||
// been added and repositioned — see the comment in
|
||||
// smart_batch_resize.js for why this is needed.
|
||||
node.setSize(node.computeSize());
|
||||
node.setDirtyCanvas(true, true);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"SmartBatchResize": {
|
||||
"display_name": "Smart Batch Resize 📁",
|
||||
"inputs": {
|
||||
"input_folder": { "name": "Input folder" },
|
||||
"longest_side": { "name": "Longest side (px)" },
|
||||
"multiple_of": { "name": "Round to multiple of" },
|
||||
"interpolation": { "name": "Interpolation method" },
|
||||
"prefix": { "name": "Filename prefix" },
|
||||
"use_original_name": { "name": "Keep original filename" },
|
||||
"use_counter": { "name": "Add counter" },
|
||||
"counter_start": { "name": "Counter start" },
|
||||
"suffix_resolution": { "name": "Add resolution to filename" },
|
||||
"folder_resolution": { "name": "Create resolution subfolder" },
|
||||
"folder_custom": { "name": "Custom output subfolder" },
|
||||
"skip_if_exists": { "name": "Skip existing files" },
|
||||
"delimiter": { "name": "Filename delimiter" }
|
||||
},
|
||||
"outputs": {
|
||||
"0": { "name": "Images" },
|
||||
"1": { "name": "Count" }
|
||||
}
|
||||
},
|
||||
"SmartResize": {
|
||||
"display_name": "Smart Resize 📐",
|
||||
"inputs": {
|
||||
"image": { "name": "Image" },
|
||||
"longest_side": { "name": "Longest side (px)" },
|
||||
"multiple_of": { "name": "Round to multiple of" },
|
||||
"interpolation": { "name": "Interpolation method" },
|
||||
"upscale_if_smaller": { "name": "Upscale if smaller" }
|
||||
},
|
||||
"outputs": {
|
||||
"0": { "name": "Image" },
|
||||
"1": { "name": "Width" },
|
||||
"2": { "name": "Height" }
|
||||
}
|
||||
},
|
||||
"SmartResolutionMultiplier": {
|
||||
"display_name": "Smart Resolution Multiplier 📏",
|
||||
"inputs": {
|
||||
"image": { "name": "Image" },
|
||||
"multiplier": { "name": "Multiplier" },
|
||||
"multiple_of": { "name": "Round to multiple of" }
|
||||
},
|
||||
"outputs": {
|
||||
"0": { "name": "Width" },
|
||||
"1": { "name": "Height" },
|
||||
"2": { "name": "Resolution" }
|
||||
}
|
||||
},
|
||||
"SmartProfileSwitch": {
|
||||
"display_name": "Smart Profile Switch 🧩",
|
||||
"inputs": {
|
||||
"index": { "name": "Index" },
|
||||
"label_1": { "name": "Label 1" },
|
||||
"width_1": { "name": "Width 1" },
|
||||
"height_1": { "name": "Height 1" },
|
||||
"label_2": { "name": "Label 2" },
|
||||
"width_2": { "name": "Width 2" },
|
||||
"height_2": { "name": "Height 2" },
|
||||
"label_3": { "name": "Label 3" },
|
||||
"width_3": { "name": "Width 3" },
|
||||
"height_3": { "name": "Height 3" },
|
||||
"label_4": { "name": "Label 4" },
|
||||
"width_4": { "name": "Width 4" },
|
||||
"height_4": { "name": "Height 4" }
|
||||
},
|
||||
"outputs": {
|
||||
"0": { "name": "Label" },
|
||||
"1": { "name": "Width" },
|
||||
"2": { "name": "Height" }
|
||||
}
|
||||
},
|
||||
"SmartPromptController": {
|
||||
"display_name": "Smart Prompt Controller 📋",
|
||||
"inputs": {
|
||||
"index": { "name": "Index" },
|
||||
"prompt_list_1": { "name": "Prompt list 1" },
|
||||
"prompt_list_2": { "name": "Prompt list 2" },
|
||||
"prompt_list_3": { "name": "Prompt list 3" },
|
||||
"prompt_list_4": { "name": "Prompt list 4" }
|
||||
},
|
||||
"outputs": {
|
||||
"0": { "name": "Prompt" },
|
||||
"1": { "name": "Active list" }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[project]
|
||||
name = "comfyui-craftkit"
|
||||
description = "Smart resize, batch resize, resolution multiplier and prompt controller nodes for ComfyUI"
|
||||
version = "1.1.1"
|
||||
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]
|
||||
Repository = "https://github.com/CraftopiaStudio/ComfyUI-CraftKit"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "craftopiastudio"
|
||||
DisplayName = "ComfyUI-CraftKit"
|
||||
Icon = "https://raw.githubusercontent.com/CraftopiaStudio/ComfyUI-CraftKit/main/assets/icon.png"
|
||||
@@ -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,84 @@ 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 = []
|
||||
|
||||
if prefix:
|
||||
parts.append(prefix)
|
||||
|
||||
if use_original_name:
|
||||
parts.append(original_stem)
|
||||
|
||||
if use_counter:
|
||||
number = counter_start + counter_index
|
||||
parts.append(f"{number:03d}")
|
||||
|
||||
if suffix_resolution:
|
||||
parts.append(str(longest_side))
|
||||
|
||||
if not parts:
|
||||
parts.append(original_stem)
|
||||
|
||||
return delimiter.join(parts)
|
||||
|
||||
|
||||
class SmartBatchResize:
|
||||
|
||||
@classmethod
|
||||
@@ -39,9 +109,9 @@ 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
|
||||
"longest_side": ("INT", {
|
||||
"default": 1024, "min": 64, "max": 8192, "step": 64,
|
||||
@@ -52,20 +122,42 @@ class SmartBatchResize:
|
||||
"tooltip": "Snap both dimensions to a multiple of this value. Use 8 for SD/Flux."
|
||||
}),
|
||||
"interpolation": (["lanczos", "bicubic", "bilinear", "nearest"], {
|
||||
"default": "lanczos"
|
||||
"default": "lanczos",
|
||||
"tooltip": "Resampling method. Lanczos is sharpest for downscaling."
|
||||
}),
|
||||
|
||||
# OUTPUT NAMING
|
||||
"suffix_resolution": ("BOOLEAN", {
|
||||
"upscale_if_smaller": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Append resolution to filename. E.g. image_1024.png"
|
||||
"tooltip": "Upscale images that are smaller than the target longest side. Turn off to only ever downscale, never upscale."
|
||||
}),
|
||||
"suffix_custom": ("STRING", {
|
||||
|
||||
# OUTPUT NAMING
|
||||
"prefix": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "Custom text to add before resolution. Only used when suffix_resolution is on."
|
||||
"tooltip": "Label prepended to filename. E.g. 'headshot' → headshot_photo_001_1024.jpg"
|
||||
}),
|
||||
|
||||
"use_original_name": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Include the original filename in the output name."
|
||||
}),
|
||||
"use_counter": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Add a sequential 3-digit counter to each filename (001, 002, ...)."
|
||||
}),
|
||||
"counter_start": ("INT", {
|
||||
"default": 1, "min": 0, "max": 99999, "step": 1,
|
||||
"tooltip": "Starting number for the counter. Useful when processing multiple batches."
|
||||
}),
|
||||
"suffix_resolution": ("BOOLEAN", {
|
||||
"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", {
|
||||
"default": False,
|
||||
@@ -74,61 +166,86 @@ class SmartBatchResize:
|
||||
"folder_custom": ("STRING", {
|
||||
"default": "resized",
|
||||
"multiline": False,
|
||||
"tooltip": "Subfolder name. Resolution is appended if folder_resolution is on."
|
||||
"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 and folder 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."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "STRING", "STRING", "INT")
|
||||
RETURN_NAMES = ("images", "filenames", "output_paths", "count")
|
||||
OUTPUT_IS_LIST = (True, True, True, False)
|
||||
RETURN_TYPES = ("IMAGE", "INT")
|
||||
RETURN_NAMES = ("images", "count")
|
||||
OUTPUT_IS_LIST = (True, False)
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "Craftopia"
|
||||
CATEGORY = "CraftKit"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def run(self, input_folder, longest_side, multiple_of, interpolation,
|
||||
suffix_resolution, suffix_custom,
|
||||
@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):
|
||||
|
||||
# Resolve filename suffix
|
||||
suffix_raw = suffix_custom.strip()
|
||||
if suffix_resolution:
|
||||
if suffix_raw == "":
|
||||
suffix_raw = f"{delimiter}{longest_side}"
|
||||
else:
|
||||
suffix_raw = f"{suffix_raw}{delimiter}{longest_side}"
|
||||
|
||||
# Ensure delimiter is prepended if there's a suffix
|
||||
if suffix_raw != "" and not suffix_raw.startswith(delimiter):
|
||||
filename_suffix = f"{delimiter}{suffix_raw}"
|
||||
else:
|
||||
filename_suffix = suffix_raw
|
||||
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 == "":
|
||||
if folder_resolution:
|
||||
subfolder = str(longest_side) # Only resolution
|
||||
else:
|
||||
subfolder = "resized" # Fallback
|
||||
else:
|
||||
if folder_resolution:
|
||||
subfolder = f"{subfolder}{delimiter}{longest_side}"
|
||||
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.")
|
||||
if not os.path.isdir(input_folder):
|
||||
raise ValueError(f"[SmartBatchResize] Folder not found: {input_folder}")
|
||||
|
||||
@@ -141,39 +258,102 @@ 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, filenames_out, paths_out = [], [], []
|
||||
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:
|
||||
out_name = f"{f.stem}{filename_suffix}{f.suffix.lower()}"
|
||||
stem = _build_stem(
|
||||
original_stem=f.stem,
|
||||
prefix=prefix.strip(),
|
||||
use_original_name=use_original_name,
|
||||
use_counter=use_counter,
|
||||
counter_index=counter_index,
|
||||
counter_start=counter_start,
|
||||
suffix_resolution=suffix_resolution,
|
||||
longest_side=longest_side,
|
||||
delimiter=delimiter,
|
||||
)
|
||||
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}")
|
||||
continue
|
||||
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
|
||||
|
||||
img = PILImage.open(f).convert("RGB")
|
||||
w, h = img.size
|
||||
new_w, new_h = _calc_new_size(w, h, longest_side, multiple_of)
|
||||
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_resized = img.resize((new_w, new_h), interp)
|
||||
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)
|
||||
|
||||
save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
img_resized = img.resize((new_w, new_h), interp)
|
||||
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
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)")
|
||||
|
||||
arr = np.array(img_resized).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
filenames_out.append(out_name)
|
||||
paths_out.append(str(out_path))
|
||||
save_kwargs = {"quality": quality} if out_ext in (".jpg", ".jpeg", ".webp") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
|
||||
if not images_out:
|
||||
raise ValueError("[SmartBatchResize] No images processed.")
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
|
||||
return (images_out, filenames_out, paths_out, len(images_out))
|
||||
processed_count += 1
|
||||
total_count += 1
|
||||
counter_index += 1
|
||||
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)
|
||||
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
class SmartProfileSwitch:
|
||||
"""
|
||||
CraftKit — Smart Profile Switch 🧩
|
||||
Maps an active index (1–4) to a label, width, and height.
|
||||
Designed to pair with Smart Prompt Controller's active_list output.
|
||||
"""
|
||||
|
||||
_DEFAULTS = [
|
||||
("Headshots", 1024, 1024),
|
||||
("Halfbody", 832, 1216),
|
||||
("Fullbody", 768, 1344),
|
||||
("Tall", 640, 1536),
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
required = {
|
||||
"index": ("INT", {
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 4,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"tooltip": "Active profile (1–4). Connect to active_list from Smart Prompt Controller.",
|
||||
}),
|
||||
}
|
||||
for i, (label, w, h) in enumerate(cls._DEFAULTS, 1):
|
||||
required[f"label_{i}"] = ("STRING", {
|
||||
"default": label,
|
||||
"tooltip": f"Label for profile {i}, used as filename suffix.",
|
||||
})
|
||||
required[f"width_{i}"] = ("INT", {"default": w, "min": 64, "max": 8192, "step": 8})
|
||||
required[f"height_{i}"] = ("INT", {"default": h, "min": 64, "max": 8192, "step": 8})
|
||||
return {"required": required}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT", "INT")
|
||||
RETURN_NAMES = ("label", "width", "height")
|
||||
FUNCTION = "route"
|
||||
CATEGORY = "CraftKit"
|
||||
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,
|
||||
label_2, width_2, height_2,
|
||||
label_3, width_3, height_3,
|
||||
label_4, width_4, height_4):
|
||||
slots = [
|
||||
(label_1, width_1, height_1),
|
||||
(label_2, width_2, height_2),
|
||||
(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}"
|
||||
return {"ui": {"text": [status]}, "result": (label, width, height)}
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"SmartProfileSwitch": SmartProfileSwitch,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SmartProfileSwitch": "Smart Profile Switch \U0001f9e9",
|
||||
}
|
||||
@@ -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.
|
||||
"""
|
||||
@@ -9,28 +9,29 @@ class SmartPromptController:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"counter": ("INT", {
|
||||
"index": ("INT", {
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 99999,
|
||||
"step": 1,
|
||||
"display": "number",
|
||||
"control_after_generate": "increment",
|
||||
"tooltip": "Current position across all lists.",
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"prompt_list_1": ("STRING", {"forceInput": True}),
|
||||
"prompt_list_2": ("STRING", {"forceInput": True}),
|
||||
"prompt_list_3": ("STRING", {"forceInput": True}),
|
||||
"prompt_list_4": ("STRING", {"forceInput": True}),
|
||||
"prompt_list_1": ("STRING", {"forceInput": True, "tooltip": "Prompt list, one prompt per line."}),
|
||||
"prompt_list_2": ("STRING", {"forceInput": True, "tooltip": "Optional second prompt list."}),
|
||||
"prompt_list_3": ("STRING", {"forceInput": True, "tooltip": "Optional third prompt list."}),
|
||||
"prompt_list_4": ("STRING", {"forceInput": True, "tooltip": "Optional fourth prompt list."}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "INT")
|
||||
RETURN_NAMES = ("prompt", "list_index")
|
||||
RETURN_NAMES = ("prompt", "active_list")
|
||||
FUNCTION = "control"
|
||||
CATEGORY = "Craftopia"
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "CraftKit"
|
||||
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."""
|
||||
@@ -40,7 +41,7 @@ class SmartPromptController:
|
||||
text = text[0] if text else ""
|
||||
return [l for l in str(text).splitlines() if l.strip()]
|
||||
|
||||
def control(self, counter, prompt_list_1="", prompt_list_2="",
|
||||
def control(self, index, prompt_list_1="", prompt_list_2="",
|
||||
prompt_list_3="", prompt_list_4=""):
|
||||
raw = [prompt_list_1, prompt_list_2, prompt_list_3, prompt_list_4]
|
||||
|
||||
@@ -58,7 +59,7 @@ class SmartPromptController:
|
||||
"result": ("", 0)}
|
||||
|
||||
# Map counter position to list + line
|
||||
position = (counter - 1) % total_all
|
||||
idx = (index - 1) % total_all
|
||||
|
||||
cumulative = 0
|
||||
selected_list_index = 0
|
||||
@@ -66,9 +67,9 @@ class SmartPromptController:
|
||||
selected_prompt = ""
|
||||
|
||||
for list_idx, lines in active:
|
||||
if position < cumulative + len(lines):
|
||||
if idx < cumulative + len(lines):
|
||||
selected_list_index = list_idx
|
||||
selected_line_index = position - cumulative
|
||||
selected_line_index = idx - cumulative
|
||||
selected_prompt = lines[selected_line_index]
|
||||
break
|
||||
cumulative += len(lines)
|
||||
@@ -93,5 +94,5 @@ NODE_CLASS_MAPPINGS = {
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"SmartPromptController": "Smart Prompt Controller \ud83c\udf9b\ufe0f",
|
||||
"SmartPromptController": "Smart Prompt Controller \ud83d\udccb",
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -44,10 +60,12 @@ class SmartResize:
|
||||
"default": 8, "min": 1, "max": 64, "step": 1,
|
||||
"tooltip": "Snap both dimensions to a multiple of this value. Use 8 for SD/Flux."
|
||||
}),
|
||||
"interpolation": (["lanczos", "bicubic", "bilinear", "nearest"],),
|
||||
"interpolation": (["lanczos", "bicubic", "bilinear", "nearest"], {
|
||||
"tooltip": "Resampling method. Lanczos is sharpest for downscaling."
|
||||
}),
|
||||
"upscale_if_smaller": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": "Upscale images that are already smaller than longest_side."
|
||||
"default": True,
|
||||
"tooltip": "Upscale images that are smaller than the target longest side. Turn off to only ever downscale, never upscale."
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -55,12 +73,14 @@ class SmartResize:
|
||||
RETURN_TYPES = ("IMAGE", "INT", "INT")
|
||||
RETURN_NAMES = ("image", "width", "height")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "Craftopia"
|
||||
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]
|
||||
@@ -70,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}
|
||||
|
||||
@@ -2,14 +2,16 @@ 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
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"image": ("IMAGE", {
|
||||
"tooltip": "Source image. Its current width/height are read directly, no separate Get Image Size node needed."
|
||||
}),
|
||||
"multiplier": ("FLOAT", {
|
||||
"default": 2.0,
|
||||
"min": 0.1,
|
||||
@@ -31,13 +33,13 @@ class SmartResolutionMultiplier:
|
||||
RETURN_TYPES = ("INT", "INT", "INT")
|
||||
RETURN_NAMES = ("width", "height", "resolution")
|
||||
FUNCTION = "run"
|
||||
CATEGORY = "Craftopia"
|
||||
CATEGORY = "CraftKit"
|
||||
|
||||
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)
|
||||
|
||||