From 2d99abb8d1028e7ba41c4378655751883415c1d8 Mon Sep 17 00:00:00 2001 From: Raykosan <71210053+Raykosan@users.noreply.github.com> Date: Fri, 20 Mar 2026 18:57:40 +0300 Subject: [PATCH] Adding the RS Spline Mask node --- Rayko_ Spline_Mask.py | 78 +++++++ Rayko_Image_Text .py | 2 +- Rayko_Models_Loader.py | 2 +- Rayko_Saturation.py | 2 +- Rayko_Save_Image.py | 2 +- Rayko_Save_Image_Pair.py | 2 +- web/spline_mask.js | 427 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 510 insertions(+), 5 deletions(-) create mode 100644 Rayko_ Spline_Mask.py create mode 100644 web/spline_mask.js diff --git a/Rayko_ Spline_Mask.py b/Rayko_ Spline_Mask.py new file mode 100644 index 0000000..4715236 --- /dev/null +++ b/Rayko_ Spline_Mask.py @@ -0,0 +1,78 @@ +import os +import torch +import numpy as np +import json +from PIL import Image, ImageDraw +import folder_paths + +print("🦊 RS Spline Mask LOADED") + +class RaykoSplineMask: + @classmethod + def INPUT_TYPES(cls): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) + if os.path.isfile(os.path.join(input_dir, f)) + and f.lower().endswith(('.png', '.jpg', '.jpeg', '.webp'))] + + if not files: + files = ["no_images_found"] + + return { + "required": { + "image": (sorted(files), {"image_upload": True}), + }, + "optional": { + "coordinates": ("STRING", {"multiline": False, "default": "[]"}), + } + } + + RETURN_TYPES = ("IMAGE", "MASK") + RETURN_NAMES = ("image", "mask") + FUNCTION = "create_mask" + CATEGORY = "🦊 RaykoStudio" + + def create_mask(self, image, coordinates="[]"): + print(f"[SPLINE] Received coordinates: {coordinates}") + print(f"[SPLINE] Type: {type(coordinates)}") + + if not image or image == "no_images_found": + h, w = 512, 512 + return (torch.zeros((1, h, w, 3)), torch.zeros((1, h, w))) + + image_path = folder_paths.get_annotated_filepath(image) + img = Image.open(image_path).convert("RGB") + w, h = img.size + + img_np = np.array(img).astype(np.float32) / 255.0 + img_tensor = torch.from_numpy(img_np).unsqueeze(0) + + mask_np = np.zeros((h, w), dtype=np.float32) + + try: + coords = json.loads(coordinates.replace("'", '"')) + print(f"[SPLINE] Parsed {len(coords)} points") + + if isinstance(coords, list) and len(coords) >= 3: + pts = [(max(0, min(int(float(p['x'])), w-1)), + max(0, min(int(float(p['y'])), h-1))) for p in coords] + + pil_mask = Image.new('L', (w, h), 0) + draw = ImageDraw.Draw(pil_mask) + draw.polygon(pts, fill=255) + mask_np = np.array(pil_mask).astype(np.float32) / 255.0 + + print(f"[SPLINE] ✅ Mask created with {len(pts)} points") + else: + print(f"[SPLINE] ⚠️ Less than 3 points") + except Exception as e: + print(f"[SPLINE] ❌ Error: {e}") + import traceback + traceback.print_exc() + + mask_tensor = torch.from_numpy(mask_np).unsqueeze(0) + + return (img_tensor, mask_tensor) + +NODE_CLASS_MAPPINGS = {"RaykoSplineMask": RaykoSplineMask} +NODE_DISPLAY_NAME_MAPPINGS = {"RaykoSplineMask": "🦊 RS Spline Mask"} \ No newline at end of file diff --git a/Rayko_Image_Text .py b/Rayko_Image_Text .py index fc7ed72..ea6aa4a 100644 --- a/Rayko_Image_Text .py +++ b/Rayko_Image_Text .py @@ -31,7 +31,7 @@ class LoadImageWithText: RETURN_TYPES = ("IMAGE", "STRING") RETURN_NAMES = ("image", "text") FUNCTION = "process" - CATEGORY = "🦊 RaykoStudio/Image" + CATEGORY = "🦊 RaykoStudio" OUTPUT_NODE = True def process(self, mode, image, text_input="", filename_prefix="ComfyUI"): diff --git a/Rayko_Models_Loader.py b/Rayko_Models_Loader.py index 2746d93..d28c6c0 100644 --- a/Rayko_Models_Loader.py +++ b/Rayko_Models_Loader.py @@ -37,7 +37,7 @@ class RaykoModelsLoader: RETURN_TYPES = ("MODEL", "CLIP", "VAE") RETURN_NAMES = ("MODEL", "CLIP", "VAE") FUNCTION = "load_models" - CATEGORY = "🦊 RaykoStudio/Loaders" + CATEGORY = "🦊 RaykoStudio" DESCRIPTION = "The node combines the loaders of the model, clip, vae and lore." @classmethod diff --git a/Rayko_Saturation.py b/Rayko_Saturation.py index b928c90..0a5ef35 100644 --- a/Rayko_Saturation.py +++ b/Rayko_Saturation.py @@ -22,7 +22,7 @@ class RS_SaturationSwitch: RETURN_TYPES = ("IMAGE",) FUNCTION = "apply_saturation" - CATEGORY = "🦊 RaykoStudio/Image" + CATEGORY = "🦊 RaykoStudio" DESCRIPTION = "Artifact-free saturation control with proper tensor handling" def apply_saturation(self, image, intensity): diff --git a/Rayko_Save_Image.py b/Rayko_Save_Image.py index 286af61..dd9825e 100644 --- a/Rayko_Save_Image.py +++ b/Rayko_Save_Image.py @@ -109,7 +109,7 @@ class RSSaveImage: RETURN_TYPES = () FUNCTION = "save_images" OUTPUT_NODE = True - CATEGORY = "🦊 RaykoStudio/Image" + CATEGORY = "🦊 RaykoStudio" def save_images(self, images, filename_prefix="RS_SaveImage/img", text="", footer_height=100, font_name=None, font_size=50, diff --git a/Rayko_Save_Image_Pair.py b/Rayko_Save_Image_Pair.py index 0b7a417..b403087 100644 --- a/Rayko_Save_Image_Pair.py +++ b/Rayko_Save_Image_Pair.py @@ -113,7 +113,7 @@ class SaveImagePair: RETURN_TYPES = () FUNCTION = "save_images_pair" OUTPUT_NODE = True - CATEGORY = "🦊 RaykoStudio/Image" + CATEGORY = "🦊 RaykoStudio" def save_images_pair(self, reference, final, filename_prefix="ThumbnailPair/img", concat_type="horizontal", text1="", text2="", footer_height=100, font_name=None, font_size=50, diff --git a/web/spline_mask.js b/web/spline_mask.js new file mode 100644 index 0000000..2ff20dd --- /dev/null +++ b/web/spline_mask.js @@ -0,0 +1,427 @@ +console.log("[SPLINE] spline_mask.js LOADED!"); +import { app } from "../../scripts/app.js"; + +app.registerExtension({ + name: "RaykoSplineMask", + + async beforeRegisterNodeDef(nodeType, nodeData, app) { + if (nodeData.name !== "RaykoSplineMask") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + nodeType.prototype.onNodeCreated = function() { + try { + onNodeCreated?.apply(this, arguments); + const node = this; + + console.log("[SPLINE] Node created:", node.id); + + const imageWidget = node.widgets?.find(w => w.name === "image"); + const coordsWidget = node.widgets?.find(w => w.name === "coordinates"); + + if (coordsWidget) { + coordsWidget.hidden = true; + coordsWidget.serializeValue = () => { + return node.properties?.spline_coords || "[]"; + }; + } + + const clearButton = node.addWidget("button", "❌ Clear Points", "clear", () => { + _points = []; + updateCoords(); + }); + + setTimeout(() => { + if (node.widgets) { + const idx = node.widgets.indexOf(clearButton); + if (idx !== -1) { + node.widgets.splice(idx, 1); + node.widgets.unshift(clearButton); + node.setDirtyCanvas(true, true); + } + } + }, 100); + + let _points = []; + let _imagePreview = null; + let _imageLoaded = false; + let _overlayCanvas = null; + let _syncRunning = false; + let _lastRect = null; + + if (node.properties?.spline_coords && node.properties.spline_coords !== "[]") { + try { + const p = JSON.parse(node.properties.spline_coords); + if (Array.isArray(p)) _points = p; + } catch (e) {} + } + + const updateCoords = () => { + const jsonStr = JSON.stringify(_points); + node.properties = node.properties || {}; + node.properties.spline_coords = jsonStr; + if (coordsWidget) { + coordsWidget.value = jsonStr; + } + drawOverlay(); + }; + + const loadImage = async (filename) => { + const cleanFilename = filename.startsWith('._') ? filename.substring(2) : filename; + const url = `/api/view?filename=${encodeURIComponent(cleanFilename)}&type=input`; + + try { + const resp = await fetch(url); + if (resp.ok) { + const blob = await resp.blob(); + const img = new Image(); + img.src = URL.createObjectURL(blob); + + await new Promise((resolve, reject) => { + img.onload = () => { + if (_imagePreview?.src) URL.revokeObjectURL(_imagePreview.src); + _imagePreview = img; + _imageLoaded = true; + + const targetHeight = Math.max(400, img.height / 3 + 150); + node.setSize([Math.max(400, img.width / 3), targetHeight]); + + setTimeout(() => { + if (!_overlayCanvas) createOverlayCanvas(); + }, 300); + + resolve(); + }; + img.onerror = reject; + }); + } + } catch (e) { + console.error("[SPLINE] Load error:", e); + _imageLoaded = false; + } + }; + + // 🔥 РАСЧЁТ ВЫСОТЫ ЗАГОЛОВКА + const getHeaderHeight = () => { + let lastWidgetBottom = 0; + + if (node.widgets) { + for (const w of node.widgets) { + if (w.hidden) continue; + + let widgetH = Math.max(w.height || 20, 28); + if (w.type === "combo" || w.name === "image") { + widgetH = Math.max(widgetH, 32); + } + + const widgetBottom = w.y + widgetH; + if (widgetBottom > lastWidgetBottom) { + lastWidgetBottom = widgetBottom; + } + } + } + + return lastWidgetBottom - 32; + }; + + // 🔥 РАСЧЁТ КООРДИНАТ ОВЕРЛЕЯ + const calculateImageRect = () => { + if (!_imagePreview || !app.canvas) return null; + + const ds = app.canvas.ds; + const canvasEl = app.canvas.canvas; + const scale = ds.scale; + + const graphX = node.pos[0]; + const graphY = node.pos[1]; + + const canvasRect = canvasEl.getBoundingClientRect(); + + // 🔥 ПРАВИЛЬНАЯ ФОРМУЛА: (graph + offset) * scale + const nodeScreenX = canvasRect.left + ((graphX + ds.offset[0]) * scale); + const nodeScreenY = canvasRect.top + ((graphY + ds.offset[1]) * scale); + + const headerHeightGraph = getHeaderHeight(); + const scaledHeaderHeight = headerHeightGraph * scale; + + const nodePadding = 0 * scale; + + const nodeWidth = node.size[0] * scale; + const nodeHeight = node.size[1] * scale; + + const previewWidth = nodeWidth - (nodePadding * 2); + const previewHeight = nodeHeight - scaledHeaderHeight - (nodePadding * 2); + + const imgScale = Math.min( + previewWidth / _imagePreview.width, + previewHeight / _imagePreview.height + ); + + const drawW = _imagePreview.width * imgScale; + const drawH = _imagePreview.height * imgScale; + + const drawX = nodeScreenX + nodePadding + (previewWidth - drawW) / 2; + const drawY = nodeScreenY + scaledHeaderHeight + nodePadding + (previewHeight - drawH) / 2; + + // 🔥 КОРРЕКЦИЯ РАЗМЕРОВ ОВЕРЛЕЯ + const overlayExpandX = 2 * scale; + const overlayExpandTop = 2 * scale; + const overlayShrinkBottom = 4 * scale; + + const correctedDrawX = drawX - overlayExpandX; + const correctedDrawY = drawY - overlayExpandTop; + const correctedDrawW = drawW + (overlayExpandX * 2); + const correctedDrawH = drawH + overlayExpandTop - overlayShrinkBottom; + + return { + left: correctedDrawX, + top: correctedDrawY, + width: correctedDrawW, + height: correctedDrawH, + scale: imgScale + }; + }; + + // 🔥 НЕПРЕРЫВНЫЙ ЦИКЛ СИНХРОНИЗАЦИИ (60 FPS) + const startSyncLoop = () => { + if (_syncRunning) return; + _syncRunning = true; + + const syncLoop = () => { + if (!_syncRunning) return; + + syncPosition(); + + if (_overlayCanvas && _imageLoaded) { + requestAnimationFrame(syncLoop); + } else { + _syncRunning = false; + } + }; + + requestAnimationFrame(syncLoop); + console.log("[SPLINE] Continuous sync loop started (60 FPS)"); + }; + + const syncPosition = () => { + if (!_overlayCanvas || !_imageLoaded) return; + + const imgRect = calculateImageRect(); + if (!imgRect) return; + + const hasChanged = !_lastRect || + Math.abs(_lastRect.left - imgRect.left) > 0.1 || + Math.abs(_lastRect.top - imgRect.top) > 0.1 || + Math.abs(_lastRect.width - imgRect.width) > 0.1 || + Math.abs(_lastRect.height - imgRect.height) > 0.1; + + if (hasChanged) { + _lastRect = { ...imgRect }; + + _overlayCanvas.style.left = `${imgRect.left}px`; + _overlayCanvas.style.top = `${imgRect.top}px`; + _overlayCanvas.style.width = `${imgRect.width}px`; + _overlayCanvas.style.height = `${imgRect.height}px`; + + _overlayCanvas.dataset.scale = imgRect.scale; + + drawOverlay(); + } + }; + + const createOverlayCanvas = () => { + if (_overlayCanvas) return; + + _overlayCanvas = document.createElement("canvas"); + // 🔥 ЧИСТЫЙ СТИЛЬ: прозрачный фон, зелёная рамка + _overlayCanvas.style.cssText = ` + position: fixed !important; + z-index: 1000 !important; + pointer-events: auto !important; + cursor: crosshair !important; + background: transparent !important; + touch-action: none; + border: 3px solid #00FF00 !important; + box-sizing: border-box !important; + `; + + document.body.appendChild(_overlayCanvas); + + _overlayCanvas.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + + const rect = _overlayCanvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const scale = parseFloat(_overlayCanvas.dataset.scale || "1"); + + const imgX = x / scale; + const imgY = y / scale; + + if (e.button === 2 || e.ctrlKey) { + let removed = false; + for (let i = _points.length - 1; i >= 0; i--) { + const dist = Math.hypot(_points[i].x - imgX, _points[i].y - imgY); + if (dist < 15 / scale) { + _points.splice(i, 1); + removed = true; + break; + } + } + if (removed) updateCoords(); + } else if (e.button === 0) { + if (_points.length >= 3) { + const distToFirst = Math.hypot(_points[0].x - imgX, _points[0].y - imgY); + if (distToFirst < 20 / scale) { + updateCoords(); + return; + } + } + _points.push({ x: imgX, y: imgY }); + updateCoords(); + } + }); + + startSyncLoop(); + }; + + // 🔥 ОТРИСОВКА ПРЕВЬЮ + node.onDrawForeground = function(ctx) { + const headerHeightGraph = getHeaderHeight(); + + const nodePadding = 0; + const canvasCorrectionX = 4; + const canvasCorrectionY = 12; + + const previewWidth = node.size[0] - (nodePadding * 2) - (canvasCorrectionX * 2); + const previewHeight = node.size[1] - headerHeightGraph - (nodePadding * 2) - canvasCorrectionY; + + if (_imageLoaded && _imagePreview) { + ctx.fillStyle = "#1a1a1a"; + ctx.fillRect(nodePadding - 2 + canvasCorrectionX, headerHeightGraph - 2, + previewWidth + 4, previewHeight + 4); + + ctx.strokeStyle = "#444"; + ctx.strokeRect(nodePadding - 2 + canvasCorrectionX, headerHeightGraph - 2, + previewWidth + 4, previewHeight + 4); + + const scale = Math.min( + previewWidth / _imagePreview.width, + previewHeight / _imagePreview.height + ); + const drawW = _imagePreview.width * scale; + const drawH = _imagePreview.height * scale; + + const drawX = nodePadding + canvasCorrectionX + (previewWidth - drawW) / 2; + const drawY = headerHeightGraph + nodePadding + (previewHeight - drawH) / 2; + + ctx.drawImage(_imagePreview, drawX, drawY, drawW, drawH); + + } else { + ctx.fillStyle = "#222"; + ctx.fillRect(nodePadding + canvasCorrectionX, headerHeightGraph, previewWidth, previewHeight); + ctx.fillStyle = "#666"; + ctx.font = "14px Arial"; + ctx.textAlign = "center"; + ctx.fillText("Select image", + nodePadding + previewWidth / 2 + canvasCorrectionX, + headerHeightGraph + previewHeight / 2); + } + }; + + const drawOverlay = () => { + if (!_overlayCanvas || !_imagePreview) return; + + const width = parseFloat(_overlayCanvas.style.width || "0"); + const height = parseFloat(_overlayCanvas.style.height || "0"); + + if (width <= 0 || height <= 0) return; + + const dpr = window.devicePixelRatio || 1; + _overlayCanvas.width = width * dpr; + _overlayCanvas.height = height * dpr; + + const ctx = _overlayCanvas.getContext("2d"); + ctx.scale(dpr, dpr); + ctx.clearRect(0, 0, width, height); + + const scale = parseFloat(_overlayCanvas.dataset.scale || "1"); + + if (_points.length >= 1) { + ctx.beginPath(); + const startX = _points[0].x * scale; + const startY = _points[0].y * scale; + ctx.moveTo(startX, startY); + + for (let i = 1; i < _points.length; i++) { + ctx.lineTo(_points[i].x * scale, _points[i].y * scale); + } + + if (_points.length >= 3) { + ctx.closePath(); + ctx.fillStyle = "rgba(0, 255, 0, 0.3)"; + ctx.fill(); + } + + ctx.strokeStyle = "#0f0"; + ctx.lineWidth = 2; + ctx.stroke(); + + for (const p of _points) { + ctx.beginPath(); + ctx.arc(p.x * scale, p.y * scale, 4, 0, Math.PI * 2); + ctx.fillStyle = "#f00"; + ctx.fill(); + ctx.strokeStyle = "#fff"; + ctx.lineWidth = 1; + ctx.stroke(); + } + } + }; + + if (imageWidget) { + const origCallback = imageWidget.callback; + imageWidget.callback = async function(value) { + _points = []; + updateCoords(); + _imageLoaded = false; + _imagePreview = null; + + if (_overlayCanvas) { + _overlayCanvas.remove(); + _overlayCanvas = null; + _lastRect = null; + _syncRunning = false; + } + + if (value && value !== "no_images_found") { + await loadImage(value); + } + + if (origCallback) origCallback.apply(this, arguments); + }; + + if (imageWidget.value && imageWidget.value !== "no_images_found") { + setTimeout(() => loadImage(imageWidget.value), 100); + } + } + + node.setSize([400, 500]); + + node.onRemoved = function() { + _syncRunning = false; + if (_overlayCanvas) { + _overlayCanvas.remove(); + _overlayCanvas = null; + } + if (_imagePreview?.src) URL.revokeObjectURL(_imagePreview.src); + }; + + } catch (error) { + console.error("[SPLINE] Critical Error:", error); + console.trace(error); + } + }; + } +}); \ No newline at end of file