A BATCH button has been added to the RS Intermediate Spline Mask node for batch image processing with a permanent mask

This commit is contained in:
Raykosan
2026-06-18 18:32:32 +03:00
parent 01853cac24
commit a47886a1ca
5 changed files with 234 additions and 90 deletions
+134 -73
View File
@@ -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"}
NODE_DISPLAY_NAME_MAPPINGS = {"RaykoIntermediateSplineMask": " RS Intermediate Spline Mask"}
+1 -1
View File
@@ -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__}")
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"]
+97 -14
View File
@@ -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);
}
}
});
}
});