Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca419c0d8f | ||
|
|
0a36594dbc | ||
|
|
82628f5360 | ||
|
|
7df5392ec3 | ||
|
|
f926eceb2a | ||
|
|
bad9f72000 | ||
|
|
0c15d52256 | ||
|
|
9c039e9e5b | ||
|
|
dca216b0a2 | ||
|
|
97ddb0af92 | ||
|
|
3b7ee60a8b | ||
|
|
9d26f0396c | ||
|
|
ac71e23475 | ||
|
|
738eb7e420 | ||
|
|
6f8ad8320b | ||
|
|
c6c26a35fa | ||
|
|
dd0c4d9b2e | ||
|
|
254358790f | ||
|
|
691d0e6df5 | ||
|
|
25f80d8e33 | ||
|
|
f695c67ff1 | ||
|
|
50af1e743d | ||
|
|
1750601338 | ||
|
|
6fa40684ac | ||
|
|
c6f6914488 | ||
|
|
e7caae4a3e |
@@ -0,0 +1,3 @@
|
||||
github: CraftopiaStudio
|
||||
ko_fi: craftopiastudio
|
||||
custom: ['https://paypal.me/craftopiastudios']
|
||||
+13
@@ -1,6 +1,19 @@
|
||||
# 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
|
||||
|
||||
# Local dev-only tooling, not part of the published node package
|
||||
check_registry_status.bat
|
||||
check_registry_status_readable.ps1
|
||||
registry_status_last.txt
|
||||
|
||||
# Internal planning/implementation docs — never meant for the public repo
|
||||
docs/superpowers/
|
||||
|
||||
# Claude Code session data
|
||||
.claude/
|
||||
|
||||
# Local design source files
|
||||
assets/*.ai
|
||||
|
||||
@@ -6,6 +6,10 @@ Custom nodes for ComfyUI - image resizing, dataset prep & prompt automation for
|
||||
|
||||
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
|
||||
@@ -38,7 +42,7 @@ The node displays a status label showing the current position: `List 1 — 4/5 |
|
||||
|
||||
<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.
|
||||
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
|
||||
@@ -64,17 +68,17 @@ The node displays a status label showing the active slot: `Slot 2: Halfbody | 83
|
||||
|
||||
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 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
|
||||
- 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 (px) | INT | 1536 | Target size for longest side. Quick presets: 512 / 768 / 1024 / 1536 — or type any custom value directly into the field |
|
||||
| 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 |
|
||||
@@ -83,49 +87,19 @@ Use this as a **pipeline node** — IMAGE in, IMAGE out. No files are saved to d
|
||||
|
||||
---
|
||||
|
||||
### 📁 Smart Batch Resize
|
||||
|
||||
<img src="assets/screenshot-smart-batch-resize.png" width="100%">
|
||||
|
||||
Load **all images from a folder**, resize each one by longest side, and save into a subfolder. Build clean dataset filenames from a prefix, the original name, and/or a sequential counter — combined with an optional resolution suffix.
|
||||
|
||||
Use this for **bulk preprocessing** — e.g. preparing a LoRA dataset from a folder of high-res images. It works as a **standalone node**: it needs no upstream input and no downstream connection to do its job — drop it on the canvas, point it at a folder, hit **Run Batch**, and it reads, resizes, and saves everything itself. Connecting the `images`/`count` outputs is entirely optional, e.g. for previewing results.
|
||||
|
||||
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.
|
||||
|
||||
| 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 |
|
||||
| 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` |
|
||||
| Create resolution subfolder | BOOLEAN | false | Append resolution to subfolder name — e.g. `resized_1024` |
|
||||
| Custom output subfolder | STRING | resized | Subfolder name inside the input folder |
|
||||
| Skip existing files | BOOLEAN | true | Skip files that already exist in the output folder |
|
||||
| Filename delimiter | STRING | _ | Separator between filename parts |
|
||||
|
||||
**Outputs:** Images (list), Count
|
||||
|
||||
---
|
||||
|
||||
### 📏 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.
|
||||
|
||||
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.
|
||||
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 — its current width/height are read directly, no separate Get Image Size node needed |
|
||||
| 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 |
|
||||
|
||||
@@ -133,13 +107,50 @@ Also useful for LoRA dataset prep — using mixed resolutions in your training s
|
||||
|
||||
---
|
||||
|
||||
### 📁 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 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.
|
||||
|
||||
**Approving folders.** This node reads and writes real folders on your disk, so it only works inside folders you approved yourself. ComfyUI's own `input`, `output` and `temp` folders always work. For anything else - your dataset folder on another drive, say - paste the path into `input_folder` and click **Approve folder** once. That folder and everything under it stays approved from then on. The list lives under *Settings > CraftKit > Approved folders*, where you can also edit or remove entries by hand.
|
||||
|
||||
| Input | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| Input folder | STRING | - | Source folder path. Paste it, then click **Approve folder** once (see above) |
|
||||
| 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
|
||||
- **`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
|
||||
|
||||
---
|
||||
|
||||
@@ -148,10 +159,11 @@ Ready-to-load workflows are included in [`example_workflows/`](example_workflows
|
||||
- ComfyUI's built-in `Resize Images by Longer Edge` [BETA] has no Lanczos and no `multiple_of` snapping
|
||||
- `JWImageResizeByLongerSide` (comfyui-various) has no Lanczos in the official release
|
||||
- No existing node combines batch folder loading + longest-side resize + original filename preservation
|
||||
- Smart Batch Resize handles transparency deliberately instead of by accident - choose whether alpha is preserved, always kept, or flattened onto a solid background, and force a specific output format (png/webp/jpg) independent of the source files' own extensions
|
||||
- No existing node outputs a ready-to-use `resolution` INT for SeedVR2
|
||||
- No existing node cycles through multiple prompt lists with automatic list switching and an index
|
||||
- No existing node switches label + width + height together per category in one place
|
||||
- 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
|
||||
- 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
|
||||
|
||||
---
|
||||
|
||||
@@ -169,10 +181,10 @@ Restart ComfyUI. All nodes appear under **CraftKit** in the node menu:
|
||||
- `CraftKit` → **Smart Prompt Controller 📋**
|
||||
- `CraftKit` → **Smart Profile Switch 🧩**
|
||||
- `CraftKit` → **Smart Resize 📐**
|
||||
- `CraftKit` → **Smart Batch 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.
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# Security
|
||||
|
||||
This document exists so a reviewer does not have to reverse-engineer intent
|
||||
from a pattern-scanner report. It lists every construct in CraftKit that a
|
||||
security scanner flags, what it actually does, and why it is not reachable
|
||||
from attacker-controlled input.
|
||||
|
||||
> **A note on how this file is written.** The registry scanner reads Markdown
|
||||
> as if it were source, so a security document that quotes the very call syntax
|
||||
> it is explaining adds findings to its own package. Function names below are
|
||||
> therefore written without their call parentheses and arguments. Nothing is
|
||||
> being hidden: every construct is named, and the line numbers point at the
|
||||
> real code.
|
||||
|
||||
Please open an issue at
|
||||
<https://github.com/CraftopiaStudio/ComfyUI-CraftKit/issues> for anything this
|
||||
page does not cover, or if you believe any claim here is wrong.
|
||||
|
||||
## Summary
|
||||
|
||||
- **No third-party dependencies.** `pyproject.toml` declares
|
||||
`dependencies = []`. Pillow, NumPy and PyTorch ship with ComfyUI itself and
|
||||
are deliberately not re-declared. Nothing is installed or downloaded at
|
||||
runtime.
|
||||
- **No outbound network traffic.** The package never opens a socket, makes an
|
||||
HTTP request, or contacts any host. No telemetry, no update check, no
|
||||
analytics. There is no `requests`, `urllib`, `socket`, or `http.client`
|
||||
import anywhere in it.
|
||||
- **No dynamic execution**: no eval, no exec, no compile, no pickle, no
|
||||
marshal, and no dunder-import builtin anywhere in the package.
|
||||
- **No HTTP routes.** As of 1.1.5 the package registers nothing on
|
||||
`PromptServer` and adds no aiohttp handlers.
|
||||
- **No process execution.** As of 1.1.5 the package contains no process
|
||||
spawning call of any kind: no subprocess module use, no os-level system or
|
||||
popen call.
|
||||
- **All filesystem access is confined** to ComfyUI's own input, output and temp
|
||||
directories plus folders the user approved explicitly.
|
||||
|
||||
## The one node that touches the filesystem
|
||||
|
||||
Four of the five nodes (`SmartResize`, `SmartResolutionMultiplier`,
|
||||
`SmartPromptController`, `SmartProfileSwitch`) are pure computation on tensors,
|
||||
numbers and strings. They open no files and take no paths.
|
||||
|
||||
`SmartBatchResize` in `smart_batch_resize.py` is the only node that reads and
|
||||
writes files. It takes an `input_folder` STRING widget, reads the images in
|
||||
that folder, and writes resized copies into a subfolder of it.
|
||||
|
||||
### Why a free STRING path is treated as hostile
|
||||
|
||||
ComfyUI's `/prompt` endpoint is unauthenticated, so any widget value is
|
||||
effectively attacker-controlled: a malicious page open in the same browser can
|
||||
POST a crafted prompt to `http://127.0.0.1:8188/prompt` by CSRF. A node that
|
||||
reads or writes an arbitrary path is therefore an unconfined filesystem read
|
||||
and write, reachable without authorization. That is exactly what got versions
|
||||
1.0.1 through 1.1.1 banned from the registry, and the finding was correct.
|
||||
|
||||
### The containment, and where it runs
|
||||
|
||||
The guard function `folder_allowed` in `craftkit_folder_guard.py` decides
|
||||
whether a path may be used at all. A path is accepted only if:
|
||||
|
||||
1. it resolves inside one of ComfyUI's own directories
|
||||
(the input, output and temp directory getters on ComfyUI's own
|
||||
`folder_paths` module), or
|
||||
2. it resolves inside a folder the user approved explicitly, stored in
|
||||
ComfyUI's own settings store under the key `CraftKit.AllowedFolders`, or
|
||||
3. it resolves inside a folder in the legacy
|
||||
`<user dir>/craftkit/allowed_folders.json`, kept readable so approvals made
|
||||
before 1.1.5 survive the update. Nothing in the package writes that file any
|
||||
more.
|
||||
|
||||
Containment is `os.path.realpath` on both sides plus `os.path.commonpath`
|
||||
against each allowed root, in the `is_path_under` helper. Resolving both sides
|
||||
means a symlink or junction pointing out of an approved folder does not slip
|
||||
through,
|
||||
and `..` cannot walk upward, because the comparison happens after resolution.
|
||||
|
||||
The guard is called before any filesystem access, on both paths:
|
||||
|
||||
| Call site | Guards |
|
||||
| --- | --- |
|
||||
| `smart_batch_resize.py:251` | the node's run method, ahead of the directory listing, ahead of any image decode, and ahead of creating the output directory |
|
||||
| `smart_batch_resize.py:213` | the IS_CHANGED classmethod, ahead of the listing used for cache hashing |
|
||||
|
||||
So reads are gated, not only writes. No pixel data can be decoded out of a
|
||||
folder the user has not approved, which is the 1.0.1 `ARBITRARY_FILE_READ`
|
||||
finding.
|
||||
|
||||
### The write target
|
||||
|
||||
The output subfolder name is validated separately, in the run method: it is
|
||||
rejected if it is absolute, carries a drive letter, contains `..`, or contains
|
||||
any path separator. The write target can therefore only ever be a direct child of an
|
||||
already-approved folder. A run that would write into the input folder itself is
|
||||
also refused, so originals are never overwritten.
|
||||
|
||||
### No escape hatch
|
||||
|
||||
Before 1.1.5 the config honoured an `"allow_any": true` flag that disabled
|
||||
containment. It is removed. A reviewer judges what the code makes possible, not
|
||||
what the default is, and a flag that turns off containment is a finding on its
|
||||
own. A user who needs a wide scope approves a wide folder instead, deliberately.
|
||||
|
||||
### Two smaller precautions
|
||||
|
||||
- **UNC paths are screened before resolution.** On Windows, merely resolving or
|
||||
stat-ing a `\\server\share` path opens an SMB connection and hands over an
|
||||
NTLM hash before any containment check gets to run. The `prescreen` helper
|
||||
therefore judges UNC values lexically and refuses them outright unless they
|
||||
sit under a share root the user already approved. No filesystem call happens
|
||||
first.
|
||||
- **The legacy approval list lives outside the package folder**, under
|
||||
ComfyUI's user directory. The package directory is a git working tree, and a
|
||||
stray `git add -A` there would publish the user's personal folder list.
|
||||
|
||||
## Removed in 1.1.5
|
||||
|
||||
Up to 1.1.4 the node had a "Browse folder" button, backed by a
|
||||
`POST /craftkit/browse_folder` route that opened the operating system's own
|
||||
folder dialog by launching a helper process: PowerShell's `FolderBrowserDialog`
|
||||
on Windows, `osascript` on macOS, `zenity` or `kdialog` on Linux.
|
||||
|
||||
The command lines were fixed constants with nothing interpolated into them, and
|
||||
the route was loopback-only with an `Origin`-versus-`Host` check. It was also
|
||||
the stronger design in one respect: the click-OK happened in the operating
|
||||
system, outside anything an HTTP request could reach.
|
||||
|
||||
It is gone regardless. Versions 1.1.2, 1.1.3 and 1.1.4 were banned for command
|
||||
injection, and 1.1.3 had already replaced the earlier COM-interop
|
||||
implementation with a plainer one, which was not enough. The feature is removed
|
||||
rather than reworked a third time. Folder approval is now a click in the
|
||||
ComfyUI frontend that writes `CraftKit.AllowedFolders` through ComfyUI's own
|
||||
settings route. The package only ever reads that setting.
|
||||
|
||||
This means the package registers no routes and starts no processes at all,
|
||||
which is what removes the entire class of finding rather than one instance of
|
||||
it.
|
||||
|
||||
## Known scanner findings and why they are false positives
|
||||
|
||||
| Pattern | Where | What it is |
|
||||
| --- | --- | --- |
|
||||
| Pillow image decode | `smart_batch_resize.py` | Decoding an image file in a folder the user approved. The guard runs above it. |
|
||||
| pathlib directory listing | `smart_batch_resize.py` | Listing that same approved folder. Same guard. |
|
||||
| two read-only file opens | `craftkit_folder_guard.py` | Reading two JSON files, both under ComfyUI's user directory, both with fixed names, never written. A missing or malformed file means "nothing extra is approved", never an exception mid-run. |
|
||||
| JSON parsing | `craftkit_folder_guard.py` | The same two files. No pickle, no YAML loader, no deserialization of user-supplied objects. |
|
||||
|
||||
If a scan reports something not listed here, please tell us which rule and
|
||||
which line. We would rather remove a construct than have a version banned for
|
||||
it.
|
||||
+23
-109
@@ -11,112 +11,26 @@ NODE_DISPLAY_NAME_MAPPINGS = {**A_NAMES, **B_NAMES, **C_NAMES, **D_NAMES, **E_NA
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
@PromptServer.instance.routes.get("/craftkit/browse_folder")
|
||||
async def browse_folder(request):
|
||||
import subprocess, sys
|
||||
|
||||
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)
|
||||
{
|
||||
var dialog = (IFileDialog)new FileOpenDialogRCW();
|
||||
dialog.SetOptions(0x20u | 0x40u); // FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM
|
||||
if (!string.IsNullOrEmpty(title)) dialog.SetTitle(title);
|
||||
int hr = dialog.Show(owner);
|
||||
if (hr != 0) return null;
|
||||
IShellItemLocal item;
|
||||
dialog.GetResult(out item);
|
||||
IntPtr pszPath;
|
||||
item.GetDisplayName(0x80058000u, out pszPath); // SIGDN_FILESYSPATH
|
||||
string path = Marshal.PtrToStringAuto(pszPath);
|
||||
Marshal.FreeCoTaskMem(pszPath);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
"@
|
||||
|
||||
$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()
|
||||
$path = [CraftKitFolderPicker]::ShowDialog($o.Handle, 'Select input folder')
|
||||
if ($path) { $script:r = $path }
|
||||
$o.Close()
|
||||
})
|
||||
$o.ShowDialog() | Out-Null
|
||||
$r
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["powershell", "-NoProfile", "-Command", ps],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
folder = await loop.run_in_executor(pool, _open_dialog)
|
||||
|
||||
if folder and __import__("os").path.isdir(folder):
|
||||
return web.json_response({"ok": True, "path": folder})
|
||||
return web.json_response({"ok": False, "cancelled": True})
|
||||
# This package deliberately registers no HTTP routes and launches no external
|
||||
# processes.
|
||||
#
|
||||
# Up to 1.1.4 there was a "Browse folder" button here, backed by a POST
|
||||
# /craftkit/browse_folder route that opened the native OS folder dialog by
|
||||
# launching a helper process: PowerShell's FolderBrowserDialog on Windows,
|
||||
# osascript on macOS, zenity/kdialog on Linux. The commands were fixed
|
||||
# constants with nothing interpolated into them, and the route was
|
||||
# loopback-only with an Origin/Host check.
|
||||
#
|
||||
# The Comfy Registry's yara scanner matches every process launch under rule
|
||||
# `python_command_injection_risk` regardless of whether attacker input can
|
||||
# reach it. On 1.1.2/1.1.3/1.1.4 that produced three severity-"info" hits, all
|
||||
# of them these dialog calls and nothing else, after which a registry admin
|
||||
# escalated each version from Flagged to Banned. Removing the calls removes the
|
||||
# only finding the scanner has against this package.
|
||||
#
|
||||
# The scanner reads source text, so this note deliberately avoids naming the
|
||||
# module involved: even a mention inside a comment can re-trigger the rule.
|
||||
#
|
||||
# Do not reintroduce a process launch or a PromptServer route here without
|
||||
# checking the registry's current policy first. Folder approval moved to
|
||||
# ComfyUI's own settings store instead — see craftkit_folder_guard.py.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 169 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 171 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 367 KiB |
@@ -0,0 +1,319 @@
|
||||
"""Containment guard for nodes that take a free-text folder path (SmartBatchResize's
|
||||
`input_folder`).
|
||||
|
||||
WHY THIS EXISTS
|
||||
----------------
|
||||
ComfyUI's /prompt endpoint is unauthenticated, so any STRING widget value is
|
||||
effectively attacker-controlled: a malicious webpage open in the same browser
|
||||
can POST a crafted prompt to http://127.0.0.1:8188/prompt via CSRF. A node
|
||||
that reads/writes an arbitrary user-supplied folder is therefore an
|
||||
unconfined filesystem read/write reachable without any real authorization —
|
||||
exactly what got CraftKit 1.0.1-1.1.1 banned from the registry (HIGH severity,
|
||||
see smart_batch_resize.py:246-263 in the pre-fix version).
|
||||
|
||||
THE FIX: a folder is only usable if one of these holds:
|
||||
a) it's inside ComfyUI's own input/output/temp directories, or
|
||||
b) the user approved it, either via the "Approve folder" button on the node
|
||||
or by editing Settings → CraftKit → Approved folders (both write the
|
||||
`CraftKit.AllowedFolders` setting through ComfyUI's own settings store),
|
||||
or
|
||||
c) it's listed in the legacy allowed_folders.json.
|
||||
|
||||
There is deliberately no "allow everything" escape hatch. An earlier version of
|
||||
this file honoured an "allow_any": true flag in the config; it was removed in
|
||||
1.1.5 because a registry reviewer judges what the code makes possible, not what
|
||||
the default is, and a flag that disables containment outright is a finding on
|
||||
its own. Anyone who needs a wide scope approves a wide folder instead.
|
||||
|
||||
Up to 1.1.4, (b) was a native OS folder dialog: the click-OK happened in the
|
||||
operating system, outside anything an HTTP request could reach. That was the
|
||||
stronger signal, but it meant launching a helper process, which the registry
|
||||
scanner flags as command injection regardless of context — see the note in
|
||||
__init__.py. Approval is now a click in the ComfyUI frontend instead, which
|
||||
is weaker on paper (ComfyUI's own settings route is unauthenticated too) but
|
||||
still confines every path to a list the user built deliberately, rather than
|
||||
accepting whatever a prompt payload contains.
|
||||
|
||||
Legacy config lives at <ComfyUI user dir>/craftkit/allowed_folders.json, not
|
||||
inside this plugin's folder (which is a git working tree — a stray
|
||||
`git add -A` would publish the user's personal folder list). It is still read
|
||||
so folders approved with the old dialog keep working after an update; nothing
|
||||
writes it any more.
|
||||
|
||||
Pattern adapted from Pixaroma's ComfyUI-Pixaroma/nodes/_path_guard.py, which
|
||||
solves the identical problem for its own arbitrary-folder features.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
_CONFIG_NAME = "allowed_folders.json"
|
||||
|
||||
# Setting id under which approved folders are stored in ComfyUI's own settings.
|
||||
# Must stay identical to the id registered in js/smart_batch_resize.js.
|
||||
SETTING_KEY = "CraftKit.AllowedFolders"
|
||||
|
||||
|
||||
def is_path_under(child: str, *parents: str) -> bool:
|
||||
"""True iff `child` resolves inside ANY of `parents`.
|
||||
|
||||
Strict realpath-on-both-sides is the primary (and usually only) test. A
|
||||
lexical (abspath) fallback runs only when the strict comparison can't even
|
||||
be made because the two sides land on different drives (e.g. a models
|
||||
folder junctioned onto another disk) — os.path.commonpath raises
|
||||
ValueError in that case rather than just returning False.
|
||||
"""
|
||||
if not child or not isinstance(child, str):
|
||||
return False
|
||||
try:
|
||||
child_real = os.path.realpath(child)
|
||||
child_abs = os.path.abspath(child)
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
for p in parents:
|
||||
if not p or not isinstance(p, str):
|
||||
continue
|
||||
cross_drive = False
|
||||
try:
|
||||
parent_real = os.path.realpath(p)
|
||||
if os.path.commonpath([child_real, parent_real]) == parent_real:
|
||||
return True
|
||||
except ValueError:
|
||||
cross_drive = True
|
||||
except (OSError, TypeError):
|
||||
continue
|
||||
if not cross_drive:
|
||||
continue
|
||||
try:
|
||||
parent_abs = os.path.abspath(p)
|
||||
if os.path.commonpath([os.path.normcase(child_abs), os.path.normcase(parent_abs)]) == os.path.normcase(parent_abs):
|
||||
return True
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _norm_raw(p) -> str:
|
||||
if not p or not isinstance(p, str):
|
||||
return ""
|
||||
return p.strip().strip('"').strip("'").strip()
|
||||
|
||||
|
||||
def unc_like(p) -> bool:
|
||||
r"""True for a Windows UNC path (\\server\share, //server/share).
|
||||
|
||||
Purely lexical — must not touch the filesystem. Merely calling
|
||||
os.path.realpath/isdir on a UNC path makes Windows open an SMB connection
|
||||
and hand over an NTLM hash before any containment check gets to run, so a
|
||||
UNC value has to be judged before it's resolved, not after.
|
||||
"""
|
||||
return _norm_raw(p).replace("/", "\\").startswith("\\\\")
|
||||
|
||||
|
||||
def is_unc_root(p) -> bool:
|
||||
r"""True when `p` is exactly a UNC share root (\\server\share, no subpath).
|
||||
|
||||
os.path.commonpath can't compare such a root against a path inside it
|
||||
(splitdrive gives an empty, "relative"-looking remainder), so it has to be
|
||||
matched by prefix instead — see _lexically_under_any.
|
||||
"""
|
||||
try:
|
||||
drive, rest = os.path.splitdrive(os.path.abspath(p))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
return drive.replace("/", "\\").startswith("\\\\") and rest.strip("\\/") == ""
|
||||
|
||||
|
||||
def _lexically_under_any(child: str, parents) -> bool:
|
||||
"""Prefix test on abspath only — no realpath, so no filesystem access.
|
||||
Weaker than is_path_under (a symlink could fool it); used only to accept a
|
||||
UNC path under a share root the user already approved via the picker."""
|
||||
child = _norm_raw(child)
|
||||
if not child:
|
||||
return False
|
||||
try:
|
||||
c = os.path.normcase(os.path.abspath(child))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return False
|
||||
for p in parents or ():
|
||||
p = _norm_raw(p)
|
||||
if not p:
|
||||
continue
|
||||
try:
|
||||
pa = os.path.normcase(os.path.abspath(p)).rstrip("\\/")
|
||||
except (OSError, ValueError, TypeError):
|
||||
continue
|
||||
if pa and (c == pa or c.startswith(pa + os.sep) or c.startswith(pa + "/")):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def comfy_roots() -> list:
|
||||
"""ComfyUI's own input/output/temp directories, whichever resolve.
|
||||
|
||||
folder_paths is imported lazily so this module stays importable outside a
|
||||
live ComfyUI process (e.g. under pytest).
|
||||
"""
|
||||
roots = []
|
||||
try:
|
||||
import folder_paths
|
||||
except Exception:
|
||||
return roots
|
||||
for getter in ("get_input_directory", "get_output_directory", "get_temp_directory"):
|
||||
try:
|
||||
fn = getattr(folder_paths, getter, None)
|
||||
v = fn() if callable(fn) else None
|
||||
if v:
|
||||
roots.append(v)
|
||||
except Exception:
|
||||
continue
|
||||
return roots
|
||||
|
||||
|
||||
def _config_path() -> str:
|
||||
base = None
|
||||
try:
|
||||
import folder_paths
|
||||
base = folder_paths.get_user_directory()
|
||||
except Exception:
|
||||
base = None
|
||||
if not base:
|
||||
base = os.path.join(os.path.expanduser("~"), ".craftkit")
|
||||
return os.path.join(base, "craftkit", _CONFIG_NAME)
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
"""Never raises — a missing or damaged file means 'nothing extra is
|
||||
allowed', never an exception mid-run. `damaged` is kept from when this file
|
||||
was still written by the native-dialog approval flow; nothing writes it any
|
||||
more, so today it only downgrades a corrupt file to 'nothing extra'."""
|
||||
out = {"folders": [], "damaged": False}
|
||||
path = _config_path()
|
||||
if not os.path.exists(path):
|
||||
return out
|
||||
try:
|
||||
# utf-8-sig transparently strips a BOM if present (e.g. a file written
|
||||
# by PowerShell's `Set-Content -Encoding utf8`, or hand-edited in an
|
||||
# older Notepad) — plain utf-8 would otherwise fail to parse it and
|
||||
# this file would wrongly read as "damaged".
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
obj = json.load(f)
|
||||
except Exception:
|
||||
out["damaged"] = True
|
||||
return out
|
||||
if not isinstance(obj, dict):
|
||||
out["damaged"] = True
|
||||
return out
|
||||
folders = obj.get("folders")
|
||||
if isinstance(folders, list):
|
||||
out["folders"] = [x for x in folders if isinstance(x, str) and x.strip()]
|
||||
elif folders is not None:
|
||||
out["damaged"] = True
|
||||
return out
|
||||
|
||||
|
||||
def _settings_file() -> str:
|
||||
"""ComfyUI's own settings JSON, where the Approve button stores folders.
|
||||
|
||||
Written by ComfyUI itself (the frontend POSTs to its /settings route); this
|
||||
module only ever reads it. Nothing here registers a route or writes the
|
||||
file, which is what keeps the package free of the patterns that got
|
||||
1.1.2-1.1.4 banned — see __init__.py.
|
||||
"""
|
||||
base = None
|
||||
try:
|
||||
import folder_paths
|
||||
base = folder_paths.get_user_directory()
|
||||
except Exception:
|
||||
base = None
|
||||
if not base:
|
||||
return ""
|
||||
default = os.path.join(base, "default", "comfy.settings.json")
|
||||
if os.path.exists(default):
|
||||
return default
|
||||
# Older layouts kept the file directly under the user directory.
|
||||
flat = os.path.join(base, "comfy.settings.json")
|
||||
return flat if os.path.exists(flat) else ""
|
||||
|
||||
|
||||
def _split_folders(value) -> list:
|
||||
"""Accept either a list or a ';'/newline-separated string.
|
||||
|
||||
The setting is a single-line text field in ComfyUI's settings UI, so ';'
|
||||
is the practical separator (no Windows or POSIX path contains one).
|
||||
Newlines are accepted too for anyone who pastes a list by hand.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
raw = [x for x in value if isinstance(x, str)]
|
||||
elif isinstance(value, str):
|
||||
raw = value.replace("\r", "\n").replace(";", "\n").split("\n")
|
||||
else:
|
||||
return []
|
||||
return [p for p in (_norm_raw(x) for x in raw) if p]
|
||||
|
||||
|
||||
def settings_folders() -> list:
|
||||
"""Folders the user approved via the node button or the settings UI.
|
||||
|
||||
Never raises: an unreadable or malformed settings file means "nothing extra
|
||||
is approved", never an exception mid-run.
|
||||
"""
|
||||
path = _settings_file()
|
||||
if not path:
|
||||
return []
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8-sig") as f:
|
||||
obj = json.load(f)
|
||||
except Exception:
|
||||
return []
|
||||
if not isinstance(obj, dict):
|
||||
return []
|
||||
return _split_folders(obj.get(SETTING_KEY))
|
||||
|
||||
|
||||
def approved_folders() -> list:
|
||||
"""Every folder the user approved, from both stores.
|
||||
|
||||
settings first (the live one, written by the Approve button), then the
|
||||
legacy JSON so folders approved with the pre-1.1.5 native dialog keep
|
||||
working across the update without the user re-approving them.
|
||||
"""
|
||||
return settings_folders() + _read_config()["folders"]
|
||||
|
||||
|
||||
def prescreen(raw) -> bool:
|
||||
"""Cheap no-filesystem screen to run BEFORE anything calls realpath/isdir
|
||||
on an untrusted folder string. False = refuse outright without ever
|
||||
touching the filesystem (see unc_like's docstring for why that matters)."""
|
||||
if unc_like(raw):
|
||||
return _lexically_under_any(raw, approved_folders())
|
||||
return True
|
||||
|
||||
|
||||
def folder_allowed(path: str) -> bool:
|
||||
"""True when `path` is somewhere SmartBatchResize may read/write."""
|
||||
if not path or not isinstance(path, str):
|
||||
return False
|
||||
if not prescreen(path):
|
||||
return False
|
||||
roots = comfy_roots()
|
||||
if roots and is_path_under(path, *roots):
|
||||
return True
|
||||
folders = approved_folders()
|
||||
if not folders:
|
||||
return False
|
||||
if is_path_under(path, *folders):
|
||||
return True
|
||||
unc_roots = [f for f in folders if is_unc_root(f)]
|
||||
return bool(unc_roots) and _lexically_under_any(path, unc_roots)
|
||||
|
||||
|
||||
def denied_message(path: str) -> str:
|
||||
return (
|
||||
f"[SmartBatchResize] Folder not approved: {path}\n"
|
||||
"To approve it: put this path in the node's input_folder field and click "
|
||||
"'Approve folder'. That approves it, and everything under it, permanently.\n"
|
||||
"The full list is editable under Settings > CraftKit > Approved folders.\n"
|
||||
"ComfyUI's own input/output/temp folders always work without approval."
|
||||
)
|
||||
@@ -33,6 +33,7 @@
|
||||
1024,
|
||||
8,
|
||||
"lanczos",
|
||||
true,
|
||||
"",
|
||||
true,
|
||||
false,
|
||||
@@ -41,7 +42,11 @@
|
||||
false,
|
||||
"resized",
|
||||
true,
|
||||
"_"
|
||||
"_",
|
||||
32,
|
||||
"keep_source",
|
||||
"auto",
|
||||
95
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
+118
-99
@@ -1,30 +1,105 @@
|
||||
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";
|
||||
|
||||
// Approved folders live in ComfyUI's own settings store, so approving one is a
|
||||
// plain settings write from the frontend: no CraftKit route, no helper process.
|
||||
// The id must stay identical to SETTING_KEY in craftkit_folder_guard.py.
|
||||
const SETTING_ID = "CraftKit.AllowedFolders";
|
||||
|
||||
function getSetting(id) {
|
||||
try {
|
||||
if (app.extensionManager?.setting?.get) return app.extensionManager.setting.get(id);
|
||||
} catch (e) { /* older frontend, fall through */ }
|
||||
try {
|
||||
return app.ui?.settings?.getSettingValue?.(id);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function setSetting(id, value) {
|
||||
if (app.extensionManager?.setting?.set) return await app.extensionManager.setting.set(id, value);
|
||||
return await app.ui.settings.setSettingValue(id, value);
|
||||
}
|
||||
|
||||
// ';' is the separator (no path contains one); newlines are accepted for
|
||||
// anyone who pastes a list into the settings field by hand. Mirrors
|
||||
// _split_folders() in craftkit_folder_guard.py.
|
||||
function splitFolders(value) {
|
||||
const parts = Array.isArray(value)
|
||||
? value
|
||||
: String(value ?? "").replace(/\r/g, "\n").replace(/;/g, "\n").split("\n");
|
||||
return parts.map(cleanPath).filter(Boolean);
|
||||
}
|
||||
|
||||
function cleanPath(p) {
|
||||
let s = String(p ?? "").trim().replace(/^["']+|["']+$/g, "").trim();
|
||||
// Strip a trailing separator, but never turn "D:/" into "D:".
|
||||
while (s.length > 3 && (s.endsWith("/") || s.endsWith("\\"))) s = s.slice(0, -1);
|
||||
return s;
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: "Craftopia.SmartBatchResize",
|
||||
|
||||
settings: [
|
||||
{
|
||||
id: SETTING_ID,
|
||||
category: ["CraftKit", "Folders", "Approved folders"],
|
||||
name: "Approved folders",
|
||||
tooltip:
|
||||
"Folders Smart Batch Resize is allowed to read and write, separated by ';'. " +
|
||||
"Subfolders are included. Use the 'Approve folder' button on the node to add one. " +
|
||||
"ComfyUI's own input/output/temp folders always work without being listed here.",
|
||||
type: "text",
|
||||
defaultValue: "",
|
||||
},
|
||||
],
|
||||
|
||||
async nodeCreated(node) {
|
||||
if (node.comfyClass !== "SmartBatchResize") return;
|
||||
|
||||
const folderWidget = node.widgets?.find(w => w.name === "input_folder");
|
||||
if (!folderWidget) return;
|
||||
|
||||
const btn = node.addWidget("button", "📁 Browse folder", null, async () => {
|
||||
try {
|
||||
const res = await fetch("/craftkit/browse_folder");
|
||||
const data = await res.json();
|
||||
if (data.ok && data.path) {
|
||||
folderWidget.value = data.path;
|
||||
const APPROVE_LABEL = "✅ Approve folder";
|
||||
const btn = node.addWidget("button", APPROVE_LABEL, null, async () => {
|
||||
const flash = (label) => {
|
||||
btn.label = label;
|
||||
btn.name = label;
|
||||
node.setDirtyCanvas(true);
|
||||
setTimeout(() => {
|
||||
btn.label = APPROVE_LABEL;
|
||||
btn.name = APPROVE_LABEL;
|
||||
node.setDirtyCanvas(true);
|
||||
}, 1800);
|
||||
};
|
||||
|
||||
const folder = cleanPath(folderWidget.value);
|
||||
if (!folder) {
|
||||
flash("⚠ Paste a path first");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const existing = splitFolders(getSetting(SETTING_ID));
|
||||
const known = existing.some(f => f.toLowerCase() === folder.toLowerCase());
|
||||
if (known) {
|
||||
flash("✔ Already approved");
|
||||
return;
|
||||
}
|
||||
await setSetting(SETTING_ID, [...existing, folder].join(";"));
|
||||
flash("✔ Folder approved");
|
||||
} catch (e) {
|
||||
console.error("[SmartBatchResize] Browse failed:", e);
|
||||
console.error("[SmartBatchResize] Could not approve folder:", e);
|
||||
flash("⚠ Approve failed (see console)");
|
||||
}
|
||||
}, { serialize: false });
|
||||
|
||||
btn.serialize = false;
|
||||
|
||||
// Move Browse button to right after input_folder (index 1)
|
||||
// Move Approve button to right after input_folder (index 1)
|
||||
const folderIdx = node.widgets.indexOf(folderWidget);
|
||||
const btnIdx = node.widgets.indexOf(btn);
|
||||
if (btnIdx !== folderIdx + 1) {
|
||||
@@ -32,70 +107,11 @@ app.registerExtension({
|
||||
node.widgets.splice(folderIdx + 1, 0, btn);
|
||||
}
|
||||
|
||||
// Compact preset chips for longest_side. Start from a REAL button widget
|
||||
// (so all of ComfyUI's layout/computeSize/sizing integration is correct)
|
||||
// and only override its draw + mouse. A bare custom widget with our own
|
||||
// computeSize forced the node width and broke the layout on click.
|
||||
// Compact preset chips for longest_side.
|
||||
const longestSideWidget = node.widgets?.find(w => w.name === "longest_side");
|
||||
if (longestSideWidget) {
|
||||
const PRESETS = [512, 768, 1024, 1536];
|
||||
|
||||
// Per-chip click handled in the button callback (buttons route clicks
|
||||
// to their callback, not to .mouse). We read the pointer position from
|
||||
// the canvas at click time and match it against the drawn chip rects.
|
||||
const presetWidget = node.addWidget("button", "size_presets", null, (value, canvas, node) => {
|
||||
const gm = (canvas || app.canvas)?.graph_mouse;
|
||||
if (!gm) return;
|
||||
const lx = gm[0] - node.pos[0];
|
||||
const ly = gm[1] - node.pos[1];
|
||||
for (const r of presetWidget._rects) {
|
||||
if (lx >= r.x1 && lx <= r.x2 && ly >= r.y1 && ly <= r.y2) {
|
||||
longestSideWidget.value = r.size;
|
||||
longestSideWidget.callback?.(r.size, app.canvas, node);
|
||||
node.setDirtyCanvas(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, { serialize: false });
|
||||
presetWidget.serialize = false;
|
||||
presetWidget._rects = [];
|
||||
|
||||
presetWidget.draw = function (ctx, node, widgetWidth, y, height) {
|
||||
const margin = 14;
|
||||
const gap = 5;
|
||||
const innerW = Math.max(40, widgetWidth - margin * 2);
|
||||
const n = PRESETS.length;
|
||||
const cellW = Math.max(10, (innerW - gap * (n - 1)) / n);
|
||||
const h = Math.min(height - 2, 18);
|
||||
const top = y + (height - h) / 2;
|
||||
const current = longestSideWidget.value;
|
||||
this._rects = [];
|
||||
|
||||
ctx.save();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = margin + i * (cellW + gap);
|
||||
const active = 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();
|
||||
};
|
||||
const presetWidget = createPresetPickerWidget(node, longestSideWidget, PRESETS);
|
||||
|
||||
// Move preset row to right after longest_side
|
||||
const lsIdx = node.widgets.indexOf(longestSideWidget);
|
||||
@@ -106,7 +122,25 @@ app.registerExtension({
|
||||
}
|
||||
}
|
||||
|
||||
// Override serialize so non-serializable JS widgets (Browse, presets, status,
|
||||
// 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 (Approve, 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).
|
||||
@@ -126,29 +160,8 @@ app.registerExtension({
|
||||
};
|
||||
|
||||
// Canvas-drawn status display
|
||||
const statusWidget = node.addWidget("button", "_sbr_status", null, () => {}, { serialize: false });
|
||||
statusWidget.serialize = false;
|
||||
statusWidget._text = "";
|
||||
|
||||
statusWidget.draw = function (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 || "— RUN TO PROCESS —", widgetWidth / 2, y + height / 2);
|
||||
ctx.restore();
|
||||
};
|
||||
const statusWidget = createStatusWidget("— RUN TO PROCESS —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
|
||||
// Run Batch button
|
||||
const runBtn = node.addWidget("button", "▶ Run Batch", null, () => {
|
||||
@@ -160,12 +173,8 @@ app.registerExtension({
|
||||
const useCounterWidget = node.widgets?.find(w => w.name === "use_counter");
|
||||
const counterStartWidget = node.widgets?.find(w => w.name === "counter_start");
|
||||
if (useCounterWidget && counterStartWidget) {
|
||||
const defaultComputeSize = counterStartWidget.computeSize;
|
||||
const updateCounterVisibility = () => {
|
||||
counterStartWidget.computeSize = useCounterWidget.value
|
||||
? defaultComputeSize
|
||||
: () => [0, -4];
|
||||
node.setDirtyCanvas(true);
|
||||
setWidgetVisible(node, counterStartWidget, useCounterWidget.value);
|
||||
};
|
||||
const origCallback = useCounterWidget.callback;
|
||||
useCounterWidget.callback = function (...args) {
|
||||
@@ -183,7 +192,7 @@ app.registerExtension({
|
||||
// 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") w.computeSize = () => [0, -4];
|
||||
if (w.type === "customtext") setWidgetVisible(node, w, false);
|
||||
}
|
||||
};
|
||||
hideCustomText();
|
||||
@@ -195,9 +204,19 @@ app.registerExtension({
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
+17
-50
@@ -1,4 +1,6 @@
|
||||
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",
|
||||
@@ -9,58 +11,14 @@ app.registerExtension({
|
||||
// Slot header dividers — visual only, not serialized
|
||||
const dividers = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const div = node.addWidget("button", `_div_${i}`, null, () => {}, { serialize: false });
|
||||
div.serialize = false;
|
||||
const num = i;
|
||||
div.draw = function (ctx, node, widgetWidth, y, height) {
|
||||
const lx = 14;
|
||||
const label = `SLOT ${num}`;
|
||||
ctx.save();
|
||||
ctx.font = "bold 10px sans-serif";
|
||||
ctx.textBaseline = "middle";
|
||||
ctx.textAlign = "left";
|
||||
ctx.fillStyle = "#888";
|
||||
ctx.fillText(label, lx, y + height / 2);
|
||||
const lineX = lx + ctx.measureText(label).width + 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(lineX, y + height / 2);
|
||||
ctx.lineTo(widgetWidth - 14, y + height / 2);
|
||||
ctx.strokeStyle = "#333";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
};
|
||||
const div = createDividerWidget(`SLOT ${i}`);
|
||||
node.addCustomWidget(div);
|
||||
dividers.push(div);
|
||||
}
|
||||
|
||||
// Status widget — shows active slot after execution
|
||||
const statusWidget = node.addWidget("button", "_sps_status", null, () => {}, { serialize: false });
|
||||
statusWidget.serialize = false;
|
||||
statusWidget._text = "";
|
||||
|
||||
statusWidget.draw = function (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 || "— RUN TO SEE RESULT —",
|
||||
widgetWidth / 2,
|
||||
y + height / 2
|
||||
);
|
||||
ctx.restore();
|
||||
};
|
||||
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");
|
||||
@@ -73,7 +31,13 @@ app.registerExtension({
|
||||
node.widgets.find(w => w.name === `height_${i}`),
|
||||
);
|
||||
}
|
||||
node.widgets = [indexWidget, ...slotWidgets, statusWidget];
|
||||
// Guard against a lookup returning undefined (e.g. a widget converted to
|
||||
// an input) and never drop a widget we didn't explicitly place — append
|
||||
// anything unrecognized at the end instead of silently discarding it.
|
||||
const orderedWidgets = [indexWidget, ...slotWidgets, statusWidget].filter(Boolean);
|
||||
const orderedSet = new Set(orderedWidgets);
|
||||
const leftoverWidgets = node.widgets.filter(w => !orderedSet.has(w));
|
||||
node.widgets = [...orderedWidgets, ...leftoverWidgets];
|
||||
|
||||
// Serialize override — keeps non-serializable widgets out of widgets_values
|
||||
const origSerialize = node.serialize;
|
||||
@@ -95,9 +59,12 @@ app.registerExtension({
|
||||
statusWidget._text = output.text[0];
|
||||
for (const w of node.widgets) {
|
||||
if (w !== statusWidget && w.type === "customtext") {
|
||||
w.computeSize = () => [0, -4];
|
||||
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",
|
||||
@@ -7,36 +9,22 @@ app.registerExtension({
|
||||
if (node.comfyClass !== "SmartPromptController") return;
|
||||
|
||||
// Canvas-drawn result display — clearly an output, not an input field
|
||||
const statusWidget = node.addWidget("button", "_spc_result", null, () => {}, { serialize: false });
|
||||
statusWidget.serialize = false;
|
||||
statusWidget._text = "";
|
||||
const statusWidget = createStatusWidget("— RUN TO SEE RESULT —");
|
||||
node.addCustomWidget(statusWidget);
|
||||
|
||||
statusWidget.draw = function (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 || "— RUN TO SEE RESULT —",
|
||||
widgetWidth / 2,
|
||||
y + height / 2
|
||||
);
|
||||
|
||||
ctx.restore();
|
||||
// Override serialize so non-serializable JS widgets (the result display)
|
||||
// are excluded from widgets_values in the saved workflow JSON. Without this,
|
||||
// LiteGraph saves null slots for these widgets which then shift all Python
|
||||
// widget values on load (configure runs before nodeCreated).
|
||||
const origSerialize = node.serialize;
|
||||
node.serialize = function () {
|
||||
const data = origSerialize.call(this);
|
||||
if (data.widgets_values) {
|
||||
data.widgets_values = this.widgets
|
||||
.filter(w => w.serialize !== false && w.options?.serialize !== false)
|
||||
.map(w => w.value);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
// Intercept onExecuted: update our widget, hide ComfyUI's default textarea
|
||||
@@ -47,12 +35,15 @@ app.registerExtension({
|
||||
if (output?.text?.[0]) {
|
||||
statusWidget._text = output.text[0];
|
||||
|
||||
// Collapse the default customtext widget ComfyUI adds for ui.text
|
||||
// Hide the default customtext widget ComfyUI adds for ui.text
|
||||
for (const w of node.widgets) {
|
||||
if (w !== statusWidget && w.type === "customtext") {
|
||||
w.computeSize = () => [0, -4];
|
||||
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);
|
||||
|
||||
+8
-54
@@ -1,4 +1,5 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
import { createPresetPickerWidget } from "./shared/preset_picker_widget.mjs?v=6";
|
||||
|
||||
app.registerExtension({
|
||||
name: "Craftopia.SmartResize",
|
||||
@@ -10,60 +11,7 @@ app.registerExtension({
|
||||
if (!longestSideWidget) return;
|
||||
|
||||
const PRESETS = [512, 768, 1024, 1536];
|
||||
|
||||
const presetWidget = node.addWidget("button", "size_presets", null, (value, canvas, node) => {
|
||||
const gm = (canvas || app.canvas)?.graph_mouse;
|
||||
if (!gm) return;
|
||||
const lx = gm[0] - node.pos[0];
|
||||
const ly = gm[1] - node.pos[1];
|
||||
for (const r of presetWidget._rects) {
|
||||
if (lx >= r.x1 && lx <= r.x2 && ly >= r.y1 && ly <= r.y2) {
|
||||
longestSideWidget.value = r.size;
|
||||
longestSideWidget.callback?.(r.size, app.canvas, node);
|
||||
node.setDirtyCanvas(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, { serialize: false });
|
||||
presetWidget.serialize = false;
|
||||
presetWidget._rects = [];
|
||||
|
||||
presetWidget.draw = function (ctx, node, widgetWidth, y, height) {
|
||||
const margin = 14;
|
||||
const gap = 5;
|
||||
const innerW = Math.max(40, widgetWidth - margin * 2);
|
||||
const n = PRESETS.length;
|
||||
const cellW = Math.max(10, (innerW - gap * (n - 1)) / n);
|
||||
const h = Math.min(height - 2, 18);
|
||||
const top = y + (height - h) / 2;
|
||||
const current = longestSideWidget.value;
|
||||
this._rects = [];
|
||||
|
||||
ctx.save();
|
||||
ctx.font = "11px sans-serif";
|
||||
ctx.textAlign = "center";
|
||||
ctx.textBaseline = "middle";
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = margin + i * (cellW + gap);
|
||||
const active = 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();
|
||||
};
|
||||
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).
|
||||
@@ -85,5 +33,11 @@ app.registerExtension({
|
||||
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);
|
||||
},
|
||||
});
|
||||
|
||||
+10
-1
@@ -1,12 +1,21 @@
|
||||
[project]
|
||||
name = "comfyui-craftkit"
|
||||
description = "Smart resize, batch resize, resolution multiplier and prompt controller nodes for ComfyUI"
|
||||
version = "1.0.2"
|
||||
version = "1.1.5"
|
||||
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 = []
|
||||
keywords = ["comfyui", "resize", "batch-resize", "resolution", "prompt", "upscale", "image-processing", "lora"]
|
||||
classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/CraftopiaStudio/ComfyUI-CraftKit"
|
||||
Issues = "https://github.com/CraftopiaStudio/ComfyUI-CraftKit/issues"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "craftopiastudio"
|
||||
|
||||
+194
-41
@@ -1,9 +1,20 @@
|
||||
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
|
||||
|
||||
from .craftkit_folder_guard import folder_allowed, denied_message
|
||||
|
||||
|
||||
INTERP_MAP = {
|
||||
"lanczos": PILImage.LANCZOS,
|
||||
@@ -12,23 +23,62 @@ INTERP_MAP = {
|
||||
"nearest": PILImage.NEAREST,
|
||||
}
|
||||
|
||||
SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tiff", ".tif"}
|
||||
# .avif/.heic decoding (and, with output_format=keep_source, re-encoding) depends on
|
||||
# optional Pillow plugins that may not be installed; if unsupported, the per-file
|
||||
# try/except below skips that file with a clear warning instead of crashing the batch.
|
||||
SUPPORTED_EXT = {".png", ".jpg", ".jpeg", ".jfif", ".webp", ".bmp", ".tiff", ".tif", ".avif", ".heic"}
|
||||
|
||||
|
||||
def _calc_new_size(w, h, longest_side, multiple_of):
|
||||
def _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller):
|
||||
longest = max(w, h)
|
||||
if longest <= longest_side:
|
||||
scaled = longest > longest_side or upscale_if_smaller
|
||||
if not scaled:
|
||||
new_w, new_h = w, h
|
||||
else:
|
||||
scale = longest_side / longest
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
new_w = round(w * scale)
|
||||
new_h = round(h * scale)
|
||||
if multiple_of > 1:
|
||||
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of)
|
||||
raw_w, raw_h = new_w, new_h
|
||||
new_w = max(multiple_of, round(raw_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(raw_h / multiple_of) * multiple_of)
|
||||
if scaled and max(new_w, new_h) > longest_side:
|
||||
# Rounding to the nearest multiple pushed the longest side past the
|
||||
# target — floor that side to the multiple instead and rescale the
|
||||
# other side to match, so the requested cap is always honored.
|
||||
if raw_w >= raw_h:
|
||||
floored = max(multiple_of, (raw_w // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_w
|
||||
new_w = floored
|
||||
new_h = max(multiple_of, round((raw_h * other_scale) / multiple_of) * multiple_of)
|
||||
else:
|
||||
floored = max(multiple_of, (raw_h // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_h
|
||||
new_h = floored
|
||||
new_w = max(multiple_of, round((raw_w * other_scale) / multiple_of) * multiple_of)
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
ALPHA_INCOMPATIBLE_EXT = {".jpg", ".jpeg", ".bmp"}
|
||||
|
||||
|
||||
def _flatten_to_white(img):
|
||||
img = img.convert("RGBA")
|
||||
bg = PILImage.new("RGB", img.size, (255, 255, 255))
|
||||
bg.paste(img, mask=img.split()[3])
|
||||
return bg
|
||||
|
||||
|
||||
def _apply_alpha_mode(img, alpha_mode):
|
||||
has_alpha = img.mode in ("RGBA", "LA", "PA") or ("transparency" in img.info)
|
||||
if alpha_mode == "flatten":
|
||||
return _flatten_to_white(img) if has_alpha else img.convert("RGB")
|
||||
if alpha_mode == "keep":
|
||||
return img.convert("RGBA")
|
||||
# auto
|
||||
return img.convert("RGBA") if has_alpha else img.convert("RGB")
|
||||
|
||||
|
||||
def _build_stem(original_stem, prefix, use_original_name, use_counter, counter_index, counter_start, suffix_resolution, longest_side, delimiter):
|
||||
parts = []
|
||||
|
||||
@@ -61,7 +111,7 @@ class SmartBatchResize:
|
||||
"input_folder": ("STRING", {
|
||||
"default": "",
|
||||
"multiline": False,
|
||||
"tooltip": "Folder containing images to resize."
|
||||
"tooltip": "Folder containing images to resize. Only files directly in this folder are scanned — subfolders are not included."
|
||||
}),
|
||||
|
||||
# RESIZE SETTINGS
|
||||
@@ -77,6 +127,10 @@ class SmartBatchResize:
|
||||
"default": "lanczos",
|
||||
"tooltip": "Resampling method. Lanczos is sharpest for downscaling."
|
||||
}),
|
||||
"upscale_if_smaller": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Upscale images that are smaller than the target longest side. Turn off to only ever downscale, never upscale."
|
||||
}),
|
||||
|
||||
# OUTPUT NAMING
|
||||
"prefix": ("STRING", {
|
||||
@@ -100,6 +154,11 @@ class SmartBatchResize:
|
||||
"default": True,
|
||||
"tooltip": "Append resolution to filename. E.g. photo_1024.png"
|
||||
}),
|
||||
"delimiter": ("STRING", {
|
||||
"default": "_",
|
||||
"multiline": False,
|
||||
"tooltip": "Separator between filename parts. E.g. _ or -"
|
||||
}),
|
||||
|
||||
# OUTPUT LOCATION
|
||||
"folder_resolution": ("BOOLEAN", {
|
||||
@@ -112,15 +171,28 @@ class SmartBatchResize:
|
||||
"tooltip": "Subfolder name. Resolution is appended if 'Create resolution subfolder' is on."
|
||||
}),
|
||||
|
||||
# OUTPUT FORMAT
|
||||
"output_format": (["keep_source", "png", "webp", "jpg"], {
|
||||
"default": "keep_source",
|
||||
"tooltip": "File format for saved images. 'keep_source' preserves each file's original extension. jpg cannot store transparency."
|
||||
}),
|
||||
"alpha_mode": (["auto", "flatten", "keep"], {
|
||||
"default": "auto",
|
||||
"tooltip": "auto: preserve transparency only if the source has it. flatten: always composite onto white and output opaque RGB. keep: always output RGBA. jpg/bmp output always flattens regardless (those formats can't store alpha)."
|
||||
}),
|
||||
"quality": ("INT", {
|
||||
"default": 95, "min": 1, "max": 100, "step": 1,
|
||||
"tooltip": "Compression quality for jpg/webp output. Ignored for png (always lossless)."
|
||||
}),
|
||||
|
||||
# OPTIONS
|
||||
"skip_if_exists": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": "Skip files that already exist in the output folder."
|
||||
"tooltip": "Skip files that already exist in the output folder AND still match the current size settings. If longest_side/multiple_of/upscale_if_smaller changed since the file was made, it's reprocessed instead of skipped."
|
||||
}),
|
||||
"delimiter": ("STRING", {
|
||||
"default": "_",
|
||||
"multiline": False,
|
||||
"tooltip": "Separator between filename parts. E.g. _ or -"
|
||||
"preview_limit": ("INT", {
|
||||
"default": 32, "min": 0, "max": 10000, "step": 1,
|
||||
"tooltip": "Max images to keep in memory for the preview output. All files are still processed and saved to disk; this only limits what's returned/previewed. 0 = no limit."
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -132,21 +204,52 @@ class SmartBatchResize:
|
||||
CATEGORY = "CraftKit"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def run(self, input_folder, longest_side, multiple_of, interpolation,
|
||||
@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 folder_allowed(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):
|
||||
skip_if_exists, delimiter, preview_limit, output_format, alpha_mode, quality):
|
||||
|
||||
# Resolve output subfolder
|
||||
subfolder = folder_custom.strip()
|
||||
if subfolder != "" and (
|
||||
os.path.isabs(subfolder)
|
||||
or Path(subfolder).drive
|
||||
or ".." in Path(subfolder).parts
|
||||
or os.sep in subfolder
|
||||
or (os.altsep and os.altsep in subfolder)
|
||||
):
|
||||
raise ValueError(f"[SmartBatchResize] Invalid subfolder name: {folder_custom!r}. Use a plain folder name, not a path.")
|
||||
if subfolder == "":
|
||||
subfolder = str(longest_side) if folder_resolution else "resized"
|
||||
elif folder_resolution:
|
||||
subfolder = f"{subfolder}{delimiter}{longest_side}"
|
||||
|
||||
if not use_original_name and not use_counter:
|
||||
raise ValueError("[SmartBatchResize] Enable 'use_original_name' or 'use_counter' — otherwise all files would collapse to the same output name.")
|
||||
|
||||
input_folder = input_folder.strip()
|
||||
if not input_folder:
|
||||
raise ValueError("[SmartBatchResize] No folder selected. Enter a path or use the Browse button.")
|
||||
raise ValueError("[SmartBatchResize] No folder selected. Paste a folder path into input_folder.")
|
||||
if not folder_allowed(input_folder):
|
||||
raise ValueError(denied_message(input_folder))
|
||||
if not os.path.isdir(input_folder):
|
||||
raise ValueError(f"[SmartBatchResize] Folder not found: {input_folder}")
|
||||
|
||||
@@ -159,11 +262,19 @@ class SmartBatchResize:
|
||||
raise ValueError(f"[SmartBatchResize] No images found in: {input_folder}")
|
||||
|
||||
out_dir = Path(input_folder) / subfolder
|
||||
if out_dir.resolve() == Path(input_folder).resolve():
|
||||
raise ValueError("[SmartBatchResize] Output folder resolves to the input folder — refusing to overwrite originals.")
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
interp = INTERP_MAP[interpolation]
|
||||
images_out = []
|
||||
counter_index = 0
|
||||
skipped_count = 0
|
||||
processed_count = 0
|
||||
failed_count = 0
|
||||
total_count = 0
|
||||
keep_all = preview_limit == 0
|
||||
pbar = ProgressBar(len(files)) if ProgressBar else None
|
||||
|
||||
for f in files:
|
||||
stem = _build_stem(
|
||||
@@ -177,34 +288,76 @@ class SmartBatchResize:
|
||||
longest_side=longest_side,
|
||||
delimiter=delimiter,
|
||||
)
|
||||
out_name = f"{stem}{f.suffix.lower()}"
|
||||
out_ext = f.suffix.lower() if output_format == "keep_source" else f".{output_format}"
|
||||
out_name = f"{stem}{out_ext}"
|
||||
out_path = out_dir / out_name
|
||||
keep_preview = keep_all or total_count < preview_limit
|
||||
|
||||
if skip_if_exists and out_path.exists():
|
||||
print(f"[SmartBatchResize] Skipped (exists): {out_name}")
|
||||
try:
|
||||
can_skip = False
|
||||
if skip_if_exists and out_path.exists():
|
||||
# Only honor the skip if the existing file's actual dimensions
|
||||
# still match what the current settings would produce — otherwise
|
||||
# a lowered longest_side/multiple_of would silently keep serving
|
||||
# a stale size from a previous run.
|
||||
try:
|
||||
src_w, src_h = PILImage.open(f).size
|
||||
expected_w, expected_h = _calc_new_size(src_w, src_h, longest_side, multiple_of, upscale_if_smaller)
|
||||
can_skip = PILImage.open(out_path).size == (expected_w, expected_h)
|
||||
except Exception:
|
||||
can_skip = False
|
||||
|
||||
if can_skip:
|
||||
print(f"[SmartBatchResize] Skipped (exists, matches current settings): {out_name}")
|
||||
skipped_count += 1
|
||||
total_count += 1
|
||||
counter_index += 1
|
||||
if keep_preview:
|
||||
existing = PILImage.open(out_path)
|
||||
existing = _apply_alpha_mode(existing, alpha_mode)
|
||||
arr = np.array(existing).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
continue
|
||||
elif skip_if_exists and out_path.exists():
|
||||
print(f"[SmartBatchResize] Reprocessing (existing file doesn't match current settings): {out_name}")
|
||||
|
||||
img = PILImage.open(f)
|
||||
img = ImageOps.exif_transpose(img)
|
||||
img = _apply_alpha_mode(img, alpha_mode)
|
||||
w, h = img.size
|
||||
new_w, new_h = _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller)
|
||||
|
||||
img_resized = img.resize((new_w, new_h), interp)
|
||||
|
||||
if img_resized.mode == "RGBA" and out_ext in ALPHA_INCOMPATIBLE_EXT:
|
||||
img_resized = _flatten_to_white(img_resized)
|
||||
print(f"[SmartBatchResize] {f.name}: flattened transparency for {out_ext} (format doesn't support alpha)")
|
||||
|
||||
save_kwargs = {"quality": quality} if out_ext in (".jpg", ".jpeg", ".webp") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
|
||||
processed_count += 1
|
||||
total_count += 1
|
||||
counter_index += 1
|
||||
continue
|
||||
if keep_preview:
|
||||
arr = np.array(img_resized).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
except Exception as e:
|
||||
print(f"[SmartBatchResize] Failed: {f.name}: {e}")
|
||||
failed_count += 1
|
||||
finally:
|
||||
if pbar:
|
||||
pbar.update(1)
|
||||
|
||||
img = PILImage.open(f).convert("RGB")
|
||||
w, h = img.size
|
||||
new_w, new_h = _calc_new_size(w, h, longest_side, multiple_of)
|
||||
|
||||
img_resized = img.resize((new_w, new_h), interp)
|
||||
|
||||
save_kwargs = {"quality": 95} if f.suffix.lower() in (".jpg", ".jpeg") else {}
|
||||
img_resized.save(out_path, **save_kwargs)
|
||||
|
||||
print(f"[SmartBatchResize] {f.name} → {out_name} ({w}x{h} → {new_w}x{new_h})")
|
||||
|
||||
arr = np.array(img_resized).astype("float32") / 255.0
|
||||
images_out.append(torch.from_numpy(arr).unsqueeze(0))
|
||||
counter_index += 1
|
||||
|
||||
if not images_out:
|
||||
raise ValueError("[SmartBatchResize] No images processed.")
|
||||
|
||||
summary = f"✓ {len(images_out)} images saved → {subfolder}/"
|
||||
return {"ui": {"text": [summary]}, "result": (images_out, len(images_out))}
|
||||
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}
|
||||
|
||||
@@ -37,7 +37,7 @@ class SmartProfileSwitch:
|
||||
RETURN_NAMES = ("label", "width", "height")
|
||||
FUNCTION = "route"
|
||||
CATEGORY = "CraftKit"
|
||||
OUTPUT_NODE = True
|
||||
OUTPUT_NODE = True # renders a status label on the node; must always execute, don't prune this
|
||||
|
||||
def route(self, index,
|
||||
label_1, width_1, height_1,
|
||||
@@ -50,6 +50,8 @@ class SmartProfileSwitch:
|
||||
(label_3, width_3, height_3),
|
||||
(label_4, width_4, height_4),
|
||||
]
|
||||
if not (1 <= index <= 4):
|
||||
print(f"[SmartProfileSwitch] index={index} is out of range (expected 1-4); clamping to slot {max(1, min(4, index))}. Check that Smart Prompt Controller is connected and has prompts.")
|
||||
idx = max(1, min(4, index)) - 1
|
||||
label, width, height = slots[idx]
|
||||
status = f"Slot {idx + 1}: {label} | {width} × {height}"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class SmartPromptController:
|
||||
"""
|
||||
Craftopia — Smart Prompt Controller 📋
|
||||
CraftKit — Smart Prompt Controller 📋
|
||||
Cycles through up to 4 prompt lists using a single incrementing counter.
|
||||
Counts lines automatically and outputs the selected prompt + list number.
|
||||
"""
|
||||
@@ -31,7 +31,7 @@ class SmartPromptController:
|
||||
RETURN_NAMES = ("prompt", "active_list")
|
||||
FUNCTION = "control"
|
||||
CATEGORY = "CraftKit"
|
||||
OUTPUT_NODE = True
|
||||
OUTPUT_NODE = True # renders a status label on the node; must always execute, don't prune this
|
||||
|
||||
def _get_lines(self, text):
|
||||
"""Return list of non-empty lines from a text block."""
|
||||
|
||||
+30
-11
@@ -13,15 +13,31 @@ INTERP_MAP = {
|
||||
|
||||
def _calc_new_size(w, h, longest_side, multiple_of, upscale_if_smaller):
|
||||
longest = max(w, h)
|
||||
if longest <= longest_side and not upscale_if_smaller:
|
||||
scaled = longest > longest_side or upscale_if_smaller
|
||||
if not scaled:
|
||||
new_w, new_h = w, h
|
||||
else:
|
||||
scale = longest_side / longest
|
||||
new_w = int(w * scale)
|
||||
new_h = int(h * scale)
|
||||
new_w = round(w * scale)
|
||||
new_h = round(h * scale)
|
||||
if multiple_of > 1:
|
||||
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(new_h / multiple_of) * multiple_of)
|
||||
raw_w, raw_h = new_w, new_h
|
||||
new_w = max(multiple_of, round(raw_w / multiple_of) * multiple_of)
|
||||
new_h = max(multiple_of, round(raw_h / multiple_of) * multiple_of)
|
||||
if scaled and max(new_w, new_h) > longest_side:
|
||||
# Rounding to the nearest multiple pushed the longest side past the
|
||||
# target — floor that side to the multiple instead and rescale the
|
||||
# other side to match, so the requested cap is always honored.
|
||||
if raw_w >= raw_h:
|
||||
floored = max(multiple_of, (raw_w // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_w
|
||||
new_w = floored
|
||||
new_h = max(multiple_of, round((raw_h * other_scale) / multiple_of) * multiple_of)
|
||||
else:
|
||||
floored = max(multiple_of, (raw_h // multiple_of) * multiple_of)
|
||||
other_scale = floored / raw_h
|
||||
new_h = floored
|
||||
new_w = max(multiple_of, round((raw_w * other_scale) / multiple_of) * multiple_of)
|
||||
return new_w, new_h
|
||||
|
||||
|
||||
@@ -60,9 +76,11 @@ class SmartResize:
|
||||
CATEGORY = "CraftKit"
|
||||
|
||||
def run(self, image, longest_side, multiple_of, interpolation, upscale_if_smaller):
|
||||
if image.shape[0] == 0:
|
||||
return (image, 0, 0)
|
||||
|
||||
interp = INTERP_MAP[interpolation]
|
||||
results = []
|
||||
last_w, last_h = 0, 0
|
||||
|
||||
for i in range(image.shape[0]):
|
||||
frame = image[i] # [H, W, C]
|
||||
@@ -72,15 +90,16 @@ class SmartResize:
|
||||
if new_w == w and new_h == h:
|
||||
results.append(frame)
|
||||
else:
|
||||
arr = (frame.numpy() * 255).astype("uint8")
|
||||
pil = PILImage.fromarray(arr, mode="RGB")
|
||||
channels = frame.shape[-1]
|
||||
pil_mode = {1: "L", 3: "RGB", 4: "RGBA"}.get(channels, "RGB")
|
||||
arr = np.clip(frame.detach().cpu().numpy() * 255, 0, 255).astype("uint8")
|
||||
pil = PILImage.fromarray(arr, mode=pil_mode)
|
||||
pil = pil.resize((new_w, new_h), interp)
|
||||
arr = np.array(pil).astype("float32") / 255.0
|
||||
results.append(torch.from_numpy(arr))
|
||||
|
||||
last_w, last_h = new_w, new_h
|
||||
|
||||
return (torch.stack(results, dim=0), last_w, last_h)
|
||||
stacked = torch.stack(results, dim=0)
|
||||
return (stacked, stacked.shape[2], stacked.shape[1])
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"SmartResize": SmartResize}
|
||||
|
||||
@@ -2,7 +2,7 @@ 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
|
||||
@@ -10,7 +10,7 @@ class SmartResolutionMultiplier:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE", {
|
||||
"tooltip": "Source image. Its current width/height are read directly — no separate Get Image Size node needed."
|
||||
"tooltip": "Source image. Its current width/height are read directly, no separate Get Image Size node needed."
|
||||
}),
|
||||
"multiplier": ("FLOAT", {
|
||||
"default": 2.0,
|
||||
@@ -38,8 +38,8 @@ class SmartResolutionMultiplier:
|
||||
def run(self, image, multiplier, multiple_of):
|
||||
h, w = image.shape[1], image.shape[2]
|
||||
|
||||
new_w = int(round(w * multiplier))
|
||||
new_h = int(round(h * multiplier))
|
||||
new_w = round(w * multiplier)
|
||||
new_h = round(h * multiplier)
|
||||
|
||||
if multiple_of > 1:
|
||||
new_w = max(multiple_of, round(new_w / multiple_of) * multiple_of)
|
||||
|
||||
Reference in New Issue
Block a user