Add new nodes and update existing nodes

New nodes: Face Detailer, Mask Combine, Multi Detect Crop, Progress Reporter
Updated: Styles Full, Styles Pipe, Video Combine, styles data

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Moser9815
2026-03-12 11:38:22 -04:00
co-authored by Claude Opus 4.6
parent ace5516265
commit cfb28ffcef
13 changed files with 2051 additions and 812 deletions
+12
View File
@@ -15,12 +15,18 @@ from .nodes.image_fallback import NODE_CLASS_MAPPINGS as IMAGE_FALLBACK_MAPPINGS
from .nodes.image_fallback import NODE_DISPLAY_NAME_MAPPINGS as IMAGE_FALLBACK_DISPLAY
from .nodes.mask_gate import NODE_CLASS_MAPPINGS as MASK_GATE_MAPPINGS
from .nodes.mask_gate import NODE_DISPLAY_NAME_MAPPINGS as MASK_GATE_DISPLAY
from .nodes.mask_combine import NODE_CLASS_MAPPINGS as MASK_COMBINE_MAPPINGS
from .nodes.mask_combine import NODE_DISPLAY_NAME_MAPPINGS as MASK_COMBINE_DISPLAY
from .nodes.latent_nodes import NODE_CLASS_MAPPINGS as LATENT_MAPPINGS
from .nodes.latent_nodes import NODE_DISPLAY_NAME_MAPPINGS as LATENT_DISPLAY
from .nodes.text_embed import NODE_CLASS_MAPPINGS as TEXT_EMBED_MAPPINGS
from .nodes.text_embed import NODE_DISPLAY_NAME_MAPPINGS as TEXT_EMBED_DISPLAY
from .nodes.save_image_with_metadata import NODE_CLASS_MAPPINGS as SAVE_IMAGE_MAPPINGS
from .nodes.save_image_with_metadata import NODE_DISPLAY_NAME_MAPPINGS as SAVE_IMAGE_DISPLAY
from .nodes.face_detailer import NODE_CLASS_MAPPINGS as FACE_DETAILER_MAPPINGS
from .nodes.face_detailer import NODE_DISPLAY_NAME_MAPPINGS as FACE_DETAILER_DISPLAY
from .nodes.multi_detect_crop import NODE_CLASS_MAPPINGS as MULTI_DETECT_MAPPINGS
from .nodes.multi_detect_crop import NODE_DISPLAY_NAME_MAPPINGS as MULTI_DETECT_DISPLAY
# Combine all mappings
NODE_CLASS_MAPPINGS = {
@@ -30,9 +36,12 @@ NODE_CLASS_MAPPINGS = {
**VIDEO_MAPPINGS,
**IMAGE_FALLBACK_MAPPINGS,
**MASK_GATE_MAPPINGS,
**MASK_COMBINE_MAPPINGS,
**LATENT_MAPPINGS,
**TEXT_EMBED_MAPPINGS,
**SAVE_IMAGE_MAPPINGS,
**FACE_DETAILER_MAPPINGS,
**MULTI_DETECT_MAPPINGS,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -42,9 +51,12 @@ NODE_DISPLAY_NAME_MAPPINGS = {
**VIDEO_DISPLAY,
**IMAGE_FALLBACK_DISPLAY,
**MASK_GATE_DISPLAY,
**MASK_COMBINE_DISPLAY,
**LATENT_DISPLAY,
**TEXT_EMBED_DISPLAY,
**SAVE_IMAGE_DISPLAY,
**FACE_DETAILER_DISPLAY,
**MULTI_DETECT_DISPLAY,
}
WEB_DIRECTORY = "./js"
+329 -692
View File
File diff suppressed because it is too large Load Diff
+120
View File
@@ -0,0 +1,120 @@
/**
* RM Mask Combine
* Adds dynamic "Add Object Input" button for additional mask inputs.
*/
import { app } from "../../scripts/app.js";
console.log("[RMMaskCombine] Script loading...");
app.registerExtension({
name: "RMAutomation.MaskCombine",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== "RMMaskCombine") return;
console.log("[RMMaskCombine] Setting up node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
nodeType.prototype.onNodeCreated = function() {
const result = onNodeCreated?.apply(this, arguments);
this.objectCount = 0;
this.addObjectButton();
return result;
};
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onConfigure = function(info) {
const result = onConfigure?.apply(this, arguments);
// Count existing object_N inputs from saved state
if (info.inputs) {
let maxObject = 0;
for (const input of info.inputs) {
if (input.name && input.name.startsWith("object_")) {
const num = parseInt(input.name.replace("object_", ""));
if (num > maxObject) maxObject = num;
}
}
this.objectCount = maxObject;
}
// Ensure button exists
this.addObjectButton();
return result;
};
nodeType.prototype.addObjectButton = function() {
if (!this.widgets?.find(w => w._btnType === "add_object")) {
const node = this;
const btn = this.addWidget("button", "Add Object Input", null, () => {
node.addObjectInput();
});
btn._btnType = "add_object";
btn.serialize = false;
}
};
nodeType.prototype.addObjectInput = function() {
this.objectCount = this.objectCount || 0;
this.objectCount++;
const inputName = `object_${this.objectCount}`;
this.addInput(inputName, "MASK");
// Resize node
const newSize = this.computeSize();
this.setSize([Math.max(this.size[0], newSize[0]), newSize[1]]);
this.setDirtyCanvas(true, true);
console.log(`[RMMaskCombine] Added input: ${inputName}`);
};
nodeType.prototype.removeLastObjectInput = function() {
if (this.objectCount <= 0) return;
const inputName = `object_${this.objectCount}`;
const idx = this.inputs?.findIndex(i => i.name === inputName);
if (idx !== undefined && idx >= 0) {
this.removeInput(idx);
this.objectCount--;
const newSize = this.computeSize();
this.setSize([Math.max(this.size[0], newSize[0]), newSize[1]]);
this.setDirtyCanvas(true, true);
console.log(`[RMMaskCombine] Removed input: ${inputName}`);
}
};
nodeType.prototype.removeAllObjectInputs = function() {
while (this.objectCount > 0) {
this.removeLastObjectInput();
}
};
const getExtraMenuOptions = nodeType.prototype.getExtraMenuOptions;
nodeType.prototype.getExtraMenuOptions = function(_, options) {
getExtraMenuOptions?.apply(this, arguments);
const node = this;
options.unshift(
{
content: "Remove last object input",
callback: () => { node.removeLastObjectInput(); },
disabled: node.objectCount <= 0,
},
{
content: "Remove all object inputs",
callback: () => { node.removeAllObjectInputs(); },
disabled: node.objectCount <= 0,
}
);
};
},
});
console.log("[RMMaskCombine] Script loaded");
+610
View File
@@ -0,0 +1,610 @@
/**
* RM Multi-Detect & Crop
* Dynamic BBOX model inputs with individual thresholds.
* Pattern adapted from RM Power LoRA Loader.
*/
import { app } from "../../../scripts/app.js";
import { api } from "../../../scripts/api.js";
console.log("[RMMultiDetectCrop] Script loading...");
const NODE_TYPE = "RMMultiDetectCrop";
const DEFAULT_WIDTH = 380;
function fitString(ctx, str, maxWidth) {
let width = ctx.measureText(str).width;
if (width <= maxWidth) return str;
const ellipsis = "...";
const ellipsisWidth = ctx.measureText(ellipsis).width;
let len = str.length;
while (width > maxWidth - ellipsisWidth && len > 0) {
len--;
str = str.substring(0, len);
width = ctx.measureText(str).width;
}
return str + ellipsis;
}
// Cache for bbox model list
let bboxCache = null;
let bboxCachePromise = null;
async function getBBoxModels(force = false) {
if (bboxCache && !force) return bboxCache;
if (bboxCachePromise && !force) return bboxCachePromise;
bboxCachePromise = api.fetchApi("/object_info/RMMultiDetectCrop")
.then(response => response.json())
.then(data => {
// Fallback: fetch from UltralyticsDetectorProvider if our node isn't registered yet
// Our node uses dynamic inputs so bbox list won't be in object_info.
// Instead fetch from a known node that uses ultralytics_bbox.
return null;
})
.catch(() => null);
// Use a more reliable approach: fetch the bbox list from the embeddings/models endpoint
// or from a node that declares ultralytics_bbox models
bboxCachePromise = fetchBBoxModels();
return bboxCachePromise;
}
async function fetchBBoxModels() {
// Try multiple known nodes that expose ultralytics_bbox model lists
const candidates = [
"RMFaceDetectCrop",
"UltralyticsDetectorProvider",
];
for (const nodeName of candidates) {
try {
const response = await api.fetchApi(`/object_info/${nodeName}`);
const data = await response.json();
const nodeInfo = data?.[nodeName];
if (!nodeInfo) continue;
// Look for model_name input that contains bbox models
const required = nodeInfo?.input?.required || {};
for (const [key, value] of Object.entries(required)) {
if (key === "model_name" && Array.isArray(value?.[0])) {
bboxCache = value[0];
return bboxCache;
}
}
} catch (e) {
// Try next candidate
}
}
console.warn("[RMMultiDetectCrop] Could not fetch BBOX model list from any known node");
return [];
}
app.registerExtension({
name: "rm.MultiDetectCrop",
async beforeRegisterNodeDef(nodeType, nodeData, app) {
if (nodeData.name !== NODE_TYPE) return;
console.log("[RMMultiDetectCrop] Setting up node");
const onNodeCreated = nodeType.prototype.onNodeCreated;
const onConfigure = nodeType.prototype.onConfigure;
nodeType.prototype.onNodeCreated = function () {
const result = onNodeCreated?.apply(this, arguments);
this.bboxWidgets = [];
this.serialize_widgets = true;
if (this.size && this.size[0] < DEFAULT_WIDTH) {
this.size[0] = DEFAULT_WIDTH;
}
this.addBBoxButton();
return result;
};
nodeType.prototype.onConfigure = function (info) {
const result = onConfigure?.apply(this, arguments);
if (info.widgets_values) {
// Remove existing bbox widgets
this.widgets = this.widgets?.filter(
(w) => !w.name?.startsWith("bbox_") && w.name !== "add_bbox_btn"
) || [];
this.bboxWidgets = [];
for (const value of info.widgets_values) {
if (value && typeof value === "object" && value.model !== undefined) {
this.addBBoxWidget(value);
}
}
// Re-add button
this.addBBoxButton();
}
return result;
};
// ================================================================
// Add BBOX Button
// ================================================================
nodeType.prototype.addBBoxButton = function () {
if (this.widgets?.find((w) => w.name === "add_bbox_btn")) return;
const node = this;
const button = {
name: "add_bbox_btn",
type: "custom",
value: "Add BBOX",
serialize: false,
draw: function (ctx, nodeRef, width, posY, height) {
const margin = 15;
const midY = posY + height / 2;
ctx.save();
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.beginPath();
ctx.roundRect(margin, posY, width - margin * 2, height, height * 0.4);
ctx.fill();
ctx.stroke();
ctx.font = "11px sans-serif";
ctx.fillStyle = LiteGraph.WIDGET_TEXT_COLOR;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("Add BBOX Model", width / 2, midY);
ctx.restore();
},
mouse: function (event, pos, nodeRef) {
if (event.type === "pointerdown" && event.button === 0) {
node.showBBoxMenu();
return true;
}
return false;
},
computeSize: function () {
return [DEFAULT_WIDTH, 22];
},
};
this.widgets = this.widgets || [];
this.widgets.push(button);
};
// ================================================================
// BBOX Menu (model selection)
// ================================================================
nodeType.prototype.showBBoxMenu = async function () {
const node = this;
const models = await getBBoxModels();
if (!models || models.length === 0) {
new LiteGraph.ContextMenu(
[{ content: "(No BBOX models found)", disabled: true }],
{ event: window.event, title: "Select BBOX Model" }
);
return;
}
const menuItems = this.buildBBoxMenu(models);
new LiteGraph.ContextMenu(menuItems, {
event: window.event,
title: "Select BBOX Model",
});
};
nodeType.prototype.buildBBoxMenu = function (models, forWidget = null) {
const node = this;
// Build hierarchical menu from model paths (same pattern as lora menu)
const tree = {};
for (const model of models) {
const parts = model.split(/[\/\\]/);
let current = tree;
for (let i = 0; i < parts.length - 1; i++) {
const folder = parts[i];
if (!current[folder]) {
current[folder] = { __files: [], __folders: {} };
}
current = current[folder].__folders;
}
const filename = parts[parts.length - 1];
if (parts.length === 1) {
if (!tree.__root) tree.__root = [];
tree.__root.push({ name: filename, path: model });
} else {
let parent = tree;
for (let i = 0; i < parts.length - 2; i++) {
parent = parent[parts[i]].__folders;
}
const parentFolder = parts[parts.length - 2];
parent[parentFolder].__files.push({ name: filename, path: model });
}
}
const buildMenuFromTree = (treeNode, isRoot = false) => {
const items = [];
const folders = [];
const files = isRoot && treeNode.__root ? [...treeNode.__root] : [];
for (const [key, value] of Object.entries(treeNode)) {
if (key === "__root" || key === "__files" || key === "__folders")
continue;
folders.push({ name: key, data: value });
}
folders.sort((a, b) => a.name.localeCompare(b.name));
for (const folder of folders) {
const subItems = [];
if (folder.data.__folders) {
subItems.push(...buildMenuFromTree(folder.data.__folders));
}
if (folder.data.__files?.length > 0) {
folder.data.__files.sort((a, b) => a.name.localeCompare(b.name));
if (subItems.length > 0) subItems.push(null);
for (const file of folder.data.__files) {
const displayName = file.name.replace(/\.pt$/, "");
subItems.push({
content: displayName,
callback: forWidget
? () => {
forWidget.value.model = file.path;
node.setDirtyCanvas(true, true);
}
: () => node.addBBoxWidget({ model: file.path }),
});
}
}
items.push({
content: `\uD83D\uDCC1 ${folder.name}`,
has_submenu: true,
callback: () => {},
submenu: { options: subItems },
});
}
if (isRoot && files.length > 0) {
files.sort((a, b) => a.name.localeCompare(b.name));
if (items.length > 0) items.push(null);
for (const file of files) {
const displayName = file.name.replace(/\.pt$/, "");
items.push({
content: displayName,
callback: forWidget
? () => {
forWidget.value.model = file.path;
node.setDirtyCanvas(true, true);
}
: () => node.addBBoxWidget({ model: file.path }),
});
}
}
return items;
};
return buildMenuFromTree(tree, true);
};
// ================================================================
// BBOX Widget (each model row)
// ================================================================
nodeType.prototype.addBBoxWidget = function (config = {}) {
const defaults = {
on: true,
model: null,
threshold: 0.5,
};
const value = { ...defaults, ...config };
const index = this.bboxWidgets?.length || 0;
const widgetName = `bbox_${index}`;
const widget = {
name: widgetName,
type: "custom",
value: value,
draw: (ctx, node, width, posY, height) => {
this.drawBBoxWidget(ctx, widget, width, posY, height);
},
mouse: (event, pos, node) => {
return this.handleBBoxWidgetMouse(event, pos, widget);
},
computeSize: () => {
return [DEFAULT_WIDTH, 22];
},
serializeValue: () => {
return { ...widget.value };
},
};
// Insert before the button
const buttonIndex =
this.widgets?.findIndex((w) => w.name === "add_bbox_btn") ?? -1;
if (buttonIndex >= 0) {
this.widgets.splice(buttonIndex, 0, widget);
} else {
this.widgets = this.widgets || [];
this.widgets.push(widget);
}
this.bboxWidgets = this.bboxWidgets || [];
this.bboxWidgets.push(widget);
const newSize = this.computeSize();
newSize[0] = Math.max(newSize[0], DEFAULT_WIDTH);
this.setSize(newSize);
this.setDirtyCanvas(true, true);
return widget;
};
// ================================================================
// Draw BBOX Widget
// ================================================================
nodeType.prototype.drawBBoxWidget = function (
ctx,
widget,
width,
posY,
height
) {
const margin = 15;
const value = widget.value;
const midY = posY + height / 2;
ctx.save();
// Row background
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.strokeStyle = LiteGraph.WIDGET_OUTLINE_COLOR;
ctx.beginPath();
ctx.roundRect(margin, posY, width - margin * 2, height, height * 0.4);
ctx.fill();
ctx.stroke();
// === TOGGLE SWITCH ===
const toggleHeight = height * 0.55;
const toggleWidth = height * 1.1;
const toggleX = margin + 5;
const toggleY = midY - toggleHeight / 2;
const toggleRadius = toggleHeight * 0.5;
ctx.beginPath();
ctx.roundRect(toggleX, toggleY, toggleWidth, toggleHeight, toggleRadius);
ctx.fillStyle = value.on
? "rgba(100, 160, 100, 0.4)"
: "rgba(80, 80, 80, 0.4)";
ctx.fill();
const knobRadius = toggleHeight * 0.38;
const knobX = value.on
? toggleX + toggleWidth - knobRadius - 2
: toggleX + knobRadius + 2;
ctx.beginPath();
ctx.arc(knobX, midY, knobRadius, 0, Math.PI * 2);
ctx.fillStyle = value.on ? "#8B8" : "#888";
ctx.fill();
// === GEAR ICON ===
const gearSize = 10;
const gearX = toggleX + toggleWidth + 8;
this.drawGearIcon(
ctx,
gearX,
midY,
gearSize * 0.5,
value.on ? "#777" : "#555"
);
let posX = gearX + gearSize + 6;
// === LAYOUT ===
const threshWidth = 40;
const threshX = width - margin - threshWidth - 4;
const modelWidth = threshX - posX - 8;
// === MODEL NAME ===
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
ctx.textAlign = "left";
ctx.fillStyle = value.on ? LiteGraph.WIDGET_TEXT_COLOR : "#666";
const displayName = value.model
? value.model
.split(/[\/\\]/)
.pop()
.replace(/\.pt$/, "")
: "(select model)";
ctx.fillText(fitString(ctx, displayName, modelWidth), posX, midY);
// === THRESHOLD ===
ctx.font = "10px monospace";
ctx.textAlign = "center";
ctx.fillStyle = value.on ? LiteGraph.WIDGET_TEXT_COLOR : "#666";
ctx.fillText(value.threshold.toFixed(2), threshX + threshWidth / 2, midY);
ctx.restore();
};
// Gear icon helper (same as Power LoRA Loader)
nodeType.prototype.drawGearIcon = function (ctx, x, y, radius, color) {
ctx.save();
ctx.fillStyle = color;
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
const teeth = 6;
const innerRadius = radius * 0.5;
const outerRadius = radius;
ctx.beginPath();
for (let i = 0; i < teeth * 2; i++) {
const angle = (i * Math.PI) / teeth;
const r = i % 2 === 0 ? outerRadius : innerRadius * 1.3;
const px = x + Math.cos(angle) * r;
const py = y + Math.sin(angle) * r;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fill();
ctx.beginPath();
ctx.arc(x, y, innerRadius * 0.5, 0, Math.PI * 2);
ctx.fillStyle = LiteGraph.WIDGET_BGCOLOR;
ctx.fill();
ctx.restore();
};
// ================================================================
// Mouse Handling
// ================================================================
nodeType.prototype.handleBBoxWidgetMouse = function (
event,
pos,
widget
) {
const margin = 15;
const width = this.size[0];
const height = 22;
const toggleWidth = height * 1.1;
const gearSize = 10;
const threshWidth = 40;
const localX = pos[0];
const toggleX = margin + 5;
const toggleEnd = toggleX + toggleWidth;
const gearX = toggleEnd + 8;
const gearRadius = gearSize * 0.5;
const threshX = width - margin - threshWidth - 4;
// Right-click → context menu
if (event.type === "pointerdown" && event.button === 2) {
this.showBBoxWidgetMenu(widget, event);
return true;
}
if (event.type !== "pointerdown" || event.button !== 0) return false;
// Toggle click
if (localX >= toggleX && localX < toggleEnd) {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
return true;
}
// Gear click
if (
localX >= gearX - gearRadius - 3 &&
localX < gearX + gearRadius + 3
) {
this.showBBoxWidgetMenu(widget, event);
return true;
}
// Threshold click → prompt
if (localX >= threshX && localX < threshX + threshWidth) {
const val = prompt(
"Enter threshold (0.0 - 1.0):",
widget.value.threshold
);
if (val !== null && !isNaN(parseFloat(val))) {
widget.value.threshold = Math.max(
0,
Math.min(1, parseFloat(val))
);
this.setDirtyCanvas(true, true);
}
return true;
}
// Model name click → show model menu
if (localX >= gearX + gearRadius + 3 && localX < threshX - 4) {
this.showBBoxMenuForWidget(widget);
return true;
}
return false;
};
// ================================================================
// Context Menu
// ================================================================
nodeType.prototype.showBBoxWidgetMenu = function (widget, event) {
const node = this;
const menuItems = [
{
content: widget.value.on ? "Disable" : "Enable",
callback: () => {
widget.value.on = !widget.value.on;
this.setDirtyCanvas(true, true);
},
},
null,
{
content: "Remove",
callback: () => {
const idx = this.widgets.indexOf(widget);
if (idx >= 0) this.widgets.splice(idx, 1);
const bboxIdx = this.bboxWidgets.indexOf(widget);
if (bboxIdx >= 0) this.bboxWidgets.splice(bboxIdx, 1);
const newSize = this.computeSize();
newSize[0] = Math.max(newSize[0], DEFAULT_WIDTH);
this.setSize(newSize);
this.setDirtyCanvas(true, true);
},
},
];
new LiteGraph.ContextMenu(menuItems, {
event: event,
title: "BBOX Options",
});
};
nodeType.prototype.showBBoxMenuForWidget = async function (widget) {
const models = await getBBoxModels();
if (!models || models.length === 0) return;
const menuItems = this.buildBBoxMenu(models, widget);
new LiteGraph.ContextMenu(menuItems, {
event: window.event,
title: "Change BBOX Model",
});
};
},
});
console.log("[RMMultiDetectCrop] Script loaded successfully!");
+23 -7
View File
@@ -404,10 +404,19 @@ styleSheet.textContent = `
document.head.appendChild(styleSheet);
class RMStylesEditorModal {
constructor() {
constructor(customPath = "") {
this.styles = [];
this.selectedStyle = null;
this.overlay = null;
this.customPath = customPath;
}
_apiUrl(path) {
if (this.customPath) {
const sep = path.includes("?") ? "&" : "?";
return `${path}${sep}path=${encodeURIComponent(this.customPath)}`;
}
return path;
}
async open() {
@@ -424,7 +433,7 @@ class RMStylesEditorModal {
async loadStyles() {
try {
const response = await api.fetchApi("/api/rmautomation/styles");
const response = await api.fetchApi(this._apiUrl("/api/rmautomation/styles"));
const data = await response.json();
this.styles = data.styles || [];
} catch (e) {
@@ -435,7 +444,7 @@ class RMStylesEditorModal {
async saveStyle(style) {
try {
const response = await api.fetchApi(`/api/rmautomation/styles/${style.number}`, {
const response = await api.fetchApi(this._apiUrl(`/api/rmautomation/styles/${style.number}`), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(style),
@@ -452,7 +461,7 @@ class RMStylesEditorModal {
async deleteStyle(number) {
if (!confirm(`Delete style #${number}?`)) return;
try {
const response = await api.fetchApi(`/api/rmautomation/styles/${number}`, {
const response = await api.fetchApi(this._apiUrl(`/api/rmautomation/styles/${number}`), {
method: "DELETE",
});
if (response.ok) {
@@ -473,11 +482,15 @@ class RMStylesEditorModal {
if (e.target === this.overlay) this.close();
};
const headerLabel = this.customPath
? `RM Styles Editor - ${this.customPath.split(/[/\\]/).pop()}`
: "RM Styles Editor";
const modal = document.createElement("div");
modal.className = "rm-styles-modal";
modal.innerHTML = `
<div class="rm-styles-modal-header">
<h2>RM Styles Editor</h2>
<h2>${headerLabel}</h2>
<button class="rm-styles-modal-close">&times;</button>
</div>
<div class="rm-styles-modal-content">
@@ -756,7 +769,7 @@ class RMStylesEditorModal {
async importStyles(importedStyles, mode) {
try {
const response = await api.fetchApi("/api/rmautomation/styles/import", {
const response = await api.fetchApi(this._apiUrl("/api/rmautomation/styles/import"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -792,8 +805,11 @@ app.registerExtension({
onNodeCreated?.apply(this, arguments);
// Add "Edit Styles" button
const node = this;
const editBtn = this.addWidget("button", "Edit Styles", null, () => {
const modal = new RMStylesEditorModal();
const pathWidget = node.widgets?.find(w => w.name === "custom_json_path");
const customPath = pathWidget?.value?.trim() || "";
const modal = new RMStylesEditorModal(customPath);
modal.open();
});
};
+32 -76
View File
@@ -1,6 +1,7 @@
/**
* RM Styles Full - Frontend Widget Controller
* Handles mode switching, random/increment/decrement logic
* Handles mode switching, increment/decrement logic.
* Random mode is handled server-side (Python) with anti-repeat history.
*/
import { app } from "../../scripts/app.js";
@@ -8,69 +9,21 @@ import { api } from "../../scripts/api.js";
console.log("[RMStyles] Script loading...");
const RANDOM_HISTORY_SIZE = 30;
const RM_STYLES_NODES = ["RMStylesFull", "RMStylesFullDisplay", "RMStylesPipe"];
/**
* Generate a random integer between min and max (inclusive),
* avoiding recently used values tracked in the history array.
* Calculate the next prompt number based on mode (Increment/Decrement only)
*/
function randomInRange(min, max, history) {
if (min >= max) return min;
const range = max - min + 1;
if (history) {
const effectiveBuffer = Math.min(RANDOM_HISTORY_SIZE, range - 1);
const avoid = new Set(history.slice(-effectiveBuffer));
let result, attempts = 0;
do {
result = Math.floor(Math.random() * range) + min;
attempts++;
} while (avoid.has(result) && attempts < 100);
history.push(result);
if (history.length > RANDOM_HISTORY_SIZE) {
history.splice(0, history.length - RANDOM_HISTORY_SIZE);
}
return result;
}
return Math.floor(Math.random() * range) + min;
}
/**
* Calculate the next prompt number based on mode
*/
function calculateNextNumber(mode, current, min, max, history) {
let result;
function calculateNextNumber(mode, current, min, max) {
switch (mode) {
case "Random":
result = randomInRange(min, max, history);
break;
case "Increment":
result = current >= max ? min : current + 1;
break;
return current >= max ? min : current + 1;
case "Decrement":
result = current <= min ? max : current - 1;
break;
return current <= min ? max : current - 1;
case "Manual":
default:
result = current;
return current;
}
return result;
}
/**
* Get all widget values from a node for debugging
*/
function getWidgetValues(node) {
if (!node?.widgets) return "no widgets";
const values = {};
for (const w of node.widgets) {
values[w.name] = w.value;
}
return values;
}
/**
@@ -97,11 +50,6 @@ function setupNodeWidgets(node) {
// Store widget references on node for easy access
node._rmWidgets = { modeWidget, prevWidget, nextWidget, minWidget, maxWidget };
// History buffer to prevent random repeats
if (!node._rmHistory) {
node._rmHistory = [];
}
// Flag to prevent recursive callbacks
node._rmUpdating = false;
@@ -115,12 +63,12 @@ function setupNodeWidgets(node) {
if (node._rmUpdating) return;
node._rmUpdating = true;
// Calculate new next_prompt based on mode
if (value !== "Manual") {
// Calculate new next_prompt for Increment/Decrement preview
if (value !== "Manual" && value !== "Random") {
const current = nextWidget.value;
const min = minWidget.value;
const max = maxWidget.value;
const newValue = calculateNextNumber(value, current, min, max, node._rmHistory);
const newValue = calculateNextNumber(value, current, min, max);
nextWidget.value = newValue;
console.log(`[RMStyles] Mode changed to ${value}, next_prompt: ${newValue}`);
}
@@ -147,31 +95,26 @@ function setupNodeWidgets(node) {
}
/**
* Handle node execution complete
* Handle node execution complete (Increment/Decrement only)
* Random mode is handled by the rm_styles_executed message from Python.
*/
function handleNodeExecuted(node) {
const w = node._rmWidgets;
if (!w) return;
const mode = w.modeWidget.value;
const usedPrompt = w.nextWidget.value;
const min = w.minWidget.value;
const max = w.maxWidget.value;
// Update previous_prompt to show what was just used
// Random mode: Python handles everything via rm_styles_executed message
if (mode === "Random") return;
const usedPrompt = w.nextWidget.value;
node._rmUpdating = true;
w.prevWidget.value = usedPrompt;
// Calculate new next_prompt for non-Manual modes
// Calculate new next_prompt for Increment/Decrement
if (mode !== "Manual") {
// Add the USED prompt to history before generating new one
if (mode === "Random" && !node._rmHistory.includes(usedPrompt)) {
node._rmHistory.push(usedPrompt);
if (node._rmHistory.length > RANDOM_HISTORY_SIZE) {
node._rmHistory.splice(0, node._rmHistory.length - RANDOM_HISTORY_SIZE);
}
}
const newNext = calculateNextNumber(mode, usedPrompt, min, max, node._rmHistory);
const newNext = calculateNextNumber(mode, usedPrompt, w.minWidget.value, w.maxWidget.value);
w.nextWidget.value = newNext;
console.log(`[RMStyles] Execution complete: used=${usedPrompt}, next=${newNext}, mode=${mode}`);
}
@@ -229,6 +172,19 @@ app.registerExtension({
pendingNodes.clear();
};
// Listen for Python's random selection message
api.addEventListener("rm_styles_executed", (e) => {
const { node_id, prompt_number } = e.detail;
const node = app.graph?.getNodeById(Number(node_id));
if (!node || !node._rmWidgets) return;
node._rmUpdating = true;
node._rmWidgets.prevWidget.value = prompt_number;
node._rmUpdating = false;
console.log(`[RMStyles] Python selected prompt #${prompt_number} (node ${node_id})`);
});
api.addEventListener("execution_start", (e) => {
pendingNodes.clear();
});
+327
View File
@@ -0,0 +1,327 @@
"""
RMFaceDetectCrop and RMFaceComposite nodes.
RMFaceDetectCrop: Detects faces via YOLO, crops each face region with configurable
expansion, and scales to a target max dimension. Uses OUTPUT_IS_LIST so that each
face flows through the entire downstream pipeline independently (each gets its own
VAE encode, its own guide/conditioning, its own sampler pass). Fails silently when
no faces are found.
RMFaceComposite: Uses INPUT_IS_LIST to collect all independently processed faces,
then composites them sequentially onto the original image with Gaussian-blurred
masks for smooth blending.
"""
import torch
import numpy as np
import cv2
from PIL import Image
import folder_paths
import comfy.utils
try:
from torchvision.transforms import GaussianBlur
HAS_GAUSSIAN_BLUR = True
except ImportError:
HAS_GAUSSIAN_BLUR = False
class RMFaceDetectCrop:
"""
Detects faces in an image, crops each face region (expanded by crop_factor),
and scales to max_size. Uses OUTPUT_IS_LIST for the image output so ComfyUI
executes all downstream nodes once per face — each face gets its own
VAE encode, guide, sampler pass, and VAE decode independently.
Replaces: UltralyticsDetectorProvider + BBOX Detector (SEGS) +
Decompose (SEGS) + From SEG_ELT + From SEG_ELT crop_region +
Bounding Box + Simple Math x2 + CropByBBoxes + ImageScaleToMaxDimension
"""
@classmethod
def INPUT_TYPES(cls):
try:
bbox_list = folder_paths.get_filename_list("ultralytics_bbox")
except Exception:
bbox_list = []
if not bbox_list:
bbox_list = ["none"]
return {
"required": {
"image": ("IMAGE",),
"model_name": (bbox_list,),
"threshold": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"dilation": ("INT", {"default": 10, "min": -512, "max": 512, "step": 1}),
"crop_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
"drop_size": ("INT", {"default": 10, "min": 1, "max": 8192, "step": 1}),
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
}
}
RETURN_TYPES = ("IMAGE", "RM_FACE_DATA", "INT")
RETURN_NAMES = ("cropped_face", "face_data", "face_count")
OUTPUT_IS_LIST = (True, False, False)
FUNCTION = "detect"
CATEGORY = "RMAutomation/Face"
def detect(self, image, model_name, threshold, dilation, crop_factor, drop_size, megapixels):
from ultralytics import YOLO
img_tensor = image[0] # First image in batch: (H, W, C)
h, w = img_tensor.shape[0], img_tensor.shape[1]
# Resolve model path
model_path = folder_paths.get_full_path("ultralytics_bbox", model_name)
if model_path is None:
print(f"[RMFaceDetectCrop] Model not found: {model_name}")
return self._no_faces(h, w)
# Run YOLO detection
try:
img_np = (img_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
img_pil = Image.fromarray(img_np)
model = YOLO(model_path)
results = model(img_pil, conf=threshold)
except Exception as e:
print(f"[RMFaceDetectCrop] Detection error: {e}")
return self._no_faces(h, w)
if not results or results[0].boxes is None or len(results[0].boxes) == 0:
return self._no_faces(h, w)
bboxes = results[0].boxes.xyxy.cpu().numpy()
confidences = results[0].boxes.conf.cpu().numpy()
# Filter small detections
faces = []
for i in range(len(bboxes)):
x1, y1, x2, y2 = bboxes[i]
if (x2 - x1) < drop_size or (y2 - y1) < drop_size:
continue
faces.append((int(x1), int(y1), int(x2), int(y2), float(confidences[i])))
if not faces:
return self._no_faces(h, w)
# Crop and scale each face independently
face_images = []
infos = []
for bx1, by1, bx2, by2, conf in faces:
# Compute expanded crop region
cl, ct, cr, cb = self._crop_region(w, h, (bx1, by1, bx2, by2), crop_factor)
cw, ch = cr - cl, cb - ct
# Create rectangular bbox mask and apply dilation
mask = np.zeros((h, w), dtype=np.float32)
mask[by1:by2, bx1:bx2] = 1.0
if dilation != 0:
kern = np.ones((abs(dilation), abs(dilation)), np.uint8)
if dilation > 0:
mask = cv2.dilate(mask, kern, iterations=1)
else:
mask = cv2.erode(mask, kern, iterations=1)
# Crop mask and image to the expanded region
cropped_mask = torch.from_numpy(mask[ct:cb, cl:cr].copy())
cropped_img = img_tensor[ct:cb, cl:cr, :].clone()
# Scale to target megapixels while preserving aspect ratio
current_pixels = cw * ch
target_pixels = megapixels * 1_000_000
scale_factor = (target_pixels / current_pixels) ** 0.5
nw, nh = round(cw * scale_factor), round(ch * scale_factor)
scaled = comfy.utils.common_upscale(
cropped_img.movedim(-1, 0).unsqueeze(0), # HWC -> BCHW
nw, nh, "lanczos", "disabled"
).squeeze(0).movedim(0, -1) # BCHW -> HWC
# Each face is an individual IMAGE tensor: (1, H, W, C)
face_images.append(scaled.unsqueeze(0))
infos.append({
"crop_region": (cl, ct, cr, cb),
"crop_w": cw,
"crop_h": ch,
"cropped_mask": cropped_mask,
})
face_data = {"found": True, "faces": infos, "original_size": (h, w)}
print(f"[RMFaceDetectCrop] Detected {len(faces)} face(s)")
return (face_images, face_data, len(faces))
def _no_faces(self, h, w):
"""Return a single dummy image and empty face_data when no faces found."""
print("[RMFaceDetectCrop] No faces detected")
dummy = torch.zeros(1, 256, 256, 3)
return (
[dummy],
{"found": False, "faces": [], "original_size": (h, w)},
0,
)
@staticmethod
def _crop_region(img_w, img_h, bbox, factor):
"""Compute crop region expanded by factor, clamped to image bounds."""
x1, y1, x2, y2 = bbox
bw, bh = x2 - x1, y2 - y1
cw, ch = bw * factor, bh * factor
cx, cy = x1 + bw / 2, y1 + bh / 2
nx1 = int(cx - cw / 2)
ny1 = int(cy - ch / 2)
nx2 = int(nx1 + cw)
ny2 = int(ny1 + ch)
# Shift region into image bounds
if nx1 < 0:
nx2 -= nx1
nx1 = 0
if ny1 < 0:
ny2 -= ny1
ny1 = 0
if nx2 > img_w:
nx1 -= (nx2 - img_w)
nx2 = img_w
if ny2 > img_h:
ny1 -= (ny2 - img_h)
ny2 = img_h
return max(0, nx1), max(0, ny1), min(img_w, nx2), min(img_h, ny2)
class RMFaceComposite:
"""
Collects all independently processed faces (via INPUT_IS_LIST) and composites
them sequentially onto the original image. Face 0 is composited first, then
face 1 onto that result, and so on. Each face uses its own Gaussian-blurred
mask for smooth edge blending.
When face_data indicates no faces were found, passes the original image
through unchanged.
Replaces: Upscale Image + Gaussian Blur Mask + ImageCompositeMasked
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"original_image": ("IMAGE",),
"processed_faces": ("IMAGE",),
"face_data": ("RM_FACE_DATA",),
"blur_kernel_size": ("INT", {"default": 30, "min": 0, "max": 100, "step": 1}),
"blur_sigma": ("FLOAT", {"default": 30.0, "min": 0.1, "max": 100.0, "step": 0.1}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
INPUT_IS_LIST = True
FUNCTION = "composite"
CATEGORY = "RMAutomation/Face"
def composite(self, original_image, processed_faces, face_data, blur_kernel_size, blur_sigma):
# INPUT_IS_LIST=True: all inputs arrive as lists, unwrap single-item ones
original = original_image[0]
fd = face_data[0]
bks = blur_kernel_size[0]
bs = blur_sigma[0]
# processed_faces is a list of N tensors, each (1, H, W, C)
if not fd.get("found", False):
print("[RMFaceComposite] No faces in face_data, passing through original")
return (original,)
result = original.clone()
device = result.device
faces = fd["faces"]
for i, info in enumerate(faces):
if i >= len(processed_faces):
print(f"[RMFaceComposite] Warning: face {i} has no matching processed image, stopping")
break
# Get the processed face — single image (1, H, W, C) → (H, W, C)
face = processed_faces[i][0].to(device)
# Scale back to original crop dimensions
cw, ch = info["crop_w"], info["crop_h"]
face = comfy.utils.common_upscale(
face.movedim(-1, 0).unsqueeze(0),
cw, ch, "nearest-exact", "disabled"
).squeeze(0).movedim(0, -1)
# Blur the detection mask for smooth blending
mask = info["cropped_mask"].float().to(device)
mask = self._blur_mask(mask, bks, bs)
mask_3d = mask.unsqueeze(-1) # (crop_h, crop_w, 1)
# Alpha-composite this face onto the accumulating result
cl, ct, cr, cb = info["crop_region"]
for b in range(result.shape[0]):
region = result[b, ct:cb, cl:cr, :]
result[b, ct:cb, cl:cr, :] = face * mask_3d + region * (1.0 - mask_3d)
print(f"[RMFaceComposite] Composited {min(len(faces), len(processed_faces))} face(s)")
return (result,)
@staticmethod
def _blur_mask(mask, kernel_size, sigma):
"""Apply Gaussian blur to a 2D mask tensor for feathered edges.
After blurring the inner bbox rectangle, a border falloff ramp is
multiplied in so the mask reaches zero at the crop region edges.
This prevents hard seams when compositing back onto the original.
"""
if not HAS_GAUSSIAN_BLUR or kernel_size <= 0:
return mask
k = kernel_size * 2 + 1
h, w = mask.shape[0], mask.shape[1]
shortest = min(h, w)
if shortest <= k:
k = int(shortest / 2)
if k % 2 == 0:
k += 1
if k < 3:
return mask
m = mask.unsqueeze(0).unsqueeze(0) # (1, 1, H, W)
blurred = GaussianBlur(kernel_size=k, sigma=sigma)(m)
blurred = blurred.squeeze(0).squeeze(0)
# Border falloff: ramp from 0 at the crop edge to 1 over `margin` pixels.
# This guarantees the mask is zero at the boundary — no hard seams.
margin = max(k // 2, 1)
falloff = torch.ones_like(blurred)
if h > margin * 2 and w > margin * 2:
# Vertical ramps (top and bottom edges)
ramp_v = torch.linspace(0.0, 1.0, margin, device=blurred.device)
falloff[:margin, :] *= ramp_v.unsqueeze(1)
falloff[-margin:, :] *= ramp_v.flip(0).unsqueeze(1)
# Horizontal ramps (left and right edges)
ramp_h = torch.linspace(0.0, 1.0, margin, device=blurred.device)
falloff[:, :margin] *= ramp_h.unsqueeze(0)
falloff[:, -margin:] *= ramp_h.flip(0).unsqueeze(0)
blurred = blurred * falloff
return blurred
NODE_CLASS_MAPPINGS = {
"RMFaceDetectCrop": RMFaceDetectCrop,
"RMFaceComposite": RMFaceComposite,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMFaceDetectCrop": "RM Face Detect & Crop",
"RMFaceComposite": "RM Face Composite",
}
+193
View File
@@ -0,0 +1,193 @@
"""
RM Mask Combine - Combines body, face, and object masks into a single mask
for downstream KSampler processing.
White = process, Black = protect.
Logic:
body + face + objects → invert(body) + face - objects
body + face → invert(body) + face
face + objects → face - objects
body + objects → invert(objects) (no face = nothing to add)
body only → invert(body)
face only → face
objects only → invert(objects)
none → all-white (process everything)
"""
import torch
import torch.nn.functional as F
class FlexibleMaskInputType(dict):
"""
Allows dynamic inputs for RMMaskCombine.
Accepts body, face as static keys and object_N as dynamic keys.
"""
def __init__(self):
super().__init__({
"body": ("MASK",),
"face": ("MASK",),
})
def __getitem__(self, key):
if key in dict.keys(self):
return dict.__getitem__(self, key)
if key.startswith("object_"):
return ("MASK",)
return ("*",)
def __contains__(self, key):
if key in dict.keys(self):
return True
if key.startswith("object_"):
return True
return False
class RMMaskCombine:
"""
Combines body, face, and dynamic object masks into a single mask.
Use "Add Object Input" to add mask inputs for body parts to exclude.
White = process, Black = protect.
The result masks the background + face, excluding the body and objects.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {},
"optional": FlexibleMaskInputType(),
}
CATEGORY = "RMAutomation/Mask"
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("mask",)
FUNCTION = "combine"
@staticmethod
def _is_valid_mask(mask):
"""Check if a mask is usable (not None, not empty, no zero dims)."""
if mask is None:
return False
if mask.numel() == 0:
return False
if any(d == 0 for d in mask.shape):
return False
return True
@staticmethod
def _normalize_mask(mask):
"""Ensure mask is 3D (B, H, W) and clamped to [0, 1]."""
if mask.dim() == 2:
mask = mask.unsqueeze(0)
elif mask.dim() == 4:
mask = mask.squeeze(1)
return mask.clamp(0, 1)
@staticmethod
def _resize_mask(mask, h, w):
"""Resize mask to target dimensions using bilinear interpolation."""
if mask.shape[-2] == h and mask.shape[-1] == w:
return mask
return F.interpolate(
mask.unsqueeze(1), size=(h, w), mode="bilinear", align_corners=False
).squeeze(1)
def combine(self, body=None, face=None, **kwargs):
# Collect object masks from dynamic inputs
object_masks = []
for key in sorted(kwargs.keys()):
if key.startswith("object_") and self._is_valid_mask(kwargs[key]):
object_masks.append(self._normalize_mask(kwargs[key]))
has_body = self._is_valid_mask(body)
has_face = self._is_valid_mask(face)
has_objects = len(object_masks) > 0
# Normalize body and face
if has_body:
body = self._normalize_mask(body)
if has_face:
face = self._normalize_mask(face)
# Determine target dimensions from the first valid mask
ref = None
if has_body:
ref = body
elif has_face:
ref = face
elif has_objects:
ref = object_masks[0]
# No inputs at all → all-white
if ref is None:
print("[RMMaskCombine] No valid inputs, returning all-white mask")
return (None,)
b, h, w = ref.shape
# Resize all masks to match reference
if has_body:
body = self._resize_mask(body, h, w)
if has_face:
face = self._resize_mask(face, h, w)
object_masks = [self._resize_mask(m, h, w) for m in object_masks]
# Merge all object masks into one (union)
combined_objects = None
if has_objects:
combined_objects = torch.zeros(b, h, w, device=ref.device)
for m in object_masks:
# Handle batch size mismatch by expanding
if m.shape[0] < b:
m = m.expand(b, -1, -1)
combined_objects = torch.max(combined_objects, m[:b])
combined_objects = combined_objects.clamp(0, 1)
# Apply formula based on which inputs are present
if has_body and has_face and has_objects:
# invert(body) + face - objects
result = (1.0 - body) + face - combined_objects
print(f"[RMMaskCombine] Formula: invert(body) + face - objects")
elif has_body and has_face:
# invert(body) + face
result = (1.0 - body) + face
print(f"[RMMaskCombine] Formula: invert(body) + face")
elif has_face and has_objects:
# face - objects
result = face - combined_objects
print(f"[RMMaskCombine] Formula: face - objects")
elif has_body and has_objects:
# invert(objects) — body without face means nothing to add back
result = 1.0 - combined_objects
print(f"[RMMaskCombine] Formula: invert(objects)")
elif has_body:
# invert(body)
result = 1.0 - body
print(f"[RMMaskCombine] Formula: invert(body)")
elif has_face:
# face only
result = face
print(f"[RMMaskCombine] Formula: face")
elif has_objects:
# invert(objects)
result = 1.0 - combined_objects
print(f"[RMMaskCombine] Formula: invert(objects)")
else:
# Should not reach here due to earlier ref check
return (None,)
result = result.clamp(0, 1)
print(f"[RMMaskCombine] Output shape: {result.shape}")
return (result,)
NODE_CLASS_MAPPINGS = {
"RMMaskCombine": RMMaskCombine,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMMaskCombine": "RM Mask Combine",
}
+214
View File
@@ -0,0 +1,214 @@
"""
RMMultiDetectCrop and companion composite node.
RMMultiDetectCrop: Like RMFaceDetectCrop but accepts multiple BBOX models,
each with its own confidence threshold. Dynamic inputs are added via a
frontend "Add BBOX" button (same pattern as RM Power Lora Loader).
All detections are merged into a single flat list.
Works with RMFaceComposite for compositing results back onto the original.
"""
import torch
import numpy as np
import cv2
from PIL import Image
import folder_paths
import comfy.utils
class FlexibleBBoxInputType(dict):
"""
Allows dynamic inputs added by the frontend JS.
ComfyUI will accept any key whose value is a dict with {on, model, threshold}.
"""
def __init__(self):
super().__init__({})
def __getitem__(self, key):
if key in dict.keys(self):
return dict.__getitem__(self, key)
return ("*",)
def __contains__(self, key):
return True
class RMMultiDetectCrop:
"""
Detects objects using multiple BBOX models (each with its own threshold),
crops each detection region (expanded by crop_factor), and scales to
max_size. Uses OUTPUT_IS_LIST so each detection flows independently
through downstream nodes.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"dilation": ("INT", {"default": 10, "min": -512, "max": 512, "step": 1}),
"crop_factor": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 10.0, "step": 0.1}),
"drop_size": ("INT", {"default": 10, "min": 1, "max": 8192, "step": 1}),
"megapixels": ("FLOAT", {"default": 1.0, "min": 0.1, "max": 16.0, "step": 0.1}),
},
"optional": FlexibleBBoxInputType(),
}
RETURN_TYPES = ("IMAGE", "RM_FACE_DATA", "INT")
RETURN_NAMES = ("cropped_region", "region_data", "region_count")
OUTPUT_IS_LIST = (True, False, False)
FUNCTION = "detect"
CATEGORY = "RMAutomation/Detection"
def detect(self, image, dilation, crop_factor, drop_size, megapixels, **kwargs):
from ultralytics import YOLO
img_tensor = image[0] # (H, W, C)
h, w = img_tensor.shape[0], img_tensor.shape[1]
# Convert image once for all models
img_np = (img_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
img_pil = Image.fromarray(img_np)
# Collect BBOX configs from dynamic kwargs
bbox_configs = []
for key, value in kwargs.items():
if key.upper().startswith("BBOX_") and isinstance(value, dict):
if value.get("on", True):
model_name = value.get("model")
threshold = value.get("threshold", 0.5)
if model_name and model_name != "none":
bbox_configs.append((model_name, threshold))
if not bbox_configs:
print("[RMMultiDetectCrop] No BBOX models configured")
return self._no_detections(h, w)
# Run each model and collect all detections
all_detections = [] # list of (x1, y1, x2, y2, conf)
for model_name, threshold in bbox_configs:
model_path = folder_paths.get_full_path("ultralytics_bbox", model_name)
if model_path is None:
print(f"[RMMultiDetectCrop] Model not found: {model_name}")
continue
try:
model = YOLO(model_path)
results = model(img_pil, conf=threshold)
except Exception as e:
print(f"[RMMultiDetectCrop] Detection error with {model_name}: {e}")
continue
if not results or results[0].boxes is None or len(results[0].boxes) == 0:
print(f"[RMMultiDetectCrop] No detections from {model_name}")
continue
bboxes = results[0].boxes.xyxy.cpu().numpy()
confidences = results[0].boxes.conf.cpu().numpy()
for i in range(len(bboxes)):
x1, y1, x2, y2 = bboxes[i]
if (x2 - x1) < drop_size or (y2 - y1) < drop_size:
continue
all_detections.append((int(x1), int(y1), int(x2), int(y2), float(confidences[i])))
print(f"[RMMultiDetectCrop] {model_name}: {len(bboxes)} raw, {sum(1 for b in bboxes if (b[2]-b[0]) >= drop_size and (b[3]-b[1]) >= drop_size)} kept")
if not all_detections:
return self._no_detections(h, w)
# Crop and scale each detection
crop_images = []
infos = []
for bx1, by1, bx2, by2, conf in all_detections:
cl, ct, cr, cb = self._crop_region(w, h, (bx1, by1, bx2, by2), crop_factor)
cw, ch = cr - cl, cb - ct
# Create rectangular bbox mask and apply dilation
mask = np.zeros((h, w), dtype=np.float32)
mask[by1:by2, bx1:bx2] = 1.0
if dilation != 0:
kern = np.ones((abs(dilation), abs(dilation)), np.uint8)
if dilation > 0:
mask = cv2.dilate(mask, kern, iterations=1)
else:
mask = cv2.erode(mask, kern, iterations=1)
cropped_mask = torch.from_numpy(mask[ct:cb, cl:cr].copy())
cropped_img = img_tensor[ct:cb, cl:cr, :].clone()
# Scale to target megapixels while preserving aspect ratio
current_pixels = cw * ch
target_pixels = megapixels * 1_000_000
scale_factor = (target_pixels / current_pixels) ** 0.5
nw, nh = round(cw * scale_factor), round(ch * scale_factor)
scaled = comfy.utils.common_upscale(
cropped_img.movedim(-1, 0).unsqueeze(0),
nw, nh, "lanczos", "disabled"
).squeeze(0).movedim(0, -1)
crop_images.append(scaled.unsqueeze(0))
infos.append({
"crop_region": (cl, ct, cr, cb),
"crop_w": cw,
"crop_h": ch,
"cropped_mask": cropped_mask,
})
region_data = {"found": True, "faces": infos, "original_size": (h, w)}
print(f"[RMMultiDetectCrop] Total detections: {len(all_detections)}")
return (crop_images, region_data, len(all_detections))
def _no_detections(self, h, w):
"""Return a single dummy image and empty data when nothing found."""
print("[RMMultiDetectCrop] No detections found")
dummy = torch.zeros(1, 256, 256, 3)
return (
[dummy],
{"found": False, "faces": [], "original_size": (h, w)},
0,
)
@staticmethod
def _crop_region(img_w, img_h, bbox, factor):
"""Compute crop region expanded by factor, clamped to image bounds."""
x1, y1, x2, y2 = bbox
bw, bh = x2 - x1, y2 - y1
cw, ch = bw * factor, bh * factor
cx, cy = x1 + bw / 2, y1 + bh / 2
nx1 = int(cx - cw / 2)
ny1 = int(cy - ch / 2)
nx2 = int(nx1 + cw)
ny2 = int(ny1 + ch)
if nx1 < 0:
nx2 -= nx1
nx1 = 0
if ny1 < 0:
ny2 -= ny1
ny1 = 0
if nx2 > img_w:
nx1 -= (nx2 - img_w)
nx2 = img_w
if ny2 > img_h:
ny1 -= (ny2 - img_h)
ny2 = img_h
return max(0, nx1), max(0, ny1), min(img_w, nx2), min(img_h, ny2)
NODE_CLASS_MAPPINGS = {
"RMMultiDetectCrop": RMMultiDetectCrop,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"RMMultiDetectCrop": "RM Multi-Detect & Crop",
}
+47
View File
@@ -0,0 +1,47 @@
"""
RM Progress Reporter - Reports workflow progress to the Playground Viewer app.
"""
import json
import os
import glob
import time
try:
import requests
HAS_REQUESTS = True
except ImportError:
import urllib.request
import urllib.error
HAS_REQUESTS = False
VIEWER_URL = "http://127.0.0.1:3001/api/comfy-progress"
PREVIEW_DIR = r"C:\Users\rober\Playground\Viewer\public\previews"
def ensure_preview_dir():
"""Create preview directory if it doesn't exist."""
if not os.path.exists(PREVIEW_DIR):
os.makedirs(PREVIEW_DIR, exist_ok=True)
def send_progress(data: dict) -> bool:
"""Send progress data to the Viewer app."""
try:
json_data = json.dumps(data)
if HAS_REQUESTS:
response = requests.post(VIEWER_URL, json=data, timeout=2)
return response.status_code == 200
else:
req = urllib.request.Request(
VIEWER_URL,
data=json_data.encode('utf-8'),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(req, timeout=2) as response:
return response.status == 200
except Exception:
# Viewer app may not be running - fail silently
return False
+107 -35
View File
@@ -3,7 +3,7 @@ RM Styles Full - Load prompts from JSON styles file with random/increment/decrem
This node loads prompt data from a JSON file and supports multiple selection modes:
- Manual: Use the exact next_prompt number specified
- Random: JS calculates random number within min/max range
- Random: Python picks a random style, avoiding the last 30 used
- Increment: JS increments after each execution (wraps at max)
- Decrement: JS decrements after each execution (wraps at min)
@@ -12,10 +12,39 @@ The JSON file can be edited via a modal editor accessible from a button on the n
import json
import os
import random
from pathlib import Path
from server import PromptServer
from aiohttp import web
# Anti-repeat history for Random mode (module-level, shared across all style nodes)
RANDOM_HISTORY_SIZE = 30
_random_history = []
def _get_random_prompt(minimum, maximum, available_numbers):
"""Pick a random style number from available styles, avoiding recently used ones."""
global _random_history
candidates = [n for n in available_numbers if minimum <= n <= maximum]
if not candidates:
return random.randint(minimum, maximum)
effective_buffer = min(RANDOM_HISTORY_SIZE, len(candidates) - 1)
avoid = set(_random_history[-effective_buffer:]) if effective_buffer > 0 else set()
good = [n for n in candidates if n not in avoid]
if not good:
good = candidates # All avoided, pick any
result = random.choice(good)
_random_history.append(result)
if len(_random_history) > RANDOM_HISTORY_SIZE:
_random_history[:] = _random_history[-RANDOM_HISTORY_SIZE:]
return result
class RMStylesFull:
"""
@@ -51,18 +80,27 @@ class RMStylesFull:
def __init__(self):
self._styles_cache = None
self._styles_mtime = 0
self._styles_path = None
def _load_styles(self) -> dict:
def _load_styles(self, custom_path=None) -> dict:
"""Load styles from JSON file with caching."""
try:
if not self.STYLES_FILE.exists():
print(f"[RMStylesFull] Styles file not found: {self.STYLES_FILE}")
styles_file = Path(custom_path) if custom_path else self.STYLES_FILE
# Invalidate cache if file path changed
if self._styles_path != str(styles_file):
self._styles_cache = None
self._styles_mtime = 0
self._styles_path = str(styles_file)
if not styles_file.exists():
print(f"[RMStylesFull] Styles file not found: {styles_file}")
return {}
current_mtime = self.STYLES_FILE.stat().st_mtime
current_mtime = styles_file.stat().st_mtime
if self._styles_cache is None or current_mtime > self._styles_mtime:
with open(self.STYLES_FILE, 'r', encoding='utf-8') as f:
with open(styles_file, 'r', encoding='utf-8') as f:
data = json.load(f)
self._styles_cache = {}
@@ -72,7 +110,7 @@ class RMStylesFull:
self._styles_cache[num] = style
self._styles_mtime = current_mtime
print(f"[RMStylesFull] Loaded {len(self._styles_cache)} styles from JSON")
print(f"[RMStylesFull] Loaded {len(self._styles_cache)} styles from {styles_file}")
return self._styles_cache
except Exception as e:
@@ -113,6 +151,13 @@ class RMStylesFull:
"default": False,
"tooltip": "Add quality prefixes to positive/negative prompts"
}),
"custom_json_path": ("STRING", {
"default": "",
"tooltip": "Path to a custom styles JSON file. Leave empty to use the default styles.json in the node pack."
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
@@ -145,15 +190,20 @@ class RMStylesFull:
return ", ".join(formatted)
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
"""Main node execution function."""
empty_result = ("", "", "", "", "", "", 0, "")
styles = self._load_styles()
custom_path = custom_json_path.strip() if custom_json_path else None
styles = self._load_styles(custom_path)
if not styles:
return empty_result
selected_number = next_prompt
# Random mode: Python picks the number, ignoring next_prompt
if mode == "Random":
selected_number = _get_random_prompt(minimum, maximum, list(styles.keys()))
else:
selected_number = next_prompt
if selected_number not in styles:
available = sorted(styles.keys())
@@ -190,6 +240,13 @@ class RMStylesFull:
motion_loras_high_str = self._format_loras(motion_loras_high)
motion_loras_low_str = self._format_loras(motion_loras_low)
# Send actual used number back to JS for widget update
if unique_id is not None:
PromptServer.instance.send_sync("rm_styles_executed", {
"node_id": str(unique_id),
"prompt_number": selected_number,
})
return (
positive,
negative,
@@ -236,15 +293,18 @@ class RMStylesFullDisplay(RMStylesFull):
"use_prefix": ("BOOLEAN", {
"default": False,
}),
"custom_json_path": ("STRING", {
"default": "",
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, unique_id=None):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, use_prefix: bool = False, custom_json_path: str = "", unique_id=None):
"""Same as parent but also returns UI data to display on the node."""
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix)
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix, custom_json_path=custom_json_path, unique_id=unique_id)
positive = result[0] if result[0] else "(empty)"
prompt_num = result[6]
@@ -268,14 +328,26 @@ def setup_styles_api():
"""Register API routes for the styles editor modal."""
try:
data_dir = Path(__file__).parent.parent / "data"
styles_file = data_dir / "styles.json"
default_styles_file = data_dir / "styles.json"
def _resolve_styles_file(request):
"""Get the styles file path, using custom path from query param if provided."""
custom = request.query.get('path', '').strip()
if custom:
return Path(custom)
return default_styles_file
def _ensure_parent_dir(file_path):
"""Create parent directory if it doesn't exist."""
os.makedirs(file_path.parent, exist_ok=True)
@PromptServer.instance.routes.get("/api/rmautomation/styles")
async def get_styles(request):
try:
if not styles_file.exists():
target = _resolve_styles_file(request)
if not target.exists():
return web.json_response({"styles": []})
with open(styles_file, 'r', encoding='utf-8') as f:
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
return web.json_response(data)
except Exception as e:
@@ -285,8 +357,9 @@ def setup_styles_api():
async def save_styles(request):
try:
data = await request.json()
os.makedirs(data_dir, exist_ok=True)
with open(styles_file, 'w', encoding='utf-8') as f:
target = _resolve_styles_file(request)
_ensure_parent_dir(target)
with open(target, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return web.json_response({"success": True})
except Exception as e:
@@ -296,9 +369,10 @@ def setup_styles_api():
async def get_style(request):
try:
number = int(request.match_info['number'])
if not styles_file.exists():
target = _resolve_styles_file(request)
if not target.exists():
return web.json_response({"error": "Styles file not found"}, status=404)
with open(styles_file, 'r', encoding='utf-8') as f:
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
for style in data.get('styles', []):
if style.get('number') == number:
@@ -314,10 +388,11 @@ def setup_styles_api():
new_style = await request.json()
new_style['number'] = number
if not styles_file.exists():
target = _resolve_styles_file(request)
if not target.exists():
data = {"styles": []}
else:
with open(styles_file, 'r', encoding='utf-8') as f:
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
styles = data.get('styles', [])
@@ -333,8 +408,8 @@ def setup_styles_api():
styles.sort(key=lambda x: x.get('number', 0))
data['styles'] = styles
os.makedirs(data_dir, exist_ok=True)
with open(styles_file, 'w', encoding='utf-8') as f:
_ensure_parent_dir(target)
with open(target, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return web.json_response({"success": True})
@@ -345,16 +420,17 @@ def setup_styles_api():
async def delete_style(request):
try:
number = int(request.match_info['number'])
if not styles_file.exists():
target = _resolve_styles_file(request)
if not target.exists():
return web.json_response({"error": "Styles file not found"}, status=404)
with open(styles_file, 'r', encoding='utf-8') as f:
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
styles = [s for s in data.get('styles', []) if s.get('number') != number]
data['styles'] = styles
with open(styles_file, 'w', encoding='utf-8') as f:
with open(target, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return web.json_response({"success": True})
@@ -372,34 +448,30 @@ def setup_styles_api():
if not imported_styles:
return web.json_response({"error": "No styles provided"}, status=400)
os.makedirs(data_dir, exist_ok=True)
target = _resolve_styles_file(request)
_ensure_parent_dir(target)
if mode == 'replace':
# Replace all existing styles with imported ones
# Ensure proper numbering
for i, style in enumerate(imported_styles):
if 'number' not in style:
style['number'] = i + 1
imported_styles.sort(key=lambda x: x.get('number', 0))
data = {"styles": imported_styles}
else:
# Append mode: add imported styles after existing ones
if styles_file.exists():
with open(styles_file, 'r', encoding='utf-8') as f:
if target.exists():
with open(target, 'r', encoding='utf-8') as f:
data = json.load(f)
else:
data = {"styles": []}
existing_styles = data.get('styles', [])
# Find the highest existing number
max_number = 0
for style in existing_styles:
num = style.get('number', 0)
if num > max_number:
max_number = num
# Renumber imported styles to start after the highest existing number
for i, style in enumerate(imported_styles):
style['number'] = max_number + i + 1
@@ -407,7 +479,7 @@ def setup_styles_api():
existing_styles.sort(key=lambda x: x.get('number', 0))
data['styles'] = existing_styles
with open(styles_file, 'w', encoding='utf-8') as f:
with open(target, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False)
return web.json_response({"success": True, "count": len(imported_styles)})
+26 -2
View File
@@ -2,7 +2,11 @@
RM Styles Pipe - Style pipe input/output nodes.
"""
import os
import glob
import time
from .styles_full import RMStylesFull
from .progress_reporter import send_progress, ensure_preview_dir, PREVIEW_DIR
class RMStylesPipe(RMStylesFull):
@@ -25,12 +29,32 @@ class RMStylesPipe(RMStylesFull):
"next_prompt": ("INT", {"default": 1, "min": 1, "max": 9999}),
"minimum": ("INT", {"default": 1, "min": 1, "max": 9999}),
"maximum": ("INT", {"default": 100, "min": 1, "max": 9999}),
"custom_json_path": ("STRING", {"default": ""}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
}
}
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int):
def load_style(self, mode: str, previous_prompt: int, next_prompt: int, minimum: int, maximum: int, custom_json_path: str = "", unique_id=None):
"""Load style and return as a pipe tuple (without prefixes)."""
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False)
# --- Progress Start: clear previews and notify viewer ---
try:
ensure_preview_dir()
for f in glob.glob(os.path.join(PREVIEW_DIR, "preview_*.jpg")):
try:
os.remove(f)
except Exception:
pass
send_progress({
"event": "workflow_start",
"workflow_name": "Generation",
"timestamp": time.time(),
})
except Exception as e:
print(f"[RMProgress] Error sending workflow_start: {e}")
result = super().load_style(mode, previous_prompt, next_prompt, minimum, maximum, use_prefix=False, custom_json_path=custom_json_path, unique_id=unique_id)
return (result,)
+11
View File
@@ -22,6 +22,7 @@ import logging
import folder_paths
from comfy.utils import ProgressBar
from .progress_reporter import send_progress
# Logger setup
class ColoredFormatter(logging.Formatter):
@@ -690,6 +691,16 @@ class RMVideoCombine:
logger.info(f"Preview info - filename: {file}, subfolder: {preview_subfolder}, type: {preview_type}")
# --- Progress End: notify viewer that generation is complete ---
try:
send_progress({
"event": "workflow_end",
"message": "Complete",
"timestamp": time.time(),
})
except Exception as e:
logger.warning(f"[RMProgress] Error sending workflow_end: {e}")
preview = {
"filename": file,
"subfolder": preview_subfolder,