15 Commits
Author SHA1 Message Date
Acly 9d2e03e8d5 Version 3.1.0 2026-01-05 10:18:20 +01:00
Acly c5606f8e8f API: print filename with stack traces when there is an error during inspection 2026-01-05 10:16:54 +01:00
Acly 79e9b6426f Support transmitting partial tiles/crops of the canvas in Krita workflows 2025-12-30 23:47:29 +01:00
Acly ad320a218c Support additional output info for Krita workflows: name, animation, layers 2025-12-29 21:20:57 +01:00
Jax a310f4593b Transmit request to resize the canvas with Krita Output node (#52)
* Added Krita Resize node for plugin
* Registered canvas resize node to the __init__.py
* Removed resizenode and integrated it into the "Krita output"
* Quick update to simplify the node to return the re-sized image to krita instead of a json
2025-12-29 19:35:06 +01:00
Acly 7d957dcfa7 Model inspection: support Z-Image SVDQ (Nunchaku) files 2025-12-22 11:25:09 +01:00
Acly 22cfd71f95 Fix detection of integer widget for Parameter node 2025-12-17 11:05:00 +01:00
Acly 21a2f44d4c Fix Parameter node min/max being reset to default when it's set to 0 #53
* Use a different default than 0 as workaround
* Don't want to change type of min/max as that would break workflows
2025-12-17 10:40:58 +01:00
Acly 0220252912 Version 3.0.1 2025-12-01 09:36:18 +01:00
Acly f447ef70fa Model inspection: support Z-Image GGUFs 2025-11-29 20:26:00 +01:00
Acly fb27a5bda8 Model inspection: support Lumina2, Z-Image, Flux2 2025-11-28 20:00:00 +01:00
Acly aa83259e66 Change image cache to take size into account 2025-11-09 15:01:52 +01:00
Acly 75c632df4b Version 3.0.0 2025-11-03 10:57:01 +01:00
Acly a088a2dde2 API: support pagination for /api/etn/model_info 2025-10-23 14:41:24 +02:00
Acly fbf99f2a08 Add LoadImageCached and SaveImageCached (renamed from SendImageHTTP)
* short-lived in-memory cache for image transfers
* upload images via HTTP to cache and load/reference them in workflows
* save/store images in workflows to cache and download them via HTTP
2025-10-20 14:39:42 +02:00
7 changed files with 267 additions and 56 deletions
+47 -12
View File
@@ -44,30 +44,45 @@ That is two 32-bit integers (big endian) with values 1 and 2 followed by the PNG
{'type': 'executed', 'data': {'node': '<node ID>', 'output': {'images': [{'source': 'websocket', 'content-type': 'image/png', 'type': 'output'}, ...]}, 'prompt_id': '<prompt ID>}} {'type': 'executed', 'data': {'node': '<node ID>', 'output': {'images': [{'source': 'websocket', 'content-type': 'image/png', 'type': 'output'}, ...]}, 'prompt_id': '<prompt ID>}}
``` ```
### Send Image (HTTP) ### Load Image from Cache
Loads an image or mask that has been uploaded previously into the workflow.
Uploaded images are temporarily stored in RAM rather than written to disk. This
method has less overhead compared to embedding images as base64 into the prompt,
but is more complex to implement.
* Inputs: id of an image that was uploaded previously
* Outputs: image (RGB) and mask (A of RGBA input, or first channel if no alpha present).
To upload an image, upload the _bytes_ of a PNG via a HTTP PUT request to
`/api/etn/image/{id}`. JPEG or other formats also work. Choose any `id` which
does not clash with other images you upload, and reference it in the node. The
request returns `201` if the image was uploaded and `200` if it was already
cached.
### Save Image to Cache
Stores an output image in RAM temporarily and allows retrieval over HTTP. Stores an output image in RAM temporarily and allows retrieval over HTTP.
This is typically faster than WebSocket, especially for large images. This is typically faster than WebSocket, especially for large images.
* Inputs: the image (RGB or RGBA), supports batches * Inputs: the image (RGB or RGBA). Batches are supported.
This node will send a JSON message over WebSocket when an image is ready: This node will send a JSON message over WebSocket when an image is ready:
```json ```json
{ {
'type': 'executed', "type": "executed",
'data': { "data": {
'node': '<node ID>', "node": "<node ID>",
'output': { "output": {
'images': [ "images": [
{'source': 'http', 'id': '<image ID>', 'content-type': 'image/png', 'type': 'output'} {"source": "http", "id": "<image ID>", "content-type": "image/png", "type": "output"}
] ]
}, },
'prompt_id': 'prompt ID' "prompt_id": "prompt ID"
} }
} }
``` ```
To download the images, send a HTTP GET request to `/api/etn/image/{id}` with the image IDs from the message. To download the images, send a HTTP GET request to `/api/etn/image/{id}` with
Images will be cached for a few minutes. the image IDs from the message. Images will be cached for a few minutes.
## <a id="regions" href="#toc">Regions</a> ## <a id="regions" href="#toc">Regions</a>
@@ -190,6 +205,8 @@ There are various types of models that can be loaded as checkpoint, LoRA, Contro
#### Paramters #### Paramters
* `folder_name`: sub-directory in ComfyUI's models folder. * `folder_name`: sub-directory in ComfyUI's models folder.
Supported model types: `checkpoints`, `diffusion_models`, `unet`, `unet_gguf` Supported model types: `checkpoints`, `diffusion_models`, `unet`, `unet_gguf`
* `limit=n`: (query parameter, optional) inspect at `n` models
* `offset=i`: (query parameter, optional) start with the `i`th model
#### Output #### Output
Lists available models with additional classification info: Lists available models with additional classification info:
@@ -203,7 +220,7 @@ Lists available models with additional classification info:
... ...
} }
``` ```
Possible values for base model: `sd15, sd20, sd21, sd3, sdxl, sdxl-refiner, ssd1b, svd, cascade-b, cascade-c, aura-flow, hunyuan-dit, flux, flux-schnell, lumina2, chroma, qwen-image` Possible values for base model: `sd15, sd20, sd21, sd3, sdxl, sdxl-refiner, ssd1b, svd, cascade-b, cascade-c, aura-flow, hunyuan-dit, flux, flux-schnell, flux2, lumina2, z-image, chroma, qwen-image`
If base model is `sdxl`, the `type` attribute is set with possible values: `eps, edm, v-prediction, v-prediction-edm` If base model is `sdxl`, the `type` attribute is set with possible values: `eps, edm, v-prediction, v-prediction-edm`
@@ -213,6 +230,24 @@ Detection supports quantized models:
Returns an entry `{"base_model": "unknown"}` for models with unknown format or which do not match any of the known base models. Returns an entry `{"base_model": "unknown"}` for models with unknown format or which do not match any of the known base models.
#### Pagination
The query parameters limit and offset allow inspecting a subset of models per request.
Usually inspection is quite fast (it only looks at model headers), but it can be slow
in some cases due to anti-virus or slow harddrives.
```
GET /api/etn/model_info/checkpoints?limit=10&offset=20
```
This will return at most 10 models, starting with the 20th model in the list.
It also returns a special `_meta` entry in the output JSON:
```json
{
"checkpoint_20.safetensors": { ... },
"_meta": { "offset": 20, "count": 1, "total": 21 }
}
```
### GET /api/etn/languages ### GET /api/etn/languages
Returns a list of available languages for translation. Returns a list of available languages for translation.
+2 -1
View File
@@ -5,10 +5,11 @@ from . import api as api, nodes, tile, region, nsfw, translation, krita
class ExternalToolingNodes(ComfyExtension): class ExternalToolingNodes(ComfyExtension):
async def get_node_list(self) -> list[type[io.ComfyNode]]: async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [ return [
nodes.LoadImageCache,
nodes.SaveImageCache,
nodes.LoadImageBase64, nodes.LoadImageBase64,
nodes.LoadMaskBase64, nodes.LoadMaskBase64,
nodes.SendImageWebSocket, nodes.SendImageWebSocket,
nodes.SendImageHTTP,
nodes.ApplyMaskToImage, nodes.ApplyMaskToImage,
nodes.ReferenceImage, nodes.ReferenceImage,
nodes.ApplyReferenceImages, nodes.ApplyReferenceImages,
+57 -14
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from aiohttp import web from aiohttp import web
from typing import NamedTuple from typing import Any, NamedTuple
from pathlib import Path from pathlib import Path
import json import json
import traceback import traceback
@@ -44,6 +44,8 @@ model_names = {
"CosmosI2V": "cosmos", "CosmosI2V": "cosmos",
"CosmosT2IPredict2": "cosmos-predict2", "CosmosT2IPredict2": "cosmos-predict2",
"CosmosI2VPredict2": "cosmos-predict2", "CosmosI2VPredict2": "cosmos-predict2",
"ZImage": "z-image",
"Lumina2": "lumina2",
"WAN21_T2V": "wan21", "WAN21_T2V": "wan21",
"WAN21_I2V": "wan21", "WAN21_I2V": "wan21",
"WAN21_FunControl2V": "wan21-fun", "WAN21_FunControl2V": "wan21-fun",
@@ -54,6 +56,7 @@ model_names = {
"ACEStep": "ace-step", "ACEStep": "ace-step",
"Omnigen2": "omnigen2", "Omnigen2": "omnigen2",
"QwenImage": "qwen-image", "QwenImage": "qwen-image",
"Flux2": "flux2",
} }
gguf_architectures = { gguf_architectures = {
@@ -123,7 +126,7 @@ def inspect_safetensors(filename: str, model_type: str, is_checkpoint: bool):
return {"base_model": "unknown"} return {"base_model": "unknown"}
base_model_name = model_names.get(raw_name, "unknown") base_model_name = model_names.get(raw_name, "unknown")
result = {"base_model": base_model_name} result: dict[str, Any] = {"base_model": base_model_name}
result["is_inpaint"] = ( result["is_inpaint"] = (
base_model_name in ["sd15", "sdxl"] and input_count > 4 base_model_name in ["sd15", "sdxl"] and input_count > 4
) or raw_name == "FluxInpaint" ) or raw_name == "FluxInpaint"
@@ -142,6 +145,7 @@ def inspect_safetensors(filename: str, model_type: str, is_checkpoint: bool):
return result return result
return {"base_model": "unknown"} return {"base_model": "unknown"}
except Exception as e: except Exception as e:
print("[comfyui-tooling-nodes] Error inspecting file", filename)
traceback.print_exc() traceback.print_exc()
return {"base_model": "unknown", "error": f"Failed to detect base model: {e}"} return {"base_model": "unknown", "error": f"Failed to detect base model: {e}"}
@@ -151,12 +155,16 @@ def detect_svdq(cfg: dict) -> str | None:
if comfy_config := md.get("comfy_config"): if comfy_config := md.get("comfy_config"):
if isinstance(comfy_config, str): if isinstance(comfy_config, str):
comfy_config = json.loads(comfy_config) comfy_config = json.loads(comfy_config)
return comfy_config.get("model_class") if model_class := comfy_config.get("model_class"):
model_class = md.get("model_class") return model_class
if model_class == "NunchakuFluxTransformer2dModel":
return "Flux" match md.get("model_class"):
if model_class == "NunchakuQwenImageTransformer2DModel": case "NunchakuFluxTransformer2dModel":
return "QwenImage" return "Flux"
case "NunchakuQwenImageTransformer2DModel":
return "QwenImage"
case "NunchakuZImageTransformer2DModel":
return "ZImage"
return None return None
@@ -168,6 +176,9 @@ def inspect_gguf(filename: str, model_type: str):
try: try:
path = folder_paths.get_full_path(model_type, filename) path = folder_paths.get_full_path(model_type, filename)
if path is None:
raise Exception(f"Could not find full path for {model_type}/{filename}")
reader = gguf.GGUFReader(path) reader = gguf.GGUFReader(path)
arch_field = reader.get_field("general.architecture") arch_field = reader.get_field("general.architecture")
if arch_field is not None: if arch_field is not None:
@@ -178,19 +189,29 @@ def inspect_gguf(filename: str, model_type: str):
arch_str = str(arch_field.parts[arch_field.data[-1]], encoding="utf-8") arch_str = str(arch_field.parts[arch_field.data[-1]], encoding="utf-8")
else: # stable-diffusion.cpp, requires conversion. not handled for now else: # stable-diffusion.cpp, requires conversion. not handled for now
return {"base_model": "flux", "is_inpaint": False} return {"base_model": "flux", "is_inpaint": False}
# Detect Chroma (modified Flux)
if arch_str == "flux" and any( if arch_str == "flux" and any(
t.name.startswith("distilled_guidance_layer") t.name.startswith("distilled_guidance_layer")
for t in itertools.islice(reader.tensors, 5) for t in itertools.islice(reader.tensors, 5)
): ):
arch_str = "chroma" arch_str = "chroma"
# Detect Z-Image (modified Lumina2)
if arch_str == "lumina2":
for t in reader.tensors:
if t.name == "cap_embedder.1.bias" and t.shape[0] == 3840:
arch_str = "z-image"
break
result = { result = {
"base_model": gguf_architectures.get(arch_str, arch_str), "base_model": gguf_architectures.get(arch_str, arch_str),
"is_inpaint": False, "is_inpaint": False,
} }
try: try:
result["quant"] = reader.get_field("general.file_type").lower() if file_type := reader.get_field("general.file_type"):
except Exception as e: result["quant"] = file_type.contents().lower()
except Exception:
result["quant"] = "gguf" result["quant"] = "gguf"
return result return result
@@ -205,17 +226,22 @@ def inspect_diffusion_model(filename: str, model_type: str, is_checkpoint: bool)
return inspect_safetensors(filename, model_type, is_checkpoint) return inspect_safetensors(filename, model_type, is_checkpoint)
def inspect_models(model_type: str): def inspect_models(model_type: str, params: dict[str, str]):
try: try:
try: try:
files = folder_paths.get_filename_list(model_type) files = folder_paths.get_filename_list(model_type)
except KeyError: except KeyError:
return web.json_response({"error": f"Model folder not found: {model_type}"}) return web.json_response({"error": f"Model folder not found: {model_type}"})
limit = int(params.get("limit", "1000"))
offset = int(params.get("offset", "0"))
files_range = files[offset : offset + limit]
is_checkpoint = model_type == "checkpoints" is_checkpoint = model_type == "checkpoints"
info = { info = {
filename: inspect_diffusion_model(filename, model_type, is_checkpoint) filename: inspect_diffusion_model(filename, model_type, is_checkpoint)
for filename in files for filename in files_range
} }
if "limit" in params:
info["_meta"] = dict(offset=offset, count=len(files_range), total=len(files))
return web.json_response(info) return web.json_response(info)
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()
@@ -261,11 +287,11 @@ if _server is not None:
error = has_invalid_folder_name(folder_name) error = has_invalid_folder_name(folder_name)
if error is not None: if error is not None:
return error return error
return inspect_models(folder_name) return inspect_models(folder_name, request.rel_url.query)
@_server.routes.get("/api/etn/model_info") @_server.routes.get("/api/etn/model_info")
async def api_model_info(request): async def api_model_info(request):
return inspect_models("checkpoints") return inspect_models("checkpoints", request.rel_url.query)
@_server.routes.get("/api/etn/languages") @_server.routes.get("/api/etn/languages")
async def languages(request): async def languages(request):
@@ -301,6 +327,23 @@ if _server is not None:
except Exception as e: except Exception as e:
return web.json_response(dict(error=str(e)), status=500) return web.json_response(dict(error=str(e)), status=500)
@_server.routes.put("/api/etn/image/{id}")
async def put_image(request: web.Request):
try:
id = request.match_info.get("id", "")
if id in image_cache:
return web.json_response(dict(status="cached"), status=200)
content_type = request.headers.get("Content-Type", "application/octet-stream")
data = bytearray()
async for chunk, _ in request.content.iter_chunks():
data.extend(chunk)
image_cache.insert(id, bytes(data), content_type)
return web.json_response(dict(status="success"), status=201)
except Exception as e:
return web.json_response(dict(error=str(e)), status=500)
@_server.routes.put("/api/etn/upload/{folder_name}/{filename}") @_server.routes.put("/api/etn/upload/{folder_name}/{filename}")
async def upload(request: web.Request): async def upload(request: web.Request):
folder_name = request.match_info.get("folder_name", "") folder_name = request.match_info.get("folder_name", "")
+3 -5
View File
@@ -32,7 +32,6 @@ function loadImage(base64) {
} }
const canvasIcon = loadImage("data:image/webp;base64,UklGRg4KAABXRUJQVlA4WAoAAAAQAAAAYwAAYwAAQUxQSNsDAAARoIRs/yI5+uAHU1kZiOu6u7u7xnObuK9NpBoKCoqi1jd6WonrsD7ROc2e3OJJr28T94aGhobYj+8w9v/X/7+n3UNETAD+b1IW1crLLrMhbYJbRs9Zv2VvuXbqVK28d8v6OaNvCdpIXkaT5InJgJgRABeNbirRYKlp9EUAJB8ZlUq2XAtI1wQIhn1RIUlV1Y5UVUmy0jwsACQPt7CtsloQSBcE6D6jSFKVRlVJFmd0B8QeClSSSn7/ACCdQjBjL0mlRSW5d0YA+4KESpJKnd8dHQsweBeptK7krsGAWIIgppKkkn+NAKSNoO9KUplLJVf2hliCIKWyrZIfXwYIgCdLzHXpSVgXvElt07bcKAAKSs2TUgvWIHiH2p6SX98pi+jgIrEFwUJqO6Ty1HY6qGwObEHwPrU9KqkOUNkS2AKwjNoeqXRSuV7EGtZQO3BVuQhiDV9Q3aIyhFiTDVS3qHwMYgtBK9Utcn932A/20nHlGoglwdOn1DEqn4XYQfA7PfB7AKuCRiqdVzZCbKD+EL14qB4WBTOoPlA2Qsyh7i9f/FUH44LBVHpRORhibr0/ms2hf5XerPaFYcFoqi+UDeaafNJkCsFeenRvnRnBLfSp3mBqNNUfygZTc/wyx9QGv6w3A2zxyxZDwV56da+h3mW/lA1dVPPLKUOXnfIL/8UurvnltKH+Vb9UDXU/4ZeyoaDkl5Ih/ET1h/InU81+aTYjmOOXOaYm06sTzAD3n/JJ7X5T/YtUXyiLfU3JGnp0jZjCS+oPfQnG7ylR/aAs3WmuexO92dTdHKZWfFGZCot3fkX1gbL1dhv1SZVerCb1NvB0K9U9ZevTsNo/OUEPnkj628Hgj33w8WBY7h9up7ql3B72tYUn51ToeHnOk7B+/tSV6pYum3q+PVwbtVDdUbZE1yKPzyY/UV1R/hg/i1zKhKRIdUNZTCZIPtB9Rvo71QXl78mM7sjrxVFSpJPFJLoY+b06jn904ccovhp5vjaOWkjNk5Ithfha5PuKqLCozFyXFxWiK5D3/o1xuiVPW9K4sT/yf35DFi47mpejy8Ks4Xw4+UAcxRvKeaisj6P4Abjaf3xWSDYcbaNmtM3RDUkhG98fDt8+IyvEK3fV2Fa1M6psW9u1Mi5kM26H28EDM7IofPPj7WUaLG//+M0wymY8EMD54M7JaRqF8cKPvy4eqtROq56uVQ4Vv/54YRxGaTr5zgB+vOjpl5IsicIwSrJ35sx5J0uiMIySLHnp6YvgUel/z6iXojTL0jRJ0jTL0uilUff0F/i3/qIb7nn06WHDnn70nhsuqhf8hxQAVlA4IAwGAACwHgCdASpkAGQAPm0wk0akIqGhLRGrUIANiWYA1BHh/t2rC93/Hf2Was/feJ2MrzB6VP6M9gD9Nunp5rP2y9Z70q+gV/Y/+B1kHoAeWv+1Xwa/t5+5PtQXQfhgKtwfp++wwU1Dx6fSXsD+VV7JPQ5/aRrblS2nsaggvO1Mch8UhK6pYtQxLM/VgrswZ0vLV8b6SwudSWaCFSHUiXEUQWX6krc9GtWanHMeaDd9wRYCfO5TwpYkgGAIkaLI4p6taB375EUfaVubYzKMfHSz2KpivsjWF0Vf+YJbACgi8j86d6EiJhQFF31NBJdS+QrGtJ2RUJRbahp1MXso6/J8AAD+/TKL/9q5tf/zOmF5Fe8B0Zn0yX3C0VLv0zxxv2+WH/dbz//rc2RS4TC1UzFVQiXVn5+Y0r+RsfJPsfPNuT02INz8gty7fI7fA/D1Wj2Jv+4RwdpyXs+cRxaT84bme5rMmPf+BH7NDUPKsj7GJ+w/6nBW2vsiPalWPfvBk6AQ3kCHmVecXkcnOgpoZ4ruAF/9Ze93DG5/8Y32x8b/CKPRt1jaXXy2LnoPvSNUT77gbB+/7vI1pfBfUHJsSwheIXY7QSixh7Ya8IliO3wqvI/uIFZAZd9pL8R1gRpYouBoyL5uIuGWQAZC5SKY0SruTf66stUOJVO9hlokeb5lWVzo7FO/Oeb/oj9iK4bqFhNZLCfqsBlH/OeefoP9sFdl7Mq1xmsevmzkfgwyiXg5hxMIP/Wa0JMPVl+XEFqTveAf1M8IBDu/pX/hCEnMn1n15Smyf72eDXKQqBrvp6BugyXXaJ05FDoz8MONUFh4rcjGL7AcijbcZ0SYwJkoeAKBW/I/sjKzTRtTP2E1fLB/8TWnzieHznDAKdlTuY2nSVTwCqFZNcFeFn7boziHOmYBLJin52d874mq1pHmJnulhT96LbKVW4vAT5PnY5F9TzmnDMwIFm4IAuEaA8X8XLE4Hp+AUEG4oswxRbVfOfxNJRyxFO3UB+v+ALgMP8kOf0uK3/3WOq4o/roivfzvW/fXviTC0mx+352hGaO+axx6vFa3eIkFsUEXCdo2LFHIlM8BtPuGUhgvM3oygIMAgvmUKILe0DFYVXhG/QoLi3sYfaoK/f0tX+fNnXhhxwEj1/Ct2Z64g0qWmkgwNkyy8m90EK1HsX0Q10CHVakDZePz5ts37u3GCANwGHQzWB+hNsevqjuU3qT95yGs0jjOtI/IjKsH9JbAmZkjGvNCPOC+FYUkOkwao9sOESY6zCgx9CM7g2LU4/CSHGoe2t0vWV/cMDH1HzI+Wa/yYp9CLDIh7J7iJd/2KnixeJvOhbUvbr9gubyyQU1iO5bnD9T536j++jKDVIk0Fwzk+d+j2eueHsIFJUvdyo2TyxP0kJbWr36R1s3giryqPvrsR5SkXx16+xqDrX4elhqh+1FwzNnSF5Lj5EUT/UC2rJvoAikbnvQ3NtJ9e83++idf3ja4FaLcUDxhoN5Rl5Ziz1LvF9iVeb6Su0QWYoRyBbyZ/pRbgYyhlAU/tonH7Wt+KhPDmXKIo0u4FDbAXM8avbFk4ax6e/dYITOCe+9dVEgcTOnBfhv0Yotd3EzNjZkLz4ksKGtFXcWIZRJ5YAyfzPYsyPex6/6ud9r2Ha9oxhVSIJV418e83qcPOIPlpe+LVGc69W6eC83l/zloqM9D6zQMkfqrjBZNpRkQS0sn8sxSu3s5qzhtH8cvjZk83gMqdfnHnl+1bvA7BI/g4+ePU7HUb9vK3Qw35bVmDcXa8xxWS2NQj8iWMH1cbHLXlboQsaCxIZoo+SeXR6ePUw3k6C/OxgqhjzExMJjLdBjoBeWYt3RPG2foTvx0T0Iz8ukdrCRJMG6HaR+6/f4nG/4xkr/fLhGlqOE/hBDBhuqnANj1CrujVDs2YayTvPuIcqCpNd3i8fOR8DfCq9ytS55F8akKneS6poHfB3bhjWbcIQXPvFzS7S5xLHEVoaixOwp0TL/8cQ8dxriyeddu5kCTyY7KepMQoeR+Pyn04nElkt9qqfYCTqHDtXBriC/UZh9AAAAAAAA=") const canvasIcon = loadImage("data:image/webp;base64,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")
const outputIcon = loadImage("data:image/webp;base64,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")
function setIconImage(nodeType, image, size, padRows, padCols) { function setIconImage(nodeType, image, size, padRows, padCols) {
const onAdded = nodeType.prototype.onAdded const onAdded = nodeType.prototype.onAdded
@@ -77,7 +76,8 @@ function defaultParameterType(widgetType, connectedNode, connectedWidget) {
if (connectedNode.comfyClass === "CLIPTextEncode") { if (connectedNode.comfyClass === "CLIPTextEncode") {
paramType = "prompt (positive)" paramType = "prompt (positive)"
} }
if (connectedWidget.options?.round === 1) { const round = connectedWidget.options?.round
if ((paramType == "number" && round === undefined) || round === 1) {
paramType = "number (integer)" paramType = "number (integer)"
} }
return paramType return paramType
@@ -96,7 +96,7 @@ function valueMatchesType(value, type, options) {
function optionalWidgetValue(widgets, index, fallback) { function optionalWidgetValue(widgets, index, fallback) {
const result = widgets.length > index ? widgets[index].value : null const result = widgets.length > index ? widgets[index].value : null
return result === null || result === 0 ? fallback : result return result === null || result === -1e10 || result === 1e10 ? fallback : result
} }
function changeWidgets(node, type, connectedNode, connectedWidget) { function changeWidgets(node, type, connectedNode, connectedWidget) {
@@ -206,8 +206,6 @@ app.registerExtension({
beforeRegisterNodeDef(nodeType /*typeof LGraphNode*/, nodeData /*ComfyObjectInfo*/, app) { beforeRegisterNodeDef(nodeType /*typeof LGraphNode*/, nodeData /*ComfyObjectInfo*/, app) {
if (nodeData.name === "ETN_KritaCanvas") { if (nodeData.name === "ETN_KritaCanvas") {
setIconImage(nodeType, canvasIcon, [200, 100], 0, 2) setIconImage(nodeType, canvasIcon, [200, 100], 0, 2)
} else if (nodeData.name === "ETN_KritaOutput") {
setIconImage(nodeType, outputIcon, [200, 100], 1, 0)
} else if (nodeData.name === "ETN_Parameter") { } else if (nodeData.name === "ETN_Parameter") {
setupParameterNode(nodeType) setupParameterNode(nodeType)
} else if (nodeData.name === "ETN_SendText") { } else if (nodeData.name === "ETN_SendText") {
+73 -8
View File
@@ -1,6 +1,7 @@
import sys import sys
import torch import torch
import numpy as np import numpy as np
from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Any, NamedTuple from typing import Any, NamedTuple
from PIL import Image from PIL import Image
@@ -73,6 +74,13 @@ class _BasicTypes(str):
BasicTypes = _BasicTypes("BASIC") BasicTypes = _BasicTypes("BASIC")
class OutputBatchMode(Enum):
default = "default"
images = "images"
animation = "animation"
layers = "layers"
class KritaOutput(io.ComfyNode): class KritaOutput(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
@@ -80,13 +88,41 @@ class KritaOutput(io.ComfyNode):
node_id="ETN_KritaOutput", node_id="ETN_KritaOutput",
display_name="Krita Output", display_name="Krita Output",
category="krita", category="krita",
inputs=[io.Image.Input("images")], inputs=[
io.Image.Input("images"),
io.Int.Input("x", "offset x", default=0),
io.Int.Input("y", "offset y", default=0),
io.String.Input("name", default=""),
io.Combo.Input(
"batch_mode", OutputBatchMode, "batch mode", default=OutputBatchMode.default
),
io.Boolean.Input("resize_canvas", "resize canvas", default=False),
],
is_output_node=True, is_output_node=True,
) )
@classmethod @classmethod
def execute(cls, images: torch.Tensor): def execute(
return SendImageWebSocket.execute(images, "PNG") cls,
images: torch.Tensor,
x: int = 0,
y: int = 0,
name="",
batch_mode: OutputBatchMode | str = OutputBatchMode.default,
resize_canvas=False,
):
batch_mode = batch_mode.value if isinstance(batch_mode, OutputBatchMode) else batch_mode
info = {
"name": name,
"offset_x": x,
"offset_y": y,
"batch_mode": batch_mode,
"resize_canvas": resize_canvas,
}
output = SendImageWebSocket.execute(images, "PNG")
assert isinstance(output.ui, dict)
output.ui["info"] = [info]
return output
class KritaSendText(io.ComfyNode): class KritaSendText(io.ComfyNode):
@@ -141,6 +177,21 @@ class KritaCanvas(io.ComfyNode):
return io.NodeOutput(_placeholder_image(), 512, 512, 0) return io.NodeOutput(_placeholder_image(), 512, 512, 0)
class SelectionContext(Enum):
automatic = "automatic"
entire_image = "entire image"
mask_bounds = "mask bounds"
_selection_context_help = """
Determines the section (crop bounding box) of the image and mask to transmit:
- automatic: area around the selection determined by Krita settings
- entire image: always use the entire canvas area
- mask bounds: tight bounding box of the current selection
This affects the Selection and Canvas nodes. The offset x/y outputs indicate the top-left corner of the context area relative to the full canvas."""
class KritaSelection(io.ComfyNode): class KritaSelection(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
@@ -148,12 +199,26 @@ class KritaSelection(io.ComfyNode):
node_id="ETN_KritaSelection", node_id="ETN_KritaSelection",
display_name="Krita Selection", display_name="Krita Selection",
category="krita", category="krita",
outputs=[io.Mask.Output(display_name="mask"), io.Boolean.Output(display_name="active")], inputs=[
io.Combo.Input(
"context",
options=SelectionContext,
default=SelectionContext.entire_image,
tooltip=_selection_context_help,
),
io.Int.Input("padding", "padding", default=0, min=0),
],
outputs=[
io.Mask.Output("mask", "mask"),
io.Boolean.Output("active", "active"),
io.Int.Output("x", "offset x"),
io.Int.Output("y", "offset y"),
],
) )
@classmethod @classmethod
def execute(cls): def execute(cls, **kwargs):
return io.NodeOutput(torch.ones(1, 512, 512), False) return io.NodeOutput(torch.ones(1, 512, 512), False, 0, 0)
class KritaImageLayer(io.ComfyNode): class KritaImageLayer(io.ComfyNode):
@@ -217,8 +282,8 @@ class Parameter(io.ComfyNode):
io.String.Input("name", default="Parameter"), io.String.Input("name", default="Parameter"),
io.Combo.Input("type", options=_param_types, default="auto"), io.Combo.Input("type", options=_param_types, default="auto"),
io.String.Input("default", default=""), io.String.Input("default", default=""),
io.Float.Input("min", default=0.0, min=-_fmax, max=_fmax, optional=True), io.Float.Input("min", default=-1e10, min=-_fmax, max=_fmax, optional=True),
io.Float.Input("max", default=1.0, min=-_fmax, max=_fmax, optional=True), io.Float.Input("max", default=1e10, min=-_fmax, max=_fmax, optional=True),
], ],
outputs=[io.AnyType.Output(display_name="value")], outputs=[io.AnyType.Output(display_name="value")],
) )
+84 -15
View File
@@ -107,6 +107,9 @@ class SendImageWebSocket(io.ComfyNode):
class ImageCache: class ImageCache:
timeout = 600 # 10 minutes
max_size = 100 * 1024 * 1024 # 100 MB
@dataclass @dataclass
class Entry: class Entry:
data: bytes data: bytes
@@ -114,8 +117,15 @@ class ImageCache:
timestamp: float timestamp: float
retrieved: int retrieved: int
class OldEntry(NamedTuple):
last_used: float
deleted: float
size: int
retrieved: int
def __init__(self): def __init__(self):
self.images: dict[str, ImageCache.Entry] = {} self.images: dict[str, ImageCache.Entry] = {}
self.old: dict[str, ImageCache.OldEntry] = {}
def add(self, image: Image.Image, format: str): def add(self, image: Image.Image, format: str):
key = uuid4().hex key = uuid4().hex
@@ -123,42 +133,101 @@ class ImageCache:
image.save(output, format=format, quality=95, compress_level=1) image.save(output, format=format, quality=95, compress_level=1)
image_data = output.getvalue() image_data = output.getvalue()
self.insert(key, image_data, f"image/{format.lower()}")
return key
def insert(self, key: str, data: bytes, content_type: str):
self.images[key] = ImageCache.Entry( self.images[key] = ImageCache.Entry(
data=image_data, data=data,
content_type=f"image/{format.lower()}", content_type=content_type,
timestamp=time.time(), timestamp=time.time(),
retrieved=0, retrieved=0,
) )
return key
def get(self, key: str): def get(self, key: str, extend: bool = False):
entry = self.images.get(key) entry = self.images.get(key)
if entry is None: if entry is None:
if old := self.old.get(key):
now = time.time()
print(
f"[comfyui-tooling-nodes] requested image {key} has been deleted ",
f"(last used {now - old.last_used:.0f}s ago, deleted {now - old.deleted:.0f}s ago, "
f"size {old.size / 1024**2:.1f}MB, retrieved {old.retrieved} times)",
)
return None, None return None, None
self.prune()
entry.retrieved += 1 entry.retrieved += 1
if extend:
entry.timestamp = time.time()
self.prune()
return entry.data, entry.content_type return entry.data, entry.content_type
def prune(self): def prune(self):
total_size = sum(len(entry.data) for entry in self.images.values())
if total_size <= self.max_size:
return
# Remove least recently used entries until under max size
sorted_entries = sorted(self.images.items(), key=lambda item: item[1].timestamp)
now = time.time() now = time.time()
keys_to_delete = [] for key, entry in sorted_entries:
for key, entry in self.images.items(): age = now - entry.timestamp
d = now - entry.timestamp if age > self.timeout or (age > 60 and entry.retrieved > 0):
if (d > 60 and entry.retrieved > 1) or d > 600: self.old[key] = ImageCache.OldEntry(
keys_to_delete.append(key) entry.timestamp, now, len(entry.data), entry.retrieved
for key in keys_to_delete: )
del self.images[key] del self.images[key]
total_size -= len(entry.data)
if total_size <= self.max_size:
break
def __contains__(self, key: str):
return key in self.images
image_cache = ImageCache() image_cache = ImageCache()
class SendImageHTTP(io.ComfyNode): class LoadImageCache(io.ComfyNode):
@classmethod @classmethod
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ETN_SendImageHTTP", node_id="ETN_LoadImageCache",
display_name="Send Image (HTTP)", display_name="Load Image from Cache",
category="external_tooling",
inputs=[io.String.Input("id", multiline=False)],
outputs=[io.Image.Output(display_name="image"), io.Mask.Output(display_name="mask")],
)
@classmethod
def execute(cls, id: str):
image_data, content_type = image_cache.get(id, extend=True)
if image_data is None:
raise ValueError(f"Image with ID {id} not found in cache.")
img = Image.open(BytesIO(image_data))
w, h = img.size
c = len(img.getbands())
normalized = np.array(img).astype(np.float32) / 255.0
tensor = torch.from_numpy(normalized).reshape(1, h, w, c)
match c:
case 1:
image = tensor.expand(1, h, w, 3)
mask = tensor.reshape(1, h, w)
case 3:
image = tensor
mask = tensor[..., 0]
case 4:
image = tensor[..., :3]
mask = tensor[..., 3]
return io.NodeOutput(image, mask)
class SaveImageCache(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ETN_SaveImageCache",
display_name="Save Image to Cache",
category="external_tooling", category="external_tooling",
inputs=[ inputs=[
io.Image.Input("images"), io.Image.Input("images"),
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-tooling-nodes" name = "comfyui-tooling-nodes"
description = "Provides nodes and server API extensions geared towards using ComfyUI as a backend for external tools." description = "Provides nodes and server API extensions geared towards using ComfyUI as a backend for external tools."
version = "3.0.0" version = "3.1.0"
license = { file = "LICENSE" } license = { file = "LICENSE" }
[project.urls] [project.urls]