Initial Z-Image-Turbo region builder
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
outputs/
|
||||
input/
|
||||
temp/
|
||||
@@ -0,0 +1,47 @@
|
||||
# ZIT-Ideogram
|
||||
|
||||
ComfyUI custom node that reuses the KJNodes Ideogram 4 visual box editor pattern for Z-Image-Turbo regional prompting.
|
||||
|
||||
## Node
|
||||
|
||||
`Z-Image-Turbo Region Builder KJ`
|
||||
|
||||
Outputs:
|
||||
|
||||
- `positive` / `negative`: Z-Image text conditioning with per-region mask metadata.
|
||||
- `region_masks`: one mask per drawn box, shape `N,H,W`.
|
||||
- `combined_mask`: max-composited mask for img2img/inpaint noise masks.
|
||||
- `source_image`: pass-through source image, or a blank image for text-to-image.
|
||||
- `preview`: editor preview with regions and mask tint.
|
||||
- `regions_json`, `bboxes`, `width`, `height`.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Put this folder in `ComfyUI/custom_nodes/ZIT-Ideogram`.
|
||||
2. Restart ComfyUI.
|
||||
3. Add node: `ZIT-Ideogram/Z-Image > Z-Image-Turbo Region Builder KJ`.
|
||||
|
||||
No extra Python packages are required.
|
||||
|
||||
## Usage
|
||||
|
||||
Use ComfyUI's built-in Z-Image-Turbo workflow as the base graph, then replace the normal text encoders with this node:
|
||||
|
||||
1. Connect Z-Image/Qwen `CLIP` to `clip`.
|
||||
2. Connect `positive` and `negative` to the sampler.
|
||||
3. For text-to-image, connect `width` and `height` to your latent/image size nodes.
|
||||
4. For image-to-image regional editing, connect a source image to `image`, use `combined_mask` as the inpaint/noise mask, and sample at a denoise/strength suitable for the edit.
|
||||
5. Draw boxes in the editor and set each region prompt, optional region negative prompt, strength, and feather.
|
||||
|
||||
The node does not call any Ideogram API and does not emit Ideogram caption JSON. It produces native ComfyUI conditioning and masks.
|
||||
|
||||
## Examples
|
||||
|
||||
Example region presets are in `examples/`:
|
||||
|
||||
- `text_to_image_regional_prompting.json`
|
||||
- `portrait_hair_editing.json`
|
||||
- `clothing_replacement.json`
|
||||
- `background_only_modification.json`
|
||||
|
||||
Open a Z-Image-Turbo workflow first, add this node, then use the node's `Paste` button to import the example region JSON.
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
from .nodes.zimage_turbo_region_builder import ZImageTurboRegionBuilderKJ
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ZImageTurboRegionBuilderKJ": ZImageTurboRegionBuilderKJ,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ZImageTurboRegionBuilderKJ": "Z-Image-Turbo Region Builder KJ",
|
||||
}
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"global_prompt": "preserve the foreground subject exactly, keep pose, face, clothes, scale, and lighting consistent",
|
||||
"global_negative_prompt": "changed subject, altered body, changed clothes, extra people, subject blur",
|
||||
"regions": [
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": 0.0,
|
||||
"w": 1.0,
|
||||
"h": 0.28,
|
||||
"prompt": "soft golden sunset sky with subtle clouds",
|
||||
"negative_prompt": "harsh shadows, storm, text",
|
||||
"strength": 0.75,
|
||||
"feather": 40
|
||||
},
|
||||
{
|
||||
"x": 0.0,
|
||||
"y": 0.28,
|
||||
"w": 0.22,
|
||||
"h": 0.72,
|
||||
"prompt": "blurred park background with trees and warm bokeh",
|
||||
"negative_prompt": "foreground person, extra limbs",
|
||||
"strength": 0.75,
|
||||
"feather": 36
|
||||
},
|
||||
{
|
||||
"x": 0.78,
|
||||
"y": 0.28,
|
||||
"w": 0.22,
|
||||
"h": 0.72,
|
||||
"prompt": "blurred park background with trees and warm bokeh",
|
||||
"negative_prompt": "foreground person, extra limbs",
|
||||
"strength": 0.75,
|
||||
"feather": 36
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"global_prompt": "preserve the person, pose, face, hands, lighting, camera angle, and background",
|
||||
"global_negative_prompt": "changed identity, changed pose, damaged hands, distorted face, low quality",
|
||||
"regions": [
|
||||
{
|
||||
"x": 0.27,
|
||||
"y": 0.38,
|
||||
"w": 0.46,
|
||||
"h": 0.43,
|
||||
"prompt": "tailored dark navy blazer over a clean white shirt, realistic fabric folds",
|
||||
"negative_prompt": "old outfit, logos, torn fabric, extra buttons",
|
||||
"strength": 0.9,
|
||||
"feather": 28
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"global_prompt": "preserve the original portrait, face, expression, body, clothes, background, lighting, and composition",
|
||||
"global_negative_prompt": "changed face, changed clothing, changed background, extra people, low quality",
|
||||
"regions": [
|
||||
{
|
||||
"x": 0.33,
|
||||
"y": 0.08,
|
||||
"w": 0.34,
|
||||
"h": 0.38,
|
||||
"prompt": "long red curly hair, natural strands, realistic volume",
|
||||
"negative_prompt": "hat, wig edge, changed face, dyed skin",
|
||||
"strength": 0.85,
|
||||
"feather": 32
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"global_prompt": "wide cinematic illustration, clean composition, natural lighting",
|
||||
"global_negative_prompt": "low quality, blurry, distorted anatomy, text artifacts",
|
||||
"regions": [
|
||||
{
|
||||
"x": 0.04,
|
||||
"y": 0.12,
|
||||
"w": 0.28,
|
||||
"h": 0.72,
|
||||
"prompt": "left side: tall glass greenhouse filled with tropical plants",
|
||||
"negative_prompt": "empty room, plain wall",
|
||||
"strength": 1.0,
|
||||
"feather": 24
|
||||
},
|
||||
{
|
||||
"x": 0.36,
|
||||
"y": 0.18,
|
||||
"w": 0.28,
|
||||
"h": 0.62,
|
||||
"prompt": "center: wooden table with ceramic tea set and warm steam",
|
||||
"negative_prompt": "plastic, clutter",
|
||||
"strength": 1.0,
|
||||
"feather": 18
|
||||
},
|
||||
{
|
||||
"x": 0.68,
|
||||
"y": 0.1,
|
||||
"w": 0.28,
|
||||
"h": 0.76,
|
||||
"prompt": "right side: arched window overlooking a rainy city skyline",
|
||||
"negative_prompt": "blank window, overexposed",
|
||||
"strength": 1.0,
|
||||
"feather": 24
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,302 @@
|
||||
"""Z-Image-Turbo regional prompt builder.
|
||||
|
||||
This node is based on KJNodes' Ideogram 4 visual region editor, but emits native
|
||||
ComfyUI/Z-Image conditioning and masks instead of Ideogram caption JSON.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import node_helpers
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
|
||||
|
||||
from comfy_api.latest import io
|
||||
|
||||
|
||||
_FONT_PATH = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fonts", "FreeMono.ttf")
|
||||
|
||||
|
||||
def _font(size):
|
||||
try:
|
||||
return ImageFont.truetype(_FONT_PATH, size)
|
||||
except Exception:
|
||||
try:
|
||||
return ImageFont.load_default(size)
|
||||
except Exception:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _parse_json_list(s):
|
||||
if s:
|
||||
try:
|
||||
v = json.loads(s)
|
||||
if isinstance(v, list):
|
||||
return v
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return []
|
||||
|
||||
|
||||
def _dumps(v):
|
||||
return json.dumps(v, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _norm_box(box):
|
||||
x = float(box.get("x", 0.0))
|
||||
y = float(box.get("y", 0.0))
|
||||
w = float(box.get("w", 0.0))
|
||||
h = float(box.get("h", 0.0))
|
||||
if w < 0:
|
||||
x += w
|
||||
w = -w
|
||||
if h < 0:
|
||||
y += h
|
||||
h = -h
|
||||
x = max(0.0, min(1.0, x))
|
||||
y = max(0.0, min(1.0, y))
|
||||
w = max(0.0, min(1.0 - x, w))
|
||||
h = max(0.0, min(1.0 - y, h))
|
||||
return x, y, w, h
|
||||
|
||||
|
||||
def _encode_zimage(clip, prompt):
|
||||
tokens = clip.tokenize(prompt or "")
|
||||
return clip.encode_from_tokens_scheduled(tokens)
|
||||
|
||||
|
||||
def _set_mask(conditioning, mask, strength, set_area_to_bounds=True):
|
||||
return node_helpers.conditioning_set_values(
|
||||
conditioning,
|
||||
{
|
||||
"mask": mask,
|
||||
"set_area_to_bounds": bool(set_area_to_bounds),
|
||||
"mask_strength": float(strength),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _box_mask(width, height, box, default_feather, default_strength):
|
||||
x, y, w, h = _norm_box(box)
|
||||
x1 = max(0, min(width, round(x * width)))
|
||||
y1 = max(0, min(height, round(y * height)))
|
||||
x2 = max(0, min(width, round((x + w) * width)))
|
||||
y2 = max(0, min(height, round((y + h) * height)))
|
||||
|
||||
mask = Image.new("L", (width, height), 0)
|
||||
if x2 > x1 and y2 > y1:
|
||||
ImageDraw.Draw(mask).rectangle([x1, y1, x2, y2], fill=255)
|
||||
|
||||
feather = float(box.get("feather", default_feather) or 0.0)
|
||||
if feather > 0:
|
||||
mask = mask.filter(ImageFilter.GaussianBlur(radius=feather))
|
||||
|
||||
strength = float(box.get("strength", default_strength) or 0.0)
|
||||
arr = np.asarray(mask, dtype=np.float32) / 255.0
|
||||
arr *= max(0.0, min(10.0, strength))
|
||||
return torch.from_numpy(arr).clamp(0.0, 1.0), {
|
||||
"x": x1,
|
||||
"y": y1,
|
||||
"width": max(0, x2 - x1),
|
||||
"height": max(0, y2 - y1),
|
||||
}
|
||||
|
||||
|
||||
def _wrap(draw, text, font, max_w):
|
||||
lines = []
|
||||
for para in (text or "").split("\n"):
|
||||
line = ""
|
||||
for word in para.split():
|
||||
test = word if not line else line + " " + word
|
||||
if line and draw.textlength(test, font=font) > max_w:
|
||||
lines.append(line)
|
||||
line = word
|
||||
else:
|
||||
line = test
|
||||
if line:
|
||||
lines.append(line)
|
||||
return lines
|
||||
|
||||
|
||||
def _render_preview(boxes, width, height, masks, bg=None, brightness=50):
|
||||
if bg is not None:
|
||||
iw, ih = bg.size
|
||||
long_edge = max(iw, ih)
|
||||
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
|
||||
rw, rh = max(1, round(iw * scale)), max(1, round(ih * scale))
|
||||
base = bg.convert("RGB").resize((rw, rh), Image.LANCZOS)
|
||||
base = ImageEnhance.Brightness(base).enhance(max(0.0, min(1.0, brightness / 100.0)))
|
||||
img = base.convert("RGBA")
|
||||
else:
|
||||
long_edge = max(width, height)
|
||||
scale = min(1.0, 1024 / long_edge) if long_edge > 0 else 1.0
|
||||
rw, rh = max(1, round(width * scale)), max(1, round(height * scale))
|
||||
g = round(max(0, min(100, brightness)) / 100 * 128)
|
||||
img = Image.new("RGBA", (rw, rh), (g, g, g, 255))
|
||||
|
||||
overlay = Image.new("RGBA", (rw, rh), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
font = _font(max(10, round(rh / 64)))
|
||||
tag_font = _font(max(9, round(rh / 72)))
|
||||
colors = [(70, 180, 230), (240, 170, 60), (120, 205, 120), (220, 100, 120), (180, 140, 255)]
|
||||
|
||||
for i, box in enumerate(boxes):
|
||||
x, y, w, h = _norm_box(box)
|
||||
x1, y1 = round(x * rw), round(y * rh)
|
||||
x2, y2 = round((x + w) * rw), round((y + h) * rh)
|
||||
color = colors[i % len(colors)]
|
||||
draw.rectangle([x1, y1, x2, y2], outline=color + (255,), width=2)
|
||||
draw.rectangle([x1, y1, x1 + 26, y1 + 16], fill=color + (230,))
|
||||
draw.text((x1 + 4, y1 + 2), str(i + 1).zfill(2), fill=(0, 0, 0, 255), font=tag_font)
|
||||
text = box.get("prompt") or box.get("desc") or ""
|
||||
ty = y1 + 21
|
||||
for line in _wrap(draw, text, font, max(12, x2 - x1 - 8)):
|
||||
if ty > y2:
|
||||
break
|
||||
draw.text((x1 + 4, ty), line, fill=color + (255,), font=font)
|
||||
ty += font.size + 2 if hasattr(font, "size") else 13
|
||||
|
||||
if masks:
|
||||
combined = torch.stack(masks).max(dim=0).values
|
||||
m = Image.fromarray((combined.detach().cpu().numpy() * 120).clip(0, 120).astype(np.uint8), "L")
|
||||
m = m.resize((rw, rh), Image.BILINEAR)
|
||||
tint = Image.new("RGBA", (rw, rh), (70, 180, 230, 0))
|
||||
tint.putalpha(m)
|
||||
overlay = Image.alpha_composite(tint, overlay)
|
||||
|
||||
img = Image.alpha_composite(img, overlay).convert("RGB")
|
||||
arr = np.asarray(img, dtype=np.float32) / 255.0
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
|
||||
class ZImageTurboRegionBuilderKJ(io.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return io.Schema(
|
||||
node_id="ZImageTurboRegionBuilderKJ",
|
||||
display_name="Z-Image-Turbo Region Builder KJ",
|
||||
category="ZIT-Ideogram/Z-Image",
|
||||
search_aliases=["z-image", "zimage", "turbo", "regional prompt", "mask", "inpaint"],
|
||||
is_experimental=True,
|
||||
description="""
|
||||
Visual region prompt builder for Z-Image-Turbo.
|
||||
|
||||
Draw boxes on the canvas, set a prompt, optional negative prompt, strength, and
|
||||
feather per region. The node outputs Z-Image text conditionings with ComfyUI
|
||||
masks, a batch of independent masks, a combined mask for img2img/inpainting, and
|
||||
serialized region data for workflow save/load.
|
||||
""",
|
||||
inputs=[
|
||||
io.Clip.Input("clip"),
|
||||
io.Int.Input("width", default=1024, min=64, max=16384, step=16),
|
||||
io.Int.Input("height", default=1024, min=64, max=16384, step=16),
|
||||
io.String.Input("global_prompt", multiline=True, default="", dynamic_prompts=True),
|
||||
io.String.Input("global_negative_prompt", multiline=True, default="", dynamic_prompts=True),
|
||||
io.Combo.Input("mode", options=["text_to_image", "image_to_image_region_edit"], default="text_to_image"),
|
||||
io.Float.Input("default_region_strength", default=1.0, min=0.0, max=10.0, step=0.01),
|
||||
io.Float.Input("default_feather", default=16.0, min=0.0, max=512.0, step=1.0),
|
||||
io.Image.Input("image", optional=True, tooltip="Optional source/reference image shown in the editor and passed through."),
|
||||
io.String.Input("regions_data", default="", socketless=True, advanced=True),
|
||||
io.Int.Input("bg_brightness", default=35, min=0, max=100, socketless=True, advanced=True),
|
||||
],
|
||||
outputs=[
|
||||
io.Conditioning.Output(display_name="positive"),
|
||||
io.Conditioning.Output(display_name="negative"),
|
||||
io.Mask.Output(display_name="region_masks"),
|
||||
io.Mask.Output(display_name="combined_mask"),
|
||||
io.Image.Output(display_name="source_image"),
|
||||
io.Image.Output(display_name="preview"),
|
||||
io.String.Output(display_name="regions_json"),
|
||||
io.BoundingBox.Output(display_name="bboxes"),
|
||||
io.Int.Output(display_name="width"),
|
||||
io.Int.Output(display_name="height"),
|
||||
],
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def execute(
|
||||
cls,
|
||||
clip,
|
||||
width,
|
||||
height,
|
||||
global_prompt,
|
||||
global_negative_prompt,
|
||||
mode,
|
||||
default_region_strength,
|
||||
default_feather,
|
||||
regions_data="",
|
||||
image=None,
|
||||
bg_brightness=35,
|
||||
) -> io.NodeOutput:
|
||||
boxes = [b for b in _parse_json_list(regions_data) if isinstance(b, dict)]
|
||||
masks = []
|
||||
bbox_dicts = []
|
||||
region_records = []
|
||||
|
||||
positive = _encode_zimage(clip, global_prompt)
|
||||
negative = _encode_zimage(clip, global_negative_prompt)
|
||||
|
||||
for box in boxes:
|
||||
mask, bbox = _box_mask(width, height, box, default_feather, default_region_strength)
|
||||
if bbox["width"] <= 0 or bbox["height"] <= 0:
|
||||
continue
|
||||
masks.append(mask)
|
||||
bbox_dicts.append(bbox)
|
||||
strength = float(box.get("strength", default_region_strength) or default_region_strength)
|
||||
prompt = (box.get("prompt") or box.get("desc") or "").strip()
|
||||
neg_prompt = (box.get("negative_prompt") or "").strip()
|
||||
if prompt:
|
||||
positive += _set_mask(_encode_zimage(clip, prompt), mask.unsqueeze(0), strength)
|
||||
if neg_prompt:
|
||||
negative += _set_mask(_encode_zimage(clip, neg_prompt), mask.unsqueeze(0), strength)
|
||||
region_records.append(
|
||||
{
|
||||
"bbox": bbox,
|
||||
"prompt": prompt,
|
||||
"negative_prompt": neg_prompt,
|
||||
"strength": strength,
|
||||
"feather": float(box.get("feather", default_feather) or 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
if masks:
|
||||
region_masks = torch.stack(masks, dim=0)
|
||||
combined_mask = region_masks.max(dim=0).values.unsqueeze(0)
|
||||
else:
|
||||
region_masks = torch.zeros((1, height, width), dtype=torch.float32)
|
||||
combined_mask = torch.zeros((1, height, width), dtype=torch.float32)
|
||||
|
||||
source_image = image
|
||||
if source_image is None:
|
||||
source_image = torch.zeros((1, height, width, 3), dtype=torch.float32)
|
||||
|
||||
bg = None
|
||||
if image is not None:
|
||||
try:
|
||||
bg = Image.fromarray((image[0].detach().cpu().numpy() * 255).clip(0, 255).astype(np.uint8))
|
||||
except Exception:
|
||||
bg = None
|
||||
preview = _render_preview(boxes, width, height, masks, bg, bg_brightness)
|
||||
bboxes_out = [bbox_dicts] if bbox_dicts else []
|
||||
|
||||
region_json = {
|
||||
"mode": mode,
|
||||
"global_prompt": global_prompt,
|
||||
"global_negative_prompt": global_negative_prompt,
|
||||
"regions": region_records,
|
||||
}
|
||||
|
||||
return io.NodeOutput(
|
||||
positive,
|
||||
negative,
|
||||
region_masks,
|
||||
combined_mask,
|
||||
source_image,
|
||||
preview,
|
||||
_dumps(region_json),
|
||||
bboxes_out,
|
||||
width,
|
||||
height,
|
||||
ui={"dims": [width, height]},
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
const { app } = window.comfyAPI.app;
|
||||
|
||||
// ─── General-purpose helpers shared across KJNodes JS ───
|
||||
|
||||
export function makeUUID() {
|
||||
let dt = new Date().getTime()
|
||||
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = ((dt + Math.random() * 16) % 16) | 0
|
||||
dt = Math.floor(dt / 16)
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
return uuid
|
||||
}
|
||||
|
||||
export function chainCallback(object, property, callback) {
|
||||
if (object == undefined) {
|
||||
console.error("Tried to add callback to non-existant object")
|
||||
return;
|
||||
}
|
||||
if (property in object) {
|
||||
const callback_orig = object[property]
|
||||
object[property] = function () {
|
||||
const r = callback_orig.apply(this, arguments);
|
||||
callback.apply(this, arguments);
|
||||
return r
|
||||
};
|
||||
} else {
|
||||
object[property] = callback;
|
||||
}
|
||||
}
|
||||
|
||||
export function hideWidgetForGood(node, widget, suffix = '') {
|
||||
widget.origType = widget.type
|
||||
widget.origComputeSize = widget.computeSize
|
||||
widget.origSerializeValue = widget.serializeValue
|
||||
widget.computeSize = () => [0, -4]
|
||||
widget.type = "converted-widget" + suffix
|
||||
if (widget.linkedWidgets) {
|
||||
for (const w of widget.linkedWidgets) {
|
||||
hideWidgetForGood(node, w, ':' + widget.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Graph coordinate helpers ───
|
||||
|
||||
export function clientToCanvas(lgCanvas, clientX, clientY) {
|
||||
const rect = lgCanvas.canvas.getBoundingClientRect();
|
||||
return [
|
||||
(clientX - rect.left) / lgCanvas.ds.scale - lgCanvas.ds.offset[0],
|
||||
(clientY - rect.top) / lgCanvas.ds.scale - lgCanvas.ds.offset[1],
|
||||
];
|
||||
}
|
||||
|
||||
export function getNodeAtPoint(graph, cx, cy) {
|
||||
for (let i = graph._nodes.length - 1; i >= 0; i--) {
|
||||
if (graph._nodes[i].isPointInside(cx, cy)) return graph._nodes[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function typesCompatible(a, b) {
|
||||
if (a === "*" || b === "*") return true;
|
||||
if (a === b) return true;
|
||||
if (typeof a !== "string" || typeof b !== "string") return false;
|
||||
if (a.toUpperCase() === b.toUpperCase()) return true;
|
||||
// ComfyUI accepts union types like "STRING,INT" — match if any token overlaps
|
||||
if (a.includes(",") || b.includes(",")) {
|
||||
const aTokens = a.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
|
||||
const bTokens = b.toUpperCase().split(",").map(s => s.trim()).filter(Boolean);
|
||||
if (aTokens.includes("*") || bTokens.includes("*")) return true;
|
||||
return aTokens.some(t => bTokens.includes(t));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Slot position helper ───
|
||||
// Uses modern API (DOM-registered positions in Vue mode) with legacy fallback
|
||||
export function getSlotPos(node, isInput, slotIdx) {
|
||||
if (isInput && node.getInputPos) return node.getInputPos(slotIdx);
|
||||
if (!isInput && node.getOutputPos) return node.getOutputPos(slotIdx);
|
||||
const out = [0, 0];
|
||||
node.getConnectionPos(isInput, slotIdx, out);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Middle-click pan passthrough for DOM widgets ───
|
||||
// Allows panning the LiteGraph canvas via middle-click drag on any DOM element
|
||||
export function addMiddleClickPan(element) {
|
||||
const onMouseDown = (e) => {
|
||||
if (e.button !== 1) return;
|
||||
e.preventDefault();
|
||||
const ds = app.canvas?.ds;
|
||||
if (!ds) return;
|
||||
const startX = e.clientX, startY = e.clientY;
|
||||
const startOffsetX = ds.offset[0], startOffsetY = ds.offset[1];
|
||||
const onMove = (me) => {
|
||||
ds.offset[0] = startOffsetX + (me.clientX - startX);
|
||||
ds.offset[1] = startOffsetY + (me.clientY - startY);
|
||||
app.canvas.setDirty(true, true);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
element.addEventListener('mousedown', onMouseDown);
|
||||
return () => element.removeEventListener('mousedown', onMouseDown);
|
||||
}
|
||||
|
||||
// ─── Resolve preview image/video from a connected source node ───
|
||||
// Walks the graph link to find what's connected to node.inputs[inputSlot]
|
||||
// and returns { url, isVideo, videoEl } or null
|
||||
export function resolveSourcePreview(node, inputSlot) {
|
||||
if (!node.graph) return null;
|
||||
const input = node.inputs?.[inputSlot];
|
||||
if (!input || input.link == null) return null;
|
||||
const link = node.graph.links?.get(input.link);
|
||||
if (!link) return null;
|
||||
const srcNode = node.graph.getNodeById(link.origin_id);
|
||||
if (!srcNode) return null;
|
||||
|
||||
// Check for VHS-style video preview widget
|
||||
const vpWidget = srcNode.widgets?.find((w) => w.name === "videopreview");
|
||||
if (vpWidget?.videoEl?.src) {
|
||||
return { isVideo: true, videoEl: vpWidget.videoEl };
|
||||
}
|
||||
|
||||
// Look for a LoadImage "image" widget or LoadVideo "video" widget
|
||||
const w = srcNode.widgets?.find((w) => w.name === "image" || w.name === "video");
|
||||
if (w?.value) {
|
||||
let subfolder = "", fname = w.value;
|
||||
const lastSlash = fname.lastIndexOf("/");
|
||||
if (lastSlash >= 0) { subfolder = fname.substring(0, lastSlash); fname = fname.substring(lastSlash + 1); }
|
||||
const isVideo = w.name === "video";
|
||||
const url = `/view?filename=${encodeURIComponent(fname)}&type=input&subfolder=${encodeURIComponent(subfolder)}`;
|
||||
return { url, isVideo };
|
||||
}
|
||||
|
||||
// Fallback: check node.imgs (set after execution)
|
||||
if (srcNode.imgs?.length > 0 && srcNode.imgs[0].src) {
|
||||
return { url: srcNode.imgs[0].src, isVideo: false };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Source input watcher ───
|
||||
// Watches a named IMAGE input for connection changes and source widget changes.
|
||||
// Calls onChange(sources) when connections change or the source node's image/video widget changes.
|
||||
// sources: array of { url, isVideo, videoEl } per connected IMAGE input matching inputName (or all if inputName is null).
|
||||
// Returns a cleanup function.
|
||||
export function watchImageInputs(node, inputName, onChange) {
|
||||
let watchedWidgets = [];
|
||||
|
||||
function unwatchWidgets() {
|
||||
for (const { widget, origCb } of watchedWidgets) widget.callback = origCb;
|
||||
watchedWidgets = [];
|
||||
}
|
||||
|
||||
function resolve() {
|
||||
if (!node.inputs) return [];
|
||||
const slots = inputName
|
||||
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
|
||||
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
|
||||
return slots.map(i => resolveSourcePreview(node, i)).filter(s => s !== null);
|
||||
}
|
||||
|
||||
function watch() {
|
||||
unwatchWidgets();
|
||||
if (!node.inputs || !node.graph) return;
|
||||
const slots = inputName
|
||||
? node.inputs.map((inp, i) => inp.name === inputName ? i : -1).filter(i => i >= 0)
|
||||
: node.inputs.map((inp, i) => inp.type === "IMAGE" ? i : -1).filter(i => i >= 0);
|
||||
for (const slotIdx of slots) {
|
||||
const input = node.inputs[slotIdx];
|
||||
if (!input || input.link == null) continue;
|
||||
const link = node.graph.links?.get(input.link);
|
||||
if (!link) continue;
|
||||
const srcNode = node.graph.getNodeById(link.origin_id);
|
||||
if (!srcNode) continue;
|
||||
const w = srcNode.widgets?.find(w => w.name === "image" || w.name === "video");
|
||||
if (!w) continue;
|
||||
const origCb = w.callback;
|
||||
w.callback = function (...args) {
|
||||
if (origCb) origCb.apply(this, args);
|
||||
setTimeout(() => onChange(resolve()), 100);
|
||||
};
|
||||
watchedWidgets.push({ widget: w, origCb });
|
||||
}
|
||||
}
|
||||
|
||||
chainCallback(node, "onConnectionsChange", function (type) {
|
||||
// Only react to input connection changes, not output
|
||||
if (type != null && type !== 1) return;
|
||||
setTimeout(() => {
|
||||
watch();
|
||||
onChange(resolve());
|
||||
}, 100);
|
||||
});
|
||||
|
||||
chainCallback(node, "onRemoved", unwatchWidgets);
|
||||
|
||||
return unwatchWidgets;
|
||||
}
|
||||
|
||||
// ─── Video frame capture ───
|
||||
// Draws the current frame of a video element to a fresh canvas at native resolution.
|
||||
// If the video isn't decoded yet (readyState < 2), waits once for "loadeddata" before capturing.
|
||||
// Calls callback(canvas) with the resulting canvas. Caller decides whether to use it directly
|
||||
// (drawImage accepts canvas) or convert via toDataURL.
|
||||
export function captureVideoFrame(videoEl, callback) {
|
||||
const capture = () => {
|
||||
if (!videoEl.videoWidth || !videoEl.videoHeight) return;
|
||||
const c = document.createElement("canvas");
|
||||
c.width = videoEl.videoWidth;
|
||||
c.height = videoEl.videoHeight;
|
||||
c.getContext("2d").drawImage(videoEl, 0, 0);
|
||||
callback(c);
|
||||
};
|
||||
if (videoEl.readyState >= 2) {
|
||||
capture();
|
||||
} else {
|
||||
const onReady = () => { videoEl.removeEventListener("loadeddata", onReady); capture(); };
|
||||
videoEl.addEventListener("loadeddata", onReady);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bounding box hit test ───
|
||||
// Tests whether (mx, my) hits a corner handle or the interior of a rect defined by (x1, y1)–(x2, y2).
|
||||
// Returns "resize-tl", "resize-tr", "resize-bl", "resize-br", "resize-t", "resize-b", "resize-l", "resize-r", "move", or null.
|
||||
export function rectHitTest(mx, my, x1, y1, x2, y2, radius) {
|
||||
const hit = (cx, cy) => Math.abs(mx - cx) < radius && Math.abs(my - cy) < radius;
|
||||
// Corners first (higher priority)
|
||||
if (hit(x1, y1)) return "resize-tl";
|
||||
if (hit(x2, y1)) return "resize-tr";
|
||||
if (hit(x1, y2)) return "resize-bl";
|
||||
if (hit(x2, y2)) return "resize-br";
|
||||
// Edges
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < radius) return "resize-t";
|
||||
if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < radius) return "resize-b";
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x1) < radius) return "resize-l";
|
||||
if (my >= y1 && my <= y2 && Math.abs(mx - x2) < radius) return "resize-r";
|
||||
// Interior
|
||||
if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Returns the appropriate CSS cursor for a bbox hit mode string.
|
||||
export function cursorForBboxMode(mode) {
|
||||
if (mode === "move") return "move";
|
||||
if (mode === "resize-tl" || mode === "resize-br") return "nwse-resize";
|
||||
if (mode === "resize-tr" || mode === "resize-bl") return "nesw-resize";
|
||||
if (mode === "resize-t" || mode === "resize-b") return "ns-resize";
|
||||
if (mode === "resize-l" || mode === "resize-r") return "ew-resize";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Wheel zoom passthrough for DOM widgets ───
|
||||
// Re-dispatches wheel events to the LiteGraph canvas for zoom
|
||||
export function addWheelPassthrough(element) {
|
||||
element.addEventListener("wheel", (e) => {
|
||||
const gc = document.getElementById("graph-canvas");
|
||||
if (gc) {
|
||||
gc.dispatchEvent(new WheelEvent(e.type, e));
|
||||
e.preventDefault();
|
||||
}
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user