diff --git a/Rayko_Intermediate_Spline_Mask.py b/Rayko_Intermediate_Spline_Mask.py index e7880bc..70b698e 100644 --- a/Rayko_Intermediate_Spline_Mask.py +++ b/Rayko_Intermediate_Spline_Mask.py @@ -1,18 +1,3 @@ -# SPDX-License-Identifier: Apache-2.0 -# Copyright 2025-2026 Raykosan (RaykoStudio) -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - import os import time import json @@ -30,6 +15,7 @@ except ImportError: print("\033[91m[InSPLINE ] Warning: Server modules not available.\033[0m") PENDING_DECISIONS = {} +_SAVED_MASK_PRESETS = {} class RaykoIntermediateSplineMask: @classmethod @@ -49,10 +35,6 @@ class RaykoIntermediateSplineMask: } return inputs - # 🔹 ДОБАВЛЕНО: Принудительный запуск ноды при каждом Queue - # ComfyUI кэширует выводы, если входы не меняются. - # Для интерактивных нод с паузой это ломает логику. - # Возврат time.time() гарантирует, что кэш всегда будет считаться "устаревшим". @classmethod def IS_CHANGED(cls, **kwargs): return time.time() @@ -66,66 +48,99 @@ class RaykoIntermediateSplineMask: def create_mask(self, image, coordinates="[]", unique_id=None, prompt_id=None): unique_id = str(unique_id) if unique_id else "unknown" + # Проверка batch mode + saved_preset = _SAVED_MASK_PRESETS.get(unique_id, {}) + batch_mode_active = saved_preset.get("active", False) + if SERVER_AVAILABLE and unique_id: - if unique_id not in PENDING_DECISIONS: - PENDING_DECISIONS[unique_id] = { - "status": "pending", - "coordinates": coordinates - } + # Если batch активен и есть сохраненные координаты - используем их без паузы + if batch_mode_active and saved_preset.get("coordinates"): + coordinates = saved_preset["coordinates"] + print(f"[InSPLINE ] Batch mode: using saved mask for node {unique_id}") + + # ← ДОБАВЛЕНО: Сохраняем изображение и отправляем событие для обновления UI + if prompt_id is None: + import uuid + prompt_id = str(uuid.uuid4()) + + try: + img_tensor = image[0] + i = 255. * img_tensor.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename = f"inspline_{unique_id}_{prompt_id}.png" + subfolder = "inspline" + full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder) + os.makedirs(full_output_folder, exist_ok=True) + img.save(os.path.join(full_output_folder, filename)) + + PromptServer.instance.send_sync("rayko.inspline.show", { + "node_id": unique_id, + "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}" + }) + except Exception as e: + print(f"[InSPLINE 🦊] Error saving image in batch mode: {e}") else: - PENDING_DECISIONS[unique_id]["coordinates"] = coordinates - - if prompt_id is None: - import uuid - prompt_id = str(uuid.uuid4()) + # Обычный режим с паузой + if unique_id not in PENDING_DECISIONS: + PENDING_DECISIONS[unique_id] = { + "status": "pending", + "coordinates": coordinates + } + else: + PENDING_DECISIONS[unique_id]["coordinates"] = coordinates + + if prompt_id is None: + import uuid + prompt_id = str(uuid.uuid4()) - try: - img_tensor = image[0] - i = 255. * img_tensor.cpu().numpy() - img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) - - filename = f"inspline_{unique_id}_{prompt_id}.png" - subfolder = "inspline" - full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder) - os.makedirs(full_output_folder, exist_ok=True) - img.save(os.path.join(full_output_folder, filename)) + try: + img_tensor = image[0] + i = 255. * img_tensor.cpu().numpy() + img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8)) + + filename = f"inspline_{unique_id}_{prompt_id}.png" + subfolder = "inspline" + full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder) + os.makedirs(full_output_folder, exist_ok=True) + img.save(os.path.join(full_output_folder, filename)) - PromptServer.instance.send_sync("rayko.inspline.show", { - "node_id": unique_id, - "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}" - }) - except Exception as e: - print(f"[InSPLINE 🦊] Error saving image: {e}") + PromptServer.instance.send_sync("rayko.inspline.show", { + "node_id": unique_id, + "image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}" + }) + except Exception as e: + print(f"[InSPLINE 🦊] Error saving image: {e}") - while True: - state = PENDING_DECISIONS.get(unique_id, {}) - current_status = state.get("status", "pending") - current_coordinates = state.get("coordinates", coordinates) - - if current_status == "approved": - coordinates = current_coordinates - if unique_id in PENDING_DECISIONS: - del PENDING_DECISIONS[unique_id] - break - - if current_status == "cancelled": - if unique_id in PENDING_DECISIONS: - del PENDING_DECISIONS[unique_id] - break + while True: + state = PENDING_DECISIONS.get(unique_id, {}) + current_status = state.get("status", "pending") + current_coordinates = state.get("coordinates", coordinates) + + if current_status == "approved": + coordinates = current_coordinates + if unique_id in PENDING_DECISIONS: + del PENDING_DECISIONS[unique_id] + break + + if current_status == "cancelled": + if unique_id in PENDING_DECISIONS: + del PENDING_DECISIONS[unique_id] + break - if current_status == "removed": - print(f"[InSPLINE 🦊] Node {unique_id} REMOVED! Cleaning up...") - if unique_id in PENDING_DECISIONS: - del PENDING_DECISIONS[unique_id] - break - - if current_status == "rejected": - PENDING_DECISIONS[unique_id]["status"] = "pending" - coordinates = current_coordinates - - time.sleep(0.3) + if current_status == "removed": + print(f"[InSPLINE 🦊] Node {unique_id} REMOVED! Cleaning up...") + if unique_id in PENDING_DECISIONS: + del PENDING_DECISIONS[unique_id] + break + + if current_status == "rejected": + PENDING_DECISIONS[unique_id]["status"] = "pending" + coordinates = current_coordinates + + time.sleep(0.3) else: - print("[InSPLINE 🦊] Server not available, skipping pause logic.") + print("[InSPLINE ] Server not available, skipping pause logic.") if image is None or len(image) == 0: h, w = 512, 512 @@ -166,12 +181,19 @@ if SERVER_AVAILABLE: node_id = str(data.get("node_id")) decision = data.get("decision") coordinates = data.get("coordinates") + batch_mode = data.get("batch_mode", False) if node_id in PENDING_DECISIONS: if decision == "approve": PENDING_DECISIONS[node_id]["status"] = "approved" if coordinates: PENDING_DECISIONS[node_id]["coordinates"] = coordinates + # Сохраняем preset если batch_mode активен + if batch_mode and coordinates: + _SAVED_MASK_PRESETS[node_id] = { + "coordinates": coordinates, + "active": True + } elif decision == "reject": PENDING_DECISIONS[node_id]["status"] = "rejected" @@ -180,6 +202,9 @@ if SERVER_AVAILABLE: elif decision == "cancel": PENDING_DECISIONS[node_id]["status"] = "cancelled" + # Очищаем preset при cancel + if node_id in _SAVED_MASK_PRESETS: + del _SAVED_MASK_PRESETS[node_id] return web.Response(status=200, text="Decision recorded") else: @@ -206,6 +231,42 @@ if SERVER_AVAILABLE: except Exception as e: print(f"[InSPLINE 🦊] Cleanup Error: {e}") return web.Response(status=500, text=str(e)) + + @PromptServer.instance.routes.post("/rayko/inspline/batch_toggle") + async def inspline_batch_toggle(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + enabled = data.get("enabled", False) + + if node_id in _SAVED_MASK_PRESETS: + _SAVED_MASK_PRESETS[node_id]["active"] = enabled + elif enabled and node_id in PENDING_DECISIONS: + coordinates = PENDING_DECISIONS[node_id].get("coordinates") + if coordinates: + _SAVED_MASK_PRESETS[node_id] = { + "coordinates": coordinates, + "active": True + } + + return web.Response(status=200, text="OK") + except Exception as e: + print(f"[InSPLINE 🦊] Batch toggle Error: {e}") + return web.Response(status=500, text=str(e)) + + @PromptServer.instance.routes.post("/rayko/inspline/clear_preset") + async def inspline_clear_preset(request): + try: + data = await request.json() + node_id = str(data.get("node_id")) + + if node_id in _SAVED_MASK_PRESETS: + del _SAVED_MASK_PRESETS[node_id] + + return web.Response(status=200, text="OK") + except Exception as e: + print(f"[InSPLINE 🦊] Clear preset Error: {e}") + return web.Response(status=500, text=str(e)) NODE_CLASS_MAPPINGS = {"RaykoIntermediateSplineMask": RaykoIntermediateSplineMask} -NODE_DISPLAY_NAME_MAPPINGS = {"RaykoIntermediateSplineMask": "🦊 RS Intermediate Spline Mask"} \ No newline at end of file +NODE_DISPLAY_NAME_MAPPINGS = {"RaykoIntermediateSplineMask": " RS Intermediate Spline Mask"} \ No newline at end of file diff --git a/__init__.py b/__init__.py index 18b6301..ebb2068 100644 --- a/__init__.py +++ b/__init__.py @@ -29,7 +29,7 @@ IGNORED_MODULES: Set[str] = {'__init__.py'} REQUIRED_MODULES: Set[str] = set() PACKAGE_DISPLAY_NAME = "ComfyUI_RaykoStudio" -__version__ = "0.23.3" +__version__ = "0.23.4" def load_modules(): print(f"🦊 {PACKAGE_DISPLAY_NAME}: Version: {__version__}") diff --git a/package.json b/package.json index 666747f..2f0f477 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ComfyUI_RaykoStudio", - "version": "0.23.3", + "version": "0.23.4", "description": "Set of custom nodes for ComfyUI providing additional image processing capabilities", "author": "Raykosan", "license": "Apache License 2.0", diff --git a/pyproject.toml b/pyproject.toml index bcadff8..c79ddbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "ComfyUI_RaykoStudio" description = "Set of custom nodes for ComfyUI providing additional image processing capabilities" -version = "0.23.3" +version = "0.23.4" license = { file = "LICENSE" } dependencies = ["torch>=1.7.0", "numpy>=1.19.0", "Pillow>=8.0.0", "freetype-py>=2.5.1"] diff --git a/web/intermediate_spline_mask.js b/web/intermediate_spline_mask.js index 035c043..27acf6b 100644 --- a/web/intermediate_spline_mask.js +++ b/web/intermediate_spline_mask.js @@ -2,6 +2,10 @@ console.log("[InSPLINE ] intermediate_spline_mask.js LOADED!"); import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; +let queueWasRunning = false; +let batchResetTimer = null; +let allNodes = []; + app.registerExtension({ name: "RaykoIntermediateSplineMask", @@ -18,6 +22,8 @@ app.registerExtension({ node.imageReady = false; node.currentStatus = "Waiting..."; node.buttons = []; + node.batchMode = false; + node.hasPreset = false; const coordsWidget = node.widgets?.find(w => w.name === "coordinates"); if (coordsWidget) { @@ -27,15 +33,16 @@ app.registerExtension({ }; } - node.addButton = (label, color, callback) => { + node.addButton = (label, color, callback, isBatch = false) => { node.buttons.push({ label: label, color: color, callback: callback, - x: 0, y: 0, w: 0, h: 30, hover: false + x: 0, y: 0, w: 0, h: 30, hover: false, isBatch: isBatch }); }; + node.addButton("⚙️ BATCH", "#2196F3", () => node.toggleBatchMode(), true); node.addButton("✔️ ACCEPT", "#28a745", () => node.sendDecision("approve")); - node.addButton("🔴 CLEAR POINTS", "#dc3545", () => node.sendDecision("reject")); + node.addButton("🔴 CLEAR MASK", "#dc3545", () => node.sendDecision("reject")); node.addButton("❌ CANCEL", "#666666", () => node.sendDecision("cancel")); node.setSize([450, 600]); @@ -159,9 +166,9 @@ app.registerExtension({ const createOverlayCanvas = () => { if (_overlayCanvas) return; _overlayCanvas = document.createElement("canvas"); - _overlayCanvas.className = "rayko-inspline-overlay"; // <-- Добавлен класс для надёжного поиска + _overlayCanvas.className = "rayko-inspline-overlay"; _overlayCanvas.style.cssText = ` - position: fixed !important; z-index: 900 !important; // <-- Изменено на 900 + position: fixed !important; z-index: 900 !important; pointer-events: auto !important; cursor: crosshair !important; background: transparent !important; touch-action: none; border: 1px dashed #00FF00 !important; box-sizing: border-box !important; @@ -297,22 +304,31 @@ app.registerExtension({ const btnH = 28; const btnY = h - 45; - const btnW = (w - 50) / 3; + const btnW = (w - 60) / 4 + 3; this.buttons[0].x = 15; this.buttons[0].y = btnY; this.buttons[0].w = btnW; this.buttons[0].h = btnH; this.buttons[1].x = 20 + btnW; this.buttons[1].y = btnY; this.buttons[1].w = btnW; this.buttons[1].h = btnH; this.buttons[2].x = 25 + (btnW * 2); this.buttons[2].y = btnY; this.buttons[2].w = btnW; this.buttons[2].h = btnH; + this.buttons[3].x = 30 + (btnW * 3); this.buttons[3].y = btnY; this.buttons[3].w = btnW; this.buttons[3].h = btnH; for (let btn of this.buttons) { + let btnColor = btn.color; + if (btn.isBatch && node.batchMode) { + btnColor = "#ff9800"; + btn.label = "️⚙️ BATCH ON"; + } else if (btn.isBatch) { + btn.label = "️⚙️ BATCH"; + } + ctx.fillStyle = btn.hover ? "#444" : "#2a2a2a"; ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(btn.x, btn.y, btn.w, btn.h, 6); else ctx.rect(btn.x, btn.y, btn.w, btn.h); ctx.fill(); ctx.lineWidth = 1; - ctx.strokeStyle = btn.color; + ctx.strokeStyle = btnColor; ctx.stroke(); - ctx.fillStyle = btn.color; + ctx.fillStyle = btnColor; ctx.font = "bold 11px Arial"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; @@ -325,6 +341,7 @@ app.registerExtension({ const onResize = node.onResize; node.onResize = function(size) { if (onResize) onResize.apply(this, arguments); + if (size[0] < 450) size[0] = 450; _lastRect = null; }; @@ -354,6 +371,27 @@ app.registerExtension({ } }; + node.toggleBatchMode = async function() { + node.batchMode = !node.batchMode; + if (node.batchMode) { + node.hasPreset = false; + } + this.setDirtyCanvas(true, true); + + try { + await fetch("/rayko/inspline/batch_toggle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + node_id: this.id.toString(), + enabled: node.batchMode + }) + }); + } catch(e) { + console.error("[InSPLINE ] Batch toggle failed:", e); + } + }; + node.sendDecision = async function(decision) { const currentCoords = node.properties?.spline_coords || "[]"; this.currentStatus = `Sending ${decision}...`; @@ -363,6 +401,16 @@ app.registerExtension({ try { await api.interrupt(); } catch (e) { console.error("Interrupt failed:", e); } + + node.batchMode = false; + node.hasPreset = false; + try { + await fetch("/rayko/inspline/clear_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: this.id.toString() }) + }); + } catch(e) {} } if (decision === "reject") { @@ -377,15 +425,21 @@ app.registerExtension({ body: JSON.stringify({ node_id: this.id.toString(), decision: decision, - coordinates: currentCoords + coordinates: currentCoords, + batch_mode: node.batchMode }) }); if (resp.ok) { if (decision === "approve") { - this.currentStatus = "✅ Approved! Processing..."; + if (node.batchMode) { + node.hasPreset = true; + // batchMode НЕ сбрасываем - кнопка остаётся оранжевой до конца очереди + } else { + this.currentStatus = "✅ Approved! Processing..."; + } } else if (decision === "reject") { - this.currentStatus = "🔄 Points cleared. Draw & Approve!"; + this.currentStatus = "🔄 Mask cleared. Draw & Approve!"; } else { this.currentStatus = "❌ Cancelled."; } @@ -407,7 +461,7 @@ app.registerExtension({ _overlayCanvas = null; } - fetch("/rayko/inspline/cleanup", { + fetch("/rayko/inspline/clear_preset", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -429,6 +483,8 @@ app.registerExtension({ } }; + allNodes.push({node}); + } catch (error) { console.error("[InSPLINE ] Critical Error:", error); } @@ -443,7 +499,6 @@ app.registerExtension({ node.image.src = image_url + "&t=" + Date.now(); node.image.onload = () => { node.imageReady = true; - // ИСПРАВЛЕНО: поиск по классу вместо жёсткой привязки к z-index const overlay = document.querySelector('.rayko-inspline-overlay'); if (overlay) { overlay.style.display = "block"; @@ -455,10 +510,38 @@ app.registerExtension({ node.setDirtyCanvas(true, true); }; if (node.size[1] < 600) node.setSize([node.size[0], 600]); - node.currentStatus = "🎨 Draw mask, then APPROVE!"; + node.currentStatus = " Draw mask, then APPROVE!"; app.canvas.centerOnNode(node); node.setDirtyCanvas(true, true); } }); + + api.addEventListener("status", (e) => { + const remaining = e.detail?.exec_info?.queue_remaining; + if (remaining > 0) { + queueWasRunning = true; + if (batchResetTimer) { clearTimeout(batchResetTimer); batchResetTimer = null; } + } else if (remaining === 0 && queueWasRunning) { + queueWasRunning = false; + if (!batchResetTimer) { + batchResetTimer = setTimeout(() => { + allNodes.forEach(({node}) => { + if (node && (node.batchMode || node.hasPreset)) { + node.batchMode = false; + node.hasPreset = false; + node.setDirtyCanvas(true, true); + + fetch("/rayko/inspline/clear_preset", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ node_id: String(node.id) }) + }).catch(err => console.error("Failed to clear preset:", err)); + } + }); + batchResetTimer = null; + }, 2000); + } + } + }); } }); \ No newline at end of file