commit 226fabacb0f85fdd38b761cf66a4fec2b132cc0c Author: bbc-s Date: Wed Jun 10 15:34:20 2026 +0200 Initial Z-Image-Turbo region builder diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f5f1d66 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +outputs/ +input/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cd4bbc --- /dev/null +++ b/README.md @@ -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. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a590bd6 --- /dev/null +++ b/__init__.py @@ -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"] diff --git a/examples/background_only_modification.json b/examples/background_only_modification.json new file mode 100644 index 0000000..a55c12b --- /dev/null +++ b/examples/background_only_modification.json @@ -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 + } + ] +} diff --git a/examples/clothing_replacement.json b/examples/clothing_replacement.json new file mode 100644 index 0000000..ea6e588 --- /dev/null +++ b/examples/clothing_replacement.json @@ -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 + } + ] +} diff --git a/examples/portrait_hair_editing.json b/examples/portrait_hair_editing.json new file mode 100644 index 0000000..6af5e80 --- /dev/null +++ b/examples/portrait_hair_editing.json @@ -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 + } + ] +} diff --git a/examples/text_to_image_regional_prompting.json b/examples/text_to_image_regional_prompting.json new file mode 100644 index 0000000..41affba --- /dev/null +++ b/examples/text_to_image_regional_prompting.json @@ -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 + } + ] +} diff --git a/fonts/FreeMono.ttf b/fonts/FreeMono.ttf new file mode 100644 index 0000000..7485f9e Binary files /dev/null and b/fonts/FreeMono.ttf differ diff --git a/nodes/zimage_turbo_region_builder.py b/nodes/zimage_turbo_region_builder.py new file mode 100644 index 0000000..2bb1470 --- /dev/null +++ b/nodes/zimage_turbo_region_builder.py @@ -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]}, + ) diff --git a/web/js/utility.js b/web/js/utility.js new file mode 100644 index 0000000..171443a --- /dev/null +++ b/web/js/utility.js @@ -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 }); +} + diff --git a/web/js/zimage_turbo_region_builder.js b/web/js/zimage_turbo_region_builder.js new file mode 100644 index 0000000..5fcb5b5 --- /dev/null +++ b/web/js/zimage_turbo_region_builder.js @@ -0,0 +1,1250 @@ +import { chainCallback, addMiddleClickPan, addWheelPassthrough, cursorForBboxMode, watchImageInputs, captureVideoFrame } from './utility.js'; +const { app } = window.comfyAPI.app; + +const HANDLE = 8; // hit radius (canvas px) for corners/edges +const REGION_COLORS = ["#46b4e6", "#e6a23c", "#6cc06c", "#d86d84", "#a98cff"]; +const MAX_STYLE_COLORS = 16; +let copiedBox = null; // internal clipboard for copy/paste of regions (shared across nodes) + +// Track the most recent generated image so it can be grabbed as a background. +let lastResultImage = null; +try { + app.api?.addEventListener?.("executed", (e) => { + const imgs = e?.detail?.output?.images; + if (Array.isArray(imgs) && imgs.length) { + lastResultImage = imgs[imgs.length - 1]; + // Live nodes swap their preview for the full-res final result automatically. + for (const n of livePreviewNodes) n._ideoGrabFinal?.(); + } + }); +} catch (e) {} +function resultViewUrl(img) { + const p = new URLSearchParams({ filename: img.filename || "", subfolder: img.subfolder || "", type: img.type || "output" }); + return "/view?" + p.toString(); +} + +// Nodes opted into "live background": feed them the sampling preview frames as they arrive. +const livePreviewNodes = new Set(); +try { + app.api?.addEventListener?.("b_preview", (e) => { + const blob = e?.detail; + if (!blob || !livePreviewNodes.size) return; + createImageBitmap(blob).then((bmp) => { + let used = false; + for (const n of livePreviewNodes) { n._ideoSetLiveBg?.(bmp); used = true; } + if (!used && bmp.close) bmp.close(); + }).catch(() => {}); + }); +} catch (e) {} + +// Parse a #rrggbb hex into {r,g,b}, or null if malformed. +function hexRgb(hex) { + const h = (hex || "").replace("#", ""); + if (h.length < 6) return null; + return { r: parseInt(h.slice(0, 2), 16), g: parseInt(h.slice(2, 4), 16), b: parseInt(h.slice(4, 6), 16) }; +} +// Perceived luminance (0-255) of an {r,g,b}. +function luminance({ r, g, b }) { return 0.299 * r + 0.587 * g + 0.114 * b; } + +// The hex color lightened toward white if too dark, so it stays readable on the dark canvas. +function readableText(hex) { + const c = hexRgb(hex); + if (!c) return "#d4d4d4"; + let { r, g, b } = c; + const lum = luminance(c), MIN = 130; + if (lum < MIN) { + const t = (MIN - lum) / (255 - lum); + r = Math.round(r + (255 - r) * t); g = Math.round(g + (255 - g) * t); b = Math.round(b + (255 - b) * t); + } + return `rgb(${r},${g},${b})`; +} + +// Black or white, whichever contrasts better with the given hex background. +function textOn(hex) { + const c = hexRgb(hex); + if (!c) return "#000"; + return luminance(c) > 140 ? "#000" : "#fff"; +} + +function injectStyle() { + if (document.getElementById("kjideo-style")) return; + const s = document.createElement("style"); + s.id = "kjideo-style"; + s.textContent = ` + .kjideo-wrap { display:flex; flex-direction:column; overflow:hidden; position:relative; pointer-events:auto; gap:4px; } + .kjideo-canvas { cursor:crosshair; display:block; width:100%; height:auto; flex:0 0 auto; background:#1a1a1a; border-radius:4px; outline:none; } + .kjideo-bar { display:flex; align-items:center; gap:6px; font:11px sans-serif; color:#aaa; user-select:none; padding:0 2px; flex:0 0 auto; } + .kjideo-panel { display:flex; flex-direction:column; gap:5px; padding:6px; background:#262626; border-radius:4px; font:11px sans-serif; color:#bbb; flex:0 0 auto; } + .kjideo-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; } + .kjideo-btn { background:#333; border:1px solid #555; border-radius:4px; color:#bbb; font:11px sans-serif; cursor:pointer; padding:2px 8px; line-height:16px; white-space:nowrap; flex-shrink:0; } + .kjideo-btn:hover { border-color:#46b4e6; color:#fff; } + .kjideo-btn.active { border-color:#46b4e6; color:#46b4e6; background:#2a3a42; } + .kjideo-area { width:100%; box-sizing:border-box; background:#1d1d1d; border:1px solid #444; border-radius:4px; color:#ddd; font:13px monospace; padding:4px 6px; resize:vertical; min-height:36px; } + .kjideo-sw { width:20px; height:20px; border:1px solid #666; border-radius:3px; cursor:pointer; flex-shrink:0; position:relative; transition:transform .18s ease, box-shadow .12s ease, opacity .12s ease; } + .kjideo-sw:hover { transform:scale(1.2); box-shadow:0 0 0 2px #46b4e6; z-index:3; } + .kjideo-sw.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; } + body.kjideo-dragging, body.kjideo-dragging * { cursor:move !important; } + .kjideo-sw input { position:absolute; opacity:0; width:0; height:0; pointer-events:none; } + .kjideo-inline { position:absolute; box-sizing:border-box; background:rgba(18,18,18,0.92); border:2px solid #46b4e6; border-radius:3px; color:#fff; font:13px monospace; padding:3px 4px; resize:none; outline:none; z-index:10; } + .kjideo-menu { position:fixed; z-index:10000; background:#262626; border:1px solid #555; border-radius:6px; padding:4px; box-shadow:0 6px 20px rgba(0,0,0,0.55); font:12px sans-serif; color:#ddd; max-height:60vh; overflow-y:auto; min-width:210px; max-width:340px; } + .kjideo-mhdr { font:11px sans-serif; color:#888; padding:2px 6px 4px; user-select:none; } + .kjideo-lrow { display:flex; align-items:center; gap:6px; padding:3px 5px; border-radius:4px; cursor:move; user-select:none; transition:transform .18s ease, box-shadow .12s ease, opacity .12s ease, background .12s; } + .kjideo-lrow:hover { background:#333; } + .kjideo-lrow.active { background:#2a3a42; box-shadow:inset 0 0 0 1px #46b4e6; } + .kjideo-lrow.dragging { opacity:.4; box-shadow:0 0 0 2px #46b4e6; background:#333; } + .kjideo-lsw { width:16px; height:16px; border-radius:3px; border:1px solid #666; flex:0 0 auto; } + .kjideo-lnum { font:bold 11px monospace; color:#888; flex:0 0 auto; width:18px; } + .kjideo-ltext { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .kjideo-ltext.empty { color:#777; font-style:italic; } + .kjideo-lbtn { background:none; border:none; color:#999; cursor:pointer; font:13px sans-serif; line-height:1; padding:2px 5px; border-radius:3px; flex:0 0 auto; } + .kjideo-lbtn:hover { color:#fff; background:#444; } + .kjideo-lbtn.del:hover { color:#fff; background:#a33; } + `; + document.head.appendChild(s); +} + +app.registerExtension({ + name: "ZITIdeogram.ZImageTurboRegionBuilder", + + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData?.name !== "ZImageTurboRegionBuilderKJ") return; + injectStyle(); + + chainCallback(nodeType.prototype, "onNodeCreated", function () { + const node = this; + const findW = (n) => node.widgets?.find((w) => w.name === n); + const elementsWidget = findW("regions_data"); + const stylePaletteWidget = null; + const bgBrightnessWidget = findW("bg_brightness"); + if (bgBrightnessWidget && typeof bgBrightnessWidget.value !== "number") bgBrightnessWidget.value = 25; + const wWidget = findW("width"), hWidget = findW("height"); + // Hide the data widgets while keeping them serializable. + function hideDataWidgets() { + for (const w of [elementsWidget, bgBrightnessWidget]) { + if (!w) continue; + w.hidden = true; + w.computeSize = () => [0, -4]; + } + for (const name of ["regions_data", "bg_brightness"]) { + const i = node.inputs?.findIndex((inp) => inp.name === name); + if (i != null && i !== -1) node.removeInput(i); + } + } + hideDataWidgets(); + + node._boxes = []; // {x,y,w,h normalized 0-1, type, text, desc, palette[]} + node._stylePalette = []; // global style color palette (hex[]) + node._activeIdx = -1; + node._drawing = false; + node._placing = false; // duplicate-placement mode: active box follows the cursor until clicked + node._dragMode = null; + node._dragStartN = null; // mouse-down point, normalized + node._boxAtStart = null; // active box snapshot at drag start + node._hoverTitle = null; // index of the title chip under the cursor + node._hoverBox = null; // index of the box under the cursor + node._focused = false; // editor (DOM) focused — gates the active-box highlight + node._selected = false; // node selected in the graph + node._bgImg = null; // optional reference image shown as the canvas background + node._bgManual = false; // bg set via "use last result" (not the image input) + node._lastImported = ""; // last import_json applied to the editor (avoid re-apply) + node._areaH = node._areaH || {}; // remembered textarea heights (per field) + node._areaObservers = []; // live ResizeObservers to disconnect on rebuild + + // ── DOM ── + const wrap = document.createElement("div"); + wrap.className = "kjideo-wrap"; + const bar = document.createElement("div"); + bar.className = "kjideo-bar"; + const hint = document.createElement("span"); + hint.style.flex = "1"; + const copyBtn = document.createElement("button"); + copyBtn.className = "kjideo-btn"; + copyBtn.textContent = "Copy"; + copyBtn.title = "Copy the current caption JSON to the clipboard"; + const importBtn = document.createElement("button"); + importBtn.className = "kjideo-btn"; + importBtn.textContent = "Paste"; + importBtn.title = "Parse a caption JSON (clipboard, else paste prompt) and populate the node"; + const clearBtn = document.createElement("button"); + clearBtn.className = "kjideo-btn"; + clearBtn.textContent = "Clear all"; + const tokenSpan = document.createElement("span"); + tokenSpan.style.cssText = "color:#888; white-space:nowrap;"; + tokenSpan.title = "Rough token estimate (~chars/4). Grey <256, green healthy, orange nearing, red ≥2048 (model cap — will error)"; + const grabBtn = document.createElement("button"); + grabBtn.className = "kjideo-btn"; + grabBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + grabBtn.addEventListener("click", () => { (node._bgManual && node._bgImg) ? node._clearBg() : node._grabResultBg(); }); + function updateGrabBtn() { + const clear = node._bgManual && node._bgImg; + grabBtn.textContent = clear ? "Clear BG" : "Grab BG"; + grabBtn.title = clear ? "Remove the grabbed background" + : "Use the last generated image as the background"; + } + const liveLabel = document.createElement("label"); + liveLabel.style.cssText = "display:flex;align-items:center;gap:3px;flex:0 0 auto;cursor:pointer;"; + liveLabel.title = "Use the live sampling preview as the background while generating"; + const liveChk = document.createElement("input"); + liveChk.type = "checkbox"; + liveChk.checked = !!node.properties.liveBg; + liveChk.addEventListener("mousedown", (e) => e.stopPropagation()); + liveChk.addEventListener("change", () => { + node.properties.liveBg = liveChk.checked; + if (liveChk.checked) livePreviewNodes.add(node); else livePreviewNodes.delete(node); + }); + liveLabel.appendChild(liveChk); liveLabel.appendChild(document.createTextNode("Live")); + if (liveChk.checked) livePreviewNodes.add(node); + const bgSlider = document.createElement("input"); + bgSlider.type = "range"; bgSlider.min = "0"; bgSlider.max = "100"; bgSlider.step = "1"; + bgSlider.value = bgBrightnessWidget ? bgBrightnessWidget.value : 25; + bgSlider.title = "Background brightness (image or blank canvas)"; + bgSlider.style.cssText = "width:64px;flex:0 0 auto;"; + stopProp(bgSlider); + bgSlider.addEventListener("input", () => { if (bgBrightnessWidget) bgBrightnessWidget.value = parseInt(bgSlider.value, 10); drawCanvas(); }); + bar.appendChild(hint); bar.appendChild(liveLabel); bar.appendChild(grabBtn); bar.appendChild(bgSlider); bar.appendChild(tokenSpan); bar.appendChild(copyBtn); bar.appendChild(importBtn); bar.appendChild(clearBtn); + updateGrabBtn(); + + // Persistent global style-palette row + const styleBar = document.createElement("div"); + styleBar.className = "kjideo-bar"; + styleBar.style.display = "none"; + const styleLbl = document.createElement("span"); + styleLbl.textContent = "Style colors:"; + styleBar.appendChild(styleLbl); + + const canvasEl = document.createElement("canvas"); + canvasEl.className = "kjideo-canvas"; + canvasEl.tabIndex = 0; // focusable, so it can receive key events + canvasEl.title = "Drag to draw · click to select · alt-click overlap · dbl-click edit · " + + "right-click region list · Del remove · Ctrl/Cmd+C/V/D copy/paste/duplicate"; + const ctx = canvasEl.getContext("2d"); + addWheelPassthrough(wrap); + addMiddleClickPan(canvasEl); + + const panel = document.createElement("div"); + panel.className = "kjideo-panel"; + + // Canvas above panel so the panel grows downward without shifting the canvas. + wrap.appendChild(bar); wrap.appendChild(styleBar); wrap.appendChild(canvasEl); wrap.appendChild(panel); + + const TOOLBAR_H = 22; + node._widgetHeight = 360; + node.ideoEditor = node.addDOMWidget("zimage_region_editor", "ZImageTurboRegionEditor", wrap, { + serialize: false, hideOnZoom: false, + getMinHeight: () => node._widgetHeight, + }); + node.resizable = true; + + // ── canvas sizing ── + // The display size is CSS-driven (width:100% + aspect-ratio); the backing store + // is sized to display Ă— devicePixelRatio in prepCanvas() so text/lines stay crisp. + function setCanvasSize(w, h) { + canvasEl.style.aspectRatio = `${w} / ${h}`; // display shape only + if (node.graph) node.graph.setDirtyCanvas(true, true); + } + function syncCanvasToDims() { + const w = wWidget ? wWidget.value : 1024, h = hWidget ? hWidget.value : 1024; + setCanvasSize(Math.max(1, w), Math.max(1, h)); + drawCanvas(); + } + + // Content height = panel's bottom edge in the wrapper (includes toolbar/canvas/gaps). + function recalcWidgetHeight() { + const contentH = panel.offsetTop + panel.offsetHeight; + if (contentH > 0) { + node._widgetHeight = contentH + 10; // margin pad + } else { // not laid out yet — estimate + const ratio = (hWidget?.value || 1) / (wWidget?.value || 1); + node._widgetHeight = Math.round(Math.max(100, node.size[0] - 30) * ratio) + TOOLBAR_H + 70; + } + } + function fitNode() { + recalcWidgetHeight(); + // computeSize (stable min-heights), not last_y which creeps with growable widgets above. + const minH = node.computeSize()[1]; + if (node.size[1] < minH) node.setSize([node.size[0], minH]); + } + + // ── geometry helpers ── (logical CSS px = the displayed canvas size) + function logW() { return canvasEl.offsetWidth || 1; } + function logH() { return canvasEl.offsetHeight || 1; } + function toPx(b) { + const W = logW(), H = logH(); + return { x1: b.x * W, y1: b.y * H, x2: (b.x + b.w) * W, y2: (b.y + b.h) * H }; + } + function mouseN(e) { + const r = canvasEl.getBoundingClientRect(); + return { x: (e.clientX - r.left) / r.width, y: (e.clientY - r.top) / r.height }; + } + function clamp01(v) { return Math.max(0, Math.min(1, v)); } + // greedy word-wrap to maxW px (ctx.font must be set by caller) + function wrapLines(text, maxW) { + const lines = []; + for (const para of text.split("\n")) { + let line = ""; + for (const word of para.split(/\s+/)) { + if (!word) continue; + const test = line ? line + " " + word : word; + if (line && ctx.measureText(test).width > maxW) { lines.push(line); line = word; } + else line = test; + } + lines.push(line); + } + return lines; + } + function normalizeBox(b) { + // collapse negative size to positive top-left + w/h, clamp into canvas + let x = b.x, y = b.y, w = b.w, h = b.h; + if (w < 0) { x += w; w = -w; } + if (h < 0) { y += h; h = -h; } + x = clamp01(x); y = clamp01(y); + w = Math.min(w, 1 - x); h = Math.min(h, 1 - y); + return { ...b, x, y, w: Math.max(0, w), h: Math.max(0, h) }; + } + + // All boxes under the point, top-first to match draw order: the active box is + // drawn last (on top), then the rest by index low→high (index 0 = front). + function boxesAt(mN) { + const rx = HANDLE / logW(), ry = HANDLE / logH(); + const res = []; + for (let i = 0; i < node._boxes.length; i++) { + const b = node._boxes[i]; + const mode = rectHitTestN(mN.x, mN.y, b.x, b.y, b.x + b.w, b.y + b.h, rx, ry); + if (mode) res.push({ index: i, mode }); + } + const ai = res.findIndex((c) => c.index === node._activeIdx); + if (ai > 0) res.unshift(res.splice(ai, 1)[0]); + return res; + } + // Hover / right-click: prefer a resize handle on the active box, else topmost. + function hitTest(mN) { + const cands = boxesAt(mN); + if (!cands.length) return null; + return cands.find((c) => c.index === node._activeIdx && c.mode !== "move") || cands[0]; + } + // Tag-chip rects (canvas px), placed to avoid overlapping each other: each + // box's tag tries top-left, top-right, bottom-right, bottom-left in turn. + function tagRects() { + ctx.font = "bold 11px monospace"; + const W = logW(), H = logH(), h = 14; + const placed = [], rects = []; + const hits = (a, b) => a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; + for (let i = 0; i < node._boxes.length; i++) { + const b = node._boxes[i]; + const x1 = b.x * W, y1 = b.y * H, x2 = (b.x + b.w) * W, y2 = (b.y + b.h) * H; + const tag = String(i + 1).padStart(2, "0"); + const w = ctx.measureText(tag).width + 8; + let pick = [x1, y1]; + for (const [cx, cy] of [[x1, y1], [x2 - w, y1], [x2 - w, y2 - h], [x1, y2 - h]]) { + if (!placed.some((p) => hits({ x: cx, y: cy, w, h }, p))) { pick = [cx, cy]; break; } + } + const r = { x: pick[0], y: pick[1], w, h, tag }; + placed.push(r); rects[i] = r; + } + return rects; + } + function titleAt(mN) { + const px = mN.x * logW(), py = mN.y * logH(); + const rects = tagRects(); + for (let i = node._boxes.length - 1; i >= 0; i--) { + const r = rects[i]; + if (r && px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h) return i; + } + return null; + } + // Click selection: active box's resize handle wins (corner resize); then a + // title-chip click selects that box (drawn to front); Alt-click cycles the + // overlap stack; else the topmost box. + function pickForSelection(mN, cycle) { + const cands = boxesAt(mN); + if (!cands.length) return null; + const ah = cands.find((c) => c.index === node._activeIdx && c.mode !== "move"); + if (ah && !cycle) return ah; + const ti = titleAt(mN); + if (ti !== null && !cycle) return { index: ti, mode: "move" }; + if (cycle && cands.length > 1) { + const pos = cands.findIndex((c) => c.index === node._activeIdx); + return cands[(pos + 1) % cands.length]; + } + return cands.find((c) => c.index === node._activeIdx && c.mode !== "move") || cands[0]; + } + // normalized variant of rectHitTest with separate x/y radii + function rectHitTestN(mx, my, x1, y1, x2, y2, rx, ry) { + const h = (cx, cy) => Math.abs(mx - cx) < rx && Math.abs(my - cy) < ry; + if (h(x1, y1)) return "resize-tl"; + if (h(x2, y1)) return "resize-tr"; + if (h(x1, y2)) return "resize-bl"; + if (h(x2, y2)) return "resize-br"; + if (mx >= x1 && mx <= x2 && Math.abs(my - y1) < ry) return "resize-t"; + if (mx >= x1 && mx <= x2 && Math.abs(my - y2) < ry) return "resize-b"; + if (my >= y1 && my <= y2 && Math.abs(mx - x1) < rx) return "resize-l"; + if (my >= y1 && my <= y2 && Math.abs(mx - x2) < rx) return "resize-r"; + if (mx >= x1 && mx <= x2 && my >= y1 && my <= y2) return "move"; + return null; + } + + function applyDrag(mode, start, dN) { + let { x, y, w, h } = start; + const dx = dN.x, dy = dN.y; + switch (mode) { + case "move": x += dx; y += dy; x = clamp01(Math.min(x, 1 - w)); y = clamp01(Math.min(y, 1 - h)); break; + case "draw": + case "resize-br": w += dx; h += dy; break; + case "resize-tl": x += dx; y += dy; w -= dx; h -= dy; break; + case "resize-tr": y += dy; w += dx; h -= dy; break; + case "resize-bl": x += dx; w -= dx; h += dy; break; + case "resize-t": y += dy; h -= dy; break; + case "resize-b": h += dy; break; + case "resize-l": x += dx; w -= dx; break; + case "resize-r": w += dx; break; + } + return mode === "move" ? { ...start, x, y } : normalizeBox({ ...start, x, y, w, h }); + } + + // ── drawing ── + let _rafPending = false; + function drawCanvas() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(() => { + _rafPending = false; + _draw(); + }); + } + function _draw() { + // Size the backing store to display Ă— DPR and draw in logical px (crisp text/lines). + const W = logW(), H = logH(), d = window.devicePixelRatio || 1; + const bw = Math.round(W * d), bh = Math.round(H * d); + if (canvasEl.width !== bw || canvasEl.height !== bh) { canvasEl.width = bw; canvasEl.height = bh; } + ctx.setTransform(d, 0, 0, d, 0, 0); + ctx.clearRect(0, 0, W, H); + let bri = bgBrightnessWidget ? bgBrightnessWidget.value : 25; + if (typeof bri !== "number" || isNaN(bri)) bri = 25; // guard against unset widget value + if (node._bgImg) { // reference image, dimmed by brightness + ctx.drawImage(node._bgImg, 0, 0, W, H); + const dim = 1 - bri / 100; + if (dim > 0) { ctx.fillStyle = `rgba(0,0,0,${dim})`; ctx.fillRect(0, 0, W, H); } + } else { // blank canvas grey from brightness + const g = Math.round(bri / 100 * 128); + ctx.fillStyle = `rgb(${g},${g},${g})`; ctx.fillRect(0, 0, W, H); + } + // active box only when the editor is focused or the node is selected + const aIdx = (node._focused || node._selected) ? node._activeIdx : -1; + // index 0 = front (drawn last among non-active) so it matches the layers list (01 on top) + const order = node._boxes.map((_, i) => i).filter((i) => i !== aIdx).reverse(); + if (aIdx >= 0 && aIdx < node._boxes.length) order.push(aIdx); // active drawn last (on top) + const tagR = tagRects(); // collision-avoided tag positions + for (const i of order) { + const b = node._boxes[i], active = i === aIdx; + const col = REGION_COLORS[i % REGION_COLORS.length]; + const { x1, y1, x2, y2 } = toPx(b); + const w = x2 - x1, h = y2 - y1; + const hovered = i === node._hoverBox || active; // active box stays highlighted (on top) + if (active) { // opaque backing so contents read clearly over boxes behind + ctx.fillStyle = "rgba(26,26,26,0.88)"; + ctx.fillRect(x1, y1, w, h); + } + ctx.fillStyle = col + (hovered ? "3a" : "22"); // tint of the box color + ctx.fillRect(x1, y1, w, h); + if (b.nobbox) ctx.setLineDash([6, 4]); // unplaced (no bbox in source) + const lw = active ? 2 : (hovered ? 1.5 : 1); + ctx.strokeStyle = col; ctx.lineWidth = lw; + ctx.strokeRect(x1 + lw / 2, y1 + lw / 2, w - lw, h - lw); // inside the box so strip/badge align at y1 + ctx.setLineDash([]); // in-box content (clipped to the box): prompt text, tag chip on top + ctx.save(); + ctx.beginPath(); ctx.rect(x1, y1, w, h); ctx.clip(); + + let body = b.prompt || b.desc || ""; + if (body) { + ctx.font = "12px monospace"; + ctx.fillStyle = readableText(col); // box color, lightened if too dark + const pad = 4, lh = 14; + let ty = y1 + 15 + 12; // first line below the tag chip + for (const line of wrapLines(body, w - pad * 2)) { + if (ty > y1 + h) break; // clip overflow vertically + ctx.fillText(line, x1 + pad, ty); + ty += lh; + } + } + // tag chip on top, at its collision-avoided position + const tr = tagR[i]; + ctx.font = "bold 11px monospace"; + ctx.fillStyle = col; // tag chip = box color + ctx.fillRect(tr.x, tr.y, tr.w, 14); + if (i === node._hoverTitle) { // hover highlight + ctx.fillStyle = "rgba(255,255,255,0.25)"; ctx.fillRect(tr.x, tr.y, tr.w, 14); + ctx.strokeStyle = "#fff"; ctx.lineWidth = 1; ctx.strokeRect(tr.x + 0.5, tr.y + 0.5, tr.w - 1, 13); + } + ctx.fillStyle = textOn(col); + ctx.fillText(tr.tag, tr.x + 4, tr.y + 11); + ctx.restore(); + } + } + + // ── serialization ── + function serialize() { + if (elementsWidget) elementsWidget.value = node._boxes.length ? JSON.stringify(node._boxes) : ""; + if (stylePaletteWidget) stylePaletteWidget.value = node._stylePalette.length ? JSON.stringify(node._stylePalette) : ""; + } + + function commit() { serialize(); renderPanel(); drawCanvas(); updateTokens(); } + // Live text edit: persist + repaint + token count, without rebuilding the panel. + function touch() { serialize(); drawCanvas(); updateTokens(); } + + function removeBox(i) { + node._boxes.splice(i, 1); + if (node._boxes.length === 0) node._activeIdx = -1; + else if (i <= node._activeIdx) node._activeIdx = Math.max(0, node._activeIdx - 1); + } + + // ── pointer interaction ── + canvasEl.addEventListener("mousedown", (e) => { + if (node._placing) { // drop the duplicate being placed + if (e.button === 0) { placeFollower(mouseN(e)); finishPlacing(); } + else cancelPlacing(); + e.preventDefault(); e.stopPropagation(); + return; + } + if (e.button !== 0) return; + canvasEl.focus(); // so Delete/Backspace targets this editor + node._hoverTitle = null; node._hoverBox = null; // clear hover highlight while interacting + const mN = mouseN(e); + const hit = pickForSelection(mN, e.altKey); + if (hit) { + node._activeIdx = hit.index; + node._dragMode = hit.mode; + node._boxAtStart = { ...node._boxes[hit.index] }; + } else { + node._dragMode = "draw"; + const nb = { x: mN.x, y: mN.y, w: 0, h: 0, prompt: "", negative_prompt: "", strength: 1.0, feather: 16.0 }; + node._boxes.push(nb); + node._activeIdx = node._boxes.length - 1; + node._boxAtStart = { ...nb }; + } + node._drawing = true; + node._dragStartN = mN; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + e.preventDefault(); e.stopPropagation(); + drawCanvas(); // panel rebuild/resize deferred to onUp so the canvas doesn't shift mid-drag + }); + + canvasEl.addEventListener("mousemove", (e) => { + if (node._placing) { placeFollower(mouseN(e)); return; } + if (node._drawing) return; + const mN = mouseN(e); + const ti = titleAt(mN); + const hit = hitTest(mN); + const hb = ti != null ? ti : (hit ? hit.index : null); + if (ti !== node._hoverTitle || hb !== node._hoverBox) { + node._hoverTitle = ti; node._hoverBox = hb; drawCanvas(); + } + canvasEl.style.cursor = ti != null ? "pointer" : (hit ? (cursorForBboxMode(hit.mode) || "crosshair") : "crosshair"); + }); + canvasEl.addEventListener("mouseleave", () => { + if (node._hoverTitle !== null || node._hoverBox !== null) { + node._hoverTitle = null; node._hoverBox = null; drawCanvas(); + } + }); + + // ── inline description editing (double-click a region) ── + let inlineTa = null; + function closeInlineEditor() { + if (inlineTa) { inlineTa.remove(); inlineTa = null; } + } + function openInlineEditor(idx) { + closeInlineEditor(); + const b = node._boxes[idx]; + if (!b) return; + node._activeIdx = idx; + const dw = canvasEl.offsetWidth, dh = canvasEl.offsetHeight; // CSS display size + const ox = canvasEl.offsetLeft, oy = canvasEl.offsetTop; + const w = Math.min(dw, Math.max(70, b.w * dw)); + const h = Math.min(dh, Math.max(42, b.h * dh)); + // clamp so the editor stays inside the canvas (wrapper is overflow:hidden) + const left = Math.max(ox, Math.min(ox + b.x * dw, ox + dw - w)); + const top = Math.max(oy, Math.min(oy + b.y * dh, oy + dh - h)); + const ta = document.createElement("textarea"); + ta.className = "kjideo-inline"; + ta.value = b.prompt || b.desc || ""; + ta.style.left = left + "px"; + ta.style.top = top + "px"; + ta.style.width = w + "px"; + ta.style.height = h + "px"; + ta.style.borderColor = REGION_COLORS[idx % REGION_COLORS.length]; + stopProp(ta); + wrap.appendChild(ta); + inlineTa = ta; + ta.focus(); ta.select(); + const orig = b.prompt || b.desc || ""; + let cancelled = false; + ta.addEventListener("input", () => { b.prompt = ta.value; b.desc = ta.value; drawCanvas(); updateTokens(); }); + ta.addEventListener("keydown", (e) => { + e.stopPropagation(); + if (e.key === "Escape") { cancelled = true; b.prompt = orig; b.desc = orig; ta.blur(); } + else if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) ta.blur(); + }); + ta.addEventListener("blur", () => { + if (!cancelled) { b.prompt = ta.value; b.desc = ta.value; } + closeInlineEditor(); + commit(); + }); + } + canvasEl.addEventListener("dblclick", (e) => { + e.preventDefault(); e.stopPropagation(); + const cands = boxesAt(mouseN(e)); // edit the active box if it's under the cursor, else topmost + const target = cands.find((c) => c.index === node._activeIdx) || cands[0]; + if (target) openInlineEditor(target.index); + }); + + // Paste a clone of the clipboard box, offset slightly and clamped into the canvas. + function pasteBox() { + if (!copiedBox) return; + const nb = JSON.parse(JSON.stringify(copiedBox)); + nb.x = Math.max(0, Math.min(clamp01(nb.x + 0.03), 1 - nb.w)); + nb.y = Math.max(0, Math.min(clamp01(nb.y + 0.03), 1 - nb.h)); + delete nb.nobbox; // a pasted box is placed + node._boxes.push(nb); + node._activeIdx = node._boxes.length - 1; + commit(); fitNode(); + } + // Keyboard: Delete removes; Ctrl/Cmd C/V/D copy/paste/duplicate the active region. + // Canvas must be focused; stop the event so LiteGraph doesn't act on the node. + canvasEl.addEventListener("keydown", (e) => { + if (node._placing) { + if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); cancelPlacing(); } + return; + } + if (node._drawing) return; + const ctrl = e.ctrlKey || e.metaKey; + if ((e.key === "Delete" || e.key === "Backspace") && node._activeIdx >= 0) { + e.preventDefault(); e.stopPropagation(); + removeBox(node._activeIdx); commit(); fitNode(); + } else if (ctrl && e.key === "c" && node._activeIdx >= 0) { + e.preventDefault(); e.stopPropagation(); + copiedBox = JSON.parse(JSON.stringify(node._boxes[node._activeIdx])); + } else if (ctrl && e.key === "v" && copiedBox) { + e.preventDefault(); e.stopPropagation(); + pasteBox(); + } else if (ctrl && e.key === "d" && node._activeIdx >= 0) { + e.preventDefault(); e.stopPropagation(); + copiedBox = JSON.parse(JSON.stringify(node._boxes[node._activeIdx])); + pasteBox(); + } + }); + + function onMove(e) { + if (!node._drawing) return; + const mN = mouseN(e); + const dN = { x: mN.x - node._dragStartN.x, y: mN.y - node._dragStartN.y }; + const nb = applyDrag(node._dragMode, node._boxAtStart, dN); + delete nb.nobbox; // moving/resizing places the element (gives it a bbox) + node._boxes[node._activeIdx] = nb; + drawCanvas(); + } + function onUp() { + if (!node._drawing) return; + node._drawing = false; + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + // drop zero-size boxes created by an accidental click + const b = node._boxes[node._activeIdx]; + if (b && (b.w < 0.005 || b.h < 0.005) && node._dragMode === "draw") { + removeBox(node._activeIdx); + } + commit(); + } + + // ── duplicate placement: the new box follows the cursor until clicked ── + function placeFollower(mN) { + const b = node._boxes[node._activeIdx]; + if (!b) return; + b.x = clamp01(Math.min(mN.x - b.w / 2, 1 - b.w)); + b.y = clamp01(Math.min(mN.y - b.h / 2, 1 - b.h)); + delete b.nobbox; + drawCanvas(); + } + function startPlacing(srcIdx) { + const src = node._boxes[srcIdx]; + if (!src) return; + const nb = JSON.parse(JSON.stringify(src)); + delete nb.nobbox; + node._boxes.push(nb); + node._activeIdx = node._boxes.length - 1; + node._placing = true; + canvasEl.focus(); + canvasEl.style.cursor = "move"; + serialize(); renderPanel(); drawCanvas(); updateTokens(); + } + function finishPlacing() { + if (!node._placing) return; + node._placing = false; + canvasEl.style.cursor = "crosshair"; + commit(); fitNode(); + } + function cancelPlacing() { + if (!node._placing) return; + node._placing = false; + canvasEl.style.cursor = "crosshair"; + removeBox(node._activeIdx); + commit(); fitNode(); + } + + // ── right-click "layers" menu: list / select / delete / duplicate / reorder regions ── + function closeLayersMenu() { + if (node._layerMenu) { node._layerMenu.remove(); node._layerMenu = null; } + if (node._layerMenuDismiss) { + document.removeEventListener("mousedown", node._layerMenuDismiss, true); + document.removeEventListener("pointerdown", node._layerMenuDismiss, true); + node._layerMenuDismiss = null; + } + } + function rowLabel(b) { + if (b.type === "text") { + const t = b.text ? `"${b.text}"` : ""; + return b.desc ? (t ? t + " — " + b.desc : b.desc) : t; + } + return b.desc || ""; + } + function openLayersMenu(clientX, clientY) { + closeLayersMenu(); + const menu = document.createElement("div"); + menu.className = "kjideo-menu"; + const hdr = document.createElement("div"); + hdr.className = "kjideo-mhdr"; + hdr.textContent = "Regions — top = front · click select · drag reorder"; + // 01 at the top of the list = front-most (drawn on top); see _draw / boxesAt. + menu.appendChild(hdr); + const list = document.createElement("div"); + menu.appendChild(list); + node._layerMenu = menu; + + const renumber = () => Array.from(list.querySelectorAll(".kjideo-lrow")).forEach((row, k) => { + row.querySelector(".kjideo-lnum").textContent = String(k + 1).padStart(2, "0"); + }); + function buildRows() { + list.innerHTML = ""; + if (!node._boxes.length) { + const empty = document.createElement("div"); + empty.className = "kjideo-mhdr"; empty.textContent = "No regions yet."; + list.appendChild(empty); + return; + } + node._boxes.forEach((b, i) => { + const row = document.createElement("div"); + row.className = "kjideo-lrow" + (i === node._activeIdx ? " active" : ""); + row._box = b; + const sw = document.createElement("div"); + sw.className = "kjideo-lsw"; + sw.style.background = (b.palette || []).find(Boolean) || "#8c8c8c"; + const num = document.createElement("span"); + num.className = "kjideo-lnum"; num.textContent = String(i + 1).padStart(2, "0"); + const txt = document.createElement("span"); + const label = rowLabel(b); + txt.className = "kjideo-ltext" + (label ? "" : " empty"); + txt.textContent = label || (b.type === "text" ? "(text)" : "(empty)"); + txt.title = label; + const dup = document.createElement("button"); + dup.className = "kjideo-lbtn"; dup.textContent = "⧉"; + dup.title = "Duplicate, then click on the canvas to place"; + const del = document.createElement("button"); + del.className = "kjideo-lbtn del"; del.textContent = "âś•"; + del.title = "Delete region"; + row.append(sw, num, txt, dup, del); + list.appendChild(row); + + row.addEventListener("click", () => { + if (row._dragged) { row._dragged = false; return; } + node._activeIdx = node._boxes.indexOf(b); + commit(); + for (const r of list.querySelectorAll(".kjideo-lrow")) r.classList.toggle("active", r._box === b); + }); + dup.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = node._boxes.indexOf(b); + closeLayersMenu(); + startPlacing(idx); + }); + del.addEventListener("click", (e) => { + e.stopPropagation(); + const idx = node._boxes.indexOf(b); + if (idx < 0) return; + removeBox(idx); commit(); fitNode(); + if (!node._boxes.length) { closeLayersMenu(); return; } + buildRows(); + }); + // drag-reorder (vertical FLIP, mirrors the palette swatch reorder) + row.addEventListener("mousedown", (e) => { + if (e.button !== 0 || e.target === dup || e.target === del) return; + e.preventDefault(); e.stopPropagation(); + const sx = e.clientX, sy = e.clientY; + let dragging = false; + const move = (me) => { + if (!dragging) { + if (Math.abs(me.clientX - sx) + Math.abs(me.clientY - sy) < 4) return; + dragging = true; row.classList.add("dragging"); document.body.classList.add("kjideo-dragging"); + } + for (const other of list.querySelectorAll(".kjideo-lrow")) { + if (other === row) continue; + const r = other.getBoundingClientRect(); + if (me.clientY >= r.top && me.clientY <= r.bottom) { + const ref = me.clientY > r.top + r.height / 2 ? other.nextSibling : other; + if (ref === row || ref === row.nextSibling) break; + const els = Array.from(list.querySelectorAll(".kjideo-lrow")); + const prev = els.map((el) => el.getBoundingClientRect().top); + list.insertBefore(row, ref); + els.forEach((el, k) => { // FLIP: slide to new positions + const dy = prev[k] - el.getBoundingClientRect().top; + if (!dy) return; + el.style.transition = "none"; + el.style.transform = `translateY(${dy}px)`; + el.getBoundingClientRect(); // flush + el.style.transition = ""; el.style.transform = ""; + }); + break; + } + } + }; + const up = () => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + document.body.classList.remove("kjideo-dragging"); + if (dragging) { + row.classList.remove("dragging"); + row._dragged = true; // suppress the trailing click + const active = node._boxes[node._activeIdx]; + const order = Array.from(list.querySelectorAll(".kjideo-lrow")).map((el) => el._box); + if (order.length === node._boxes.length) node._boxes = order; + node._activeIdx = active ? node._boxes.indexOf(active) : -1; + renumber(); + commit(); + } + }; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + }); + }); + } + buildRows(); + + document.body.appendChild(menu); + const r = menu.getBoundingClientRect(); // clamp into the viewport + let left = clientX, top = clientY; + if (left + r.width > window.innerWidth) left = window.innerWidth - r.width - 4; + if (top + r.height > window.innerHeight) top = window.innerHeight - r.height - 4; + menu.style.left = Math.max(4, left) + "px"; + menu.style.top = Math.max(4, top) + "px"; + + node._layerMenuDismiss = (e) => { if (!menu.contains(e.target)) closeLayersMenu(); }; + setTimeout(() => { + document.addEventListener("mousedown", node._layerMenuDismiss, true); + document.addEventListener("pointerdown", node._layerMenuDismiss, true); + }, 0); + } + + canvasEl.addEventListener("contextmenu", (e) => { + e.preventDefault(); e.stopPropagation(); + if (node._placing) return; + closeInlineEditor(); + openLayersMenu(e.clientX, e.clientY); + }); + stopProp(clearBtn); + clearBtn.addEventListener("click", () => { + closeInlineEditor(); + node._boxes = []; node._activeIdx = -1; node._stylePalette = []; + commit(); rebuildStylePalette(); fitNode(); + }); + + // Z-Image overrides for the copied KJ editor import/export path. + function buildZImageRegionPayload() { + return { + global_prompt: getW("global_prompt") || "", + global_negative_prompt: getW("global_negative_prompt") || "", + regions: node._boxes.map((b) => ({ + x: b.x || 0, + y: b.y || 0, + w: b.w || 0, + h: b.h || 0, + prompt: b.prompt || b.desc || "", + negative_prompt: b.negative_prompt || "", + strength: Number.isFinite(Number(b.strength)) ? Number(b.strength) : 1.0, + feather: Number.isFinite(Number(b.feather)) ? Number(b.feather) : 16.0, + })), + }; + } + buildCaption = function () { return JSON.stringify(buildZImageRegionPayload(), null, 2); }; + updateTokens = function () { + const txt = (getW("global_prompt") || "") + " " + node._boxes.map((b) => b.prompt || b.desc || "").join(" "); + const n = Math.ceil(txt.length / 4); + tokenSpan.textContent = "~" + n + " tok"; + tokenSpan.style.color = n >= 2048 ? "#e05555" : n >= 1792 ? "#e6a23c" : n >= 256 ? "#6cc06c" : "#888"; + }; + tryParseCaption = function (t) { + if (!t) return null; + try { + const o = JSON.parse(t); + if (Array.isArray(o)) return { regions: o }; + if (o && typeof o === "object" && Array.isArray(o.regions)) return o; + } catch (e) {} + return null; + }; + loadCaption = function (cap) { + closeInlineEditor(); + const W = wWidget?.value || 1024, H = hWidget?.value || 1024; + node._boxes = (cap.regions || []).map((b, i) => { + const bb = b.bbox && typeof b.bbox === "object" ? b.bbox : null; + const x = Number.isFinite(Number(b.x)) ? Number(b.x) : (bb ? Number(bb.x || 0) / W : 0.05 + (i % 6) * 0.035); + const y = Number.isFinite(Number(b.y)) ? Number(b.y) : (bb ? Number(bb.y || 0) / H : 0.05 + (i % 6) * 0.035); + const w = Number.isFinite(Number(b.w)) ? Number(b.w) : (bb ? Number(bb.width || 0) / W : 0.24); + const h = Number.isFinite(Number(b.h)) ? Number(b.h) : (bb ? Number(bb.height || 0) / H : 0.16); + return { + x: clamp01(x), y: clamp01(y), w: Math.max(0, Math.min(1, w)), h: Math.max(0, Math.min(1, h)), + prompt: b.prompt || b.desc || "", + desc: b.prompt || b.desc || "", + negative_prompt: b.negative_prompt || b.negative || "", + strength: Number.isFinite(Number(b.strength)) ? Number(b.strength) : 1.0, + feather: Number.isFinite(Number(b.feather)) ? Number(b.feather) : 16.0, + }; + }); + node._activeIdx = node._boxes.length ? 0 : -1; + syncCanvasToDims(); commit(); fitNode(); + }; + async function doCopy() { + const txt = buildCaption(); + try { + await navigator.clipboard.writeText(txt); + copyBtn.textContent = "Copied"; + setTimeout(() => (copyBtn.textContent = "Copy"), 900); + } catch (e) { + window.prompt("Copy the Z-Image region JSON:", txt); + } + } + stopProp(copyBtn); + copyBtn.addEventListener("click", doCopy); + + async function doImport() { + let cap = null, txt = ""; + try { + txt = (await navigator.clipboard.readText() || "").trim(); + cap = tryParseCaption(txt); + } catch (e) {} + if (!cap) { + txt = (window.prompt("Paste Z-Image region JSON:", "") || "").trim(); + cap = tryParseCaption(txt); + } + if (!cap) { + if (txt) alert("Not valid Z-Image region JSON. Expected { regions: [...] } or an array of regions."); + return; + } + loadCaption(cap); + } + stopProp(importBtn); + importBtn.addEventListener("click", doImport); + + chainCallback(node, "onExecuted", function (message) { + if (message?.dims && !node._bgImg) { + const [w, h] = message.dims; + if (wWidget && w) wWidget.value = w; + if (hWidget && h) hWidget.value = h; + syncCanvasToDims(); fitNode(); + } + }); + // property panel + function stopProp(el) { + for (const ev of ["mousedown", "pointerdown", "wheel"]) el.addEventListener(ev, (e) => e.stopPropagation()); + } + // Color swatches: onEdit on change, onStruct on add/remove/reorder. Shared by both palettes. + // Pointer-based drag (HTML5 DnD is unreliable inside LiteGraph DOM widgets) with live reorder. + function buildSwatchRow(container, arr, max, onEdit, onStruct) { + arr.forEach((hex, i) => { + const sw = document.createElement("div"); + sw.className = "kjideo-sw"; + sw.style.background = hex; + sw.dataset.hex = hex; + sw.title = "Click edit · drag reorder · right-click remove"; + const inp = document.createElement("input"); + inp.type = "color"; inp.value = hex; + sw.appendChild(inp); + container.appendChild(sw); + inp.addEventListener("input", () => { arr[i] = inp.value; sw.style.background = inp.value; sw.dataset.hex = inp.value; onEdit(); }); + sw.addEventListener("wheel", (e) => e.stopPropagation()); + sw.addEventListener("contextmenu", (e) => { e.preventDefault(); e.stopPropagation(); arr.splice(i, 1); onStruct(); }); + sw.addEventListener("mousedown", (e) => { + if (e.button !== 0) return; + e.preventDefault(); e.stopPropagation(); + const sx = e.clientX, sy = e.clientY; + let dragging = false; + const move = (me) => { + if (!dragging) { + if (Math.abs(me.clientX - sx) + Math.abs(me.clientY - sy) < 4) return; + dragging = true; sw.classList.add("dragging"); document.body.classList.add("kjideo-dragging"); + } + for (const other of container.querySelectorAll(".kjideo-sw")) { + if (other === sw) continue; + const r = other.getBoundingClientRect(); + if (me.clientX >= r.left && me.clientX <= r.right && me.clientY >= r.top - 6 && me.clientY <= r.bottom + 6) { + const ref = me.clientX > r.left + r.width / 2 ? other.nextSibling : other; + if (ref === sw || ref === sw.nextSibling) break; // already there + const els = Array.from(container.querySelectorAll(".kjideo-sw")); + const prev = els.map((el) => el.getBoundingClientRect().left); + container.insertBefore(sw, ref); + els.forEach((el, k) => { // FLIP: slide to new positions + const dx = prev[k] - el.getBoundingClientRect().left; + if (!dx) return; + el.style.transition = "none"; + el.style.transform = `translateX(${dx}px)`; + el.getBoundingClientRect(); // flush + el.style.transition = ""; el.style.transform = ""; + }); + break; + } + } + }; + const up = () => { + document.removeEventListener("mousemove", move); + document.removeEventListener("mouseup", up); + document.body.classList.remove("kjideo-dragging"); + if (dragging) { + sw.classList.remove("dragging"); + const order = Array.from(container.querySelectorAll(".kjideo-sw")).map((el) => el.dataset.hex); + if (order.length === arr.length) { arr.length = 0; arr.push(...order); } + onStruct(); + } else { + inp.click(); // no drag → treat as click, open the picker + } + }; + document.addEventListener("mousemove", move); + document.addEventListener("mouseup", up); + }); + }); + if (arr.length < max) { + const add = document.createElement("button"); + add.className = "kjideo-btn"; add.textContent = "+"; + stopProp(add); + add.addEventListener("click", () => { arr.push("#ffffff"); onStruct(); }); + container.appendChild(add); + } + } + + // Swatch color changed (no add/remove): persist + repaint. + function swatchEdit() { serialize(); drawCanvas(); } + + function rebuildStylePalette() { + while (styleBar.children.length > 1) styleBar.removeChild(styleBar.lastChild); + buildSwatchRow(styleBar, node._stylePalette, MAX_STYLE_COLORS, + swatchEdit, + () => { swatchEdit(); rebuildStylePalette(); fitNode(); }); + } + + // Textarea whose user-dragged height persists across panel rebuilds / box switches. + function makeArea(field, value, placeholder, onInput, defaultH) { + const ta = document.createElement("textarea"); + ta.className = "kjideo-area"; + ta.placeholder = placeholder; + ta.value = value || ""; + const h = node._areaH[field] || defaultH; + if (h) ta.style.height = h + "px"; + stopProp(ta); + ta.addEventListener("input", onInput); + const ro = new ResizeObserver(() => { + if (ta.offsetHeight > 0) { node._areaH[field] = ta.offsetHeight; fitNode(); } + }); + ro.observe(ta); + node._areaObservers.push(ro); + return ta; + } + function renderPanel() { + for (const ro of node._areaObservers) ro.disconnect(); + node._areaObservers = []; + panel.innerHTML = ""; + const b = node._boxes[node._activeIdx]; + if (!b) { + hint.textContent = "Drag on the canvas to add a region"; + const p = document.createElement("div"); + p.style.color = "#888"; + p.textContent = node._boxes.length ? "Click a region to edit it." : "No regions yet."; + panel.appendChild(p); + requestAnimationFrame(fitNode); + return; + } + const col = REGION_COLORS[node._activeIdx % REGION_COLORS.length]; + hint.innerHTML = `region ${node._activeIdx + 1} · dbl-click edit · alt-click overlap · del remove`; + + panel.appendChild(makeArea("prompt", b.prompt || b.desc || "", "region prompt", + function () { b.prompt = this.value; b.desc = this.value; touch(); }, 92)); + panel.appendChild(makeArea("negative_prompt", b.negative_prompt || "", "optional region negative prompt", + function () { b.negative_prompt = this.value; touch(); }, 56)); + + const numRow = document.createElement("div"); + numRow.className = "kjideo-row"; + function addNumber(label, field, fallback, min, max, step) { + const span = document.createElement("span"); + span.textContent = label + ":"; + const input = document.createElement("input"); + input.type = "number"; + input.min = String(min); input.max = String(max); input.step = String(step); + input.value = Number.isFinite(Number(b[field])) ? Number(b[field]) : fallback; + input.style.cssText = "width:68px;background:#1d1d1d;border:1px solid #444;border-radius:4px;color:#ddd;font:12px monospace;padding:2px 4px;"; + stopProp(input); + input.addEventListener("input", () => { + const v = Number(input.value); + b[field] = Number.isFinite(v) ? Math.max(min, Math.min(max, v)) : fallback; + touch(); + }); + numRow.append(span, input); + } + addNumber("strength", "strength", 1.0, 0.0, 10.0, 0.01); + addNumber("feather", "feather", 16.0, 0.0, 512.0, 1.0); + panel.appendChild(numRow); + + requestAnimationFrame(fitNode); + } + + // ── width/height widget callbacks ── + for (const w of [wWidget, hWidget]) { + if (!w) continue; + chainCallback(w, "callback", () => { syncCanvasToDims(); drawCanvas(); fitNode(); }); + } + // Update the token estimate when the caption-level text widgets change. + for (const name of ["global_prompt", "global_negative_prompt"]) { + const w = findW(name); + if (w) chainCallback(w, "callback", () => updateTokens()); + } + + // ── keep canvas + getMinHeight in sync while the node is resized ── + let _resizing = false; + chainCallback(node, "onResize", function () { + if (_resizing) return; + _resizing = true; + recalcWidgetHeight(); + // Resize clamp reads computeSize() before getMinHeight refreshes; re-grow with fresh min. + const minH = node.computeSize()[1]; + if (node.size[1] < minH) node.size[1] = minH; + drawCanvas(); + _resizing = false; + }); + + // Optional reference image as the canvas background (matches ImageTransformKJ). + function loadBg(src) { + const img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { + node._bgImg = img; + const r16 = (v) => Math.max(16, Math.round(v / 16) * 16); // model needs multiples of 16 + if (wWidget) wWidget.value = r16(img.naturalWidth); // match canvas aspect to the image + if (hWidget) hWidget.value = r16(img.naturalHeight); + syncCanvasToDims(); drawCanvas(); fitNode(); updateGrabBtn(); + }; + img.src = src; + } + watchImageInputs(node, "image", (sources) => { + if (!sources.length) { if (!node._bgManual) { node._bgImg = null; drawCanvas(); updateGrabBtn(); } return; } + node._bgManual = false; // a connected image takes over + const s = sources[0]; + if (s.isVideo && s.videoEl) captureVideoFrame(s.videoEl, (cv) => loadBg(cv.toDataURL("image/webp", 0.9))); + else if (s.url) loadBg(s.url); + }); + // "Grab BG" button: use the last generated image as the background, or clear it. + node._grabResultBg = () => { + if (!lastResultImage) { alert("No sampling result yet — run a generation first."); return; } + node._bgManual = true; + loadBg(resultViewUrl(lastResultImage)); + }; + node._clearBg = () => { + node._bgManual = false; node._bgImg = null; + if (node._liveBmp?.close) { try { node._liveBmp.close(); } catch (e) {} node._liveBmp = null; } + drawCanvas(); updateGrabBtn(); + }; + // Feed a live sampling-preview frame as the background (no width/height change). + node._ideoSetLiveBg = (bmp) => { + if (node._liveBmp?.close && node._liveBmp !== bmp) { try { node._liveBmp.close(); } catch (e) {} } + node._liveBmp = bmp; node._bgImg = bmp; node._bgManual = true; + drawCanvas(); updateGrabBtn(); + }; + // After generation, replace the live preview with the full-res final result. + node._ideoGrabFinal = () => { + if (!lastResultImage) return; + if (node._liveBmp?.close) { try { node._liveBmp.close(); } catch (e) {} node._liveBmp = null; } + node._bgManual = true; + loadBg(resultViewUrl(lastResultImage)); + }; + + // Active-box highlight only while the editor is focused or the node is selected. + wrap.addEventListener("focusin", () => { if (!node._focused) { node._focused = true; drawCanvas(); } }); + wrap.addEventListener("focusout", (e) => { + if (!wrap.contains(e.relatedTarget)) { node._focused = false; drawCanvas(); } + }); + chainCallback(node, "onSelected", function () { node._selected = true; drawCanvas(); }); + chainCallback(node, "onDeselected", function () { node._selected = false; finishPlacing(); closeLayersMenu(); drawCanvas(); }); + + chainCallback(node, "onRemoved", function () { + livePreviewNodes.delete(node); + closeInlineEditor(); + closeLayersMenu(); + for (const ro of node._areaObservers) ro.disconnect(); + node._areaObservers = []; + }); + + // ── restore on load ── + function _parseBoxes(s) { + try { + const p = JSON.parse(s); + if (Array.isArray(p) && p.some((b) => b && typeof b.x === "number" && typeof b.w === "number")) return p; + } catch (e) {} + return null; + } + // Persist editor data by name (robust to widget-order changes across versions). + chainCallback(node, "onSerialize", function (o) { + if (o) o.zit_regions = { boxes: node._boxes }; + }); + chainCallback(node, "onConfigure", function (o) { + const raw = o && Array.isArray(o.widgets_values) ? o.widgets_values : []; + // Recover regions: name-keyed blob → named widget → raw saved values (survives + // any widget reorder/remap across versions) → live widgets. + let boxes = (o && o.zit_regions && Array.isArray(o.zit_regions.boxes)) ? o.zit_regions.boxes : _parseBoxes(elementsWidget?.value || ""); + if (!boxes) { for (const v of raw) { const b = _parseBoxes(v); if (b) { boxes = b; break; } } } + if (!boxes) { for (const w of node.widgets || []) { const b = _parseBoxes(w?.value); if (b) { boxes = b; break; } } } + if (boxes) { + node._boxes = boxes.filter((b) => b && typeof b.x === "number"); + node._activeIdx = node._boxes.length ? 0 : -1; + } + const isPal = (p) => Array.isArray(p) && p.length && p.every((c) => typeof c === "string" && c[0] === "#"); + let pal = (o && o.ideo && isPal(o.ideo.palette)) ? o.ideo.palette : null; + if (!pal) { try { const p = JSON.parse(stylePaletteWidget?.value || ""); if (isPal(p)) pal = p; } catch (e) {} } + if (!pal) { for (const v of raw) { try { const p = JSON.parse(v); if (isPal(p)) { pal = p; break; } } catch (e) {} } } + if (pal) node._stylePalette = pal.slice(); + hideDataWidgets(); + serialize(); // realign widget values for Python + future saves + if (bgBrightnessWidget) bgSlider.value = bgBrightnessWidget.value; + syncCanvasToDims(); + rebuildStylePalette(); + renderPanel(); + drawCanvas(); + updateTokens(); + requestAnimationFrame(fitNode); + }); + + // initial layout (deferred so size/last_y are settled) + setTimeout(() => { + hideDataWidgets(); + if (node.size[0] < 380) node.setSize([380, node.size[1]]); + syncCanvasToDims(); + rebuildStylePalette(); + renderPanel(); + drawCanvas(); + updateTokens(); + fitNode(); + }, 0); + }); + }, +}); + + + + + + + + + +