mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-06-25 12:31:15 -03:00
- Add genParamsMapper.js: sampler/scheduler display→internal mapping, combined-name parsing, widget matching - Add sendGenParamsToWorkflow() in uiHelpers.js: resolves sampler, fetches registry by send_gen_params marker, sends via update-node-widget - Add send-params-btn UI in showcase hover panel and recipe modal - Add flashWidget() in workflow_registry.js: text-color visual cue on updated widget values (Vue: inline style + CSS, canvas: property shadow) - Add silent option to sendWidgetValueToNodes for consolidated toast - Normalize param display labels (cfg_scale→CFG, etc.) in recipe modal - Add 33 tests for genParamsMapper; update existing test assertions
380 lines
14 KiB
JavaScript
380 lines
14 KiB
JavaScript
import { app } from "../../scripts/app.js";
|
|
import { api } from "../../scripts/api.js";
|
|
import { getAllGraphNodes, getNodeReference, getNodeFromGraph } from "./utils.js";
|
|
import { ensureLmStyles } from "./lm_styles_loader.js";
|
|
|
|
const LORA_NODE_CLASSES = new Set([
|
|
"Lora Loader (LoraManager)",
|
|
"Lora Stacker (LoraManager)",
|
|
"WanVideo Lora Select (LoraManager)",
|
|
]);
|
|
|
|
const TARGET_WIDGET_NAMES = new Set(["ckpt_name", "unet_name"]);
|
|
|
|
// Node classes whose "text" widget is a prompt text input (not LoRA syntax, notes, etc.)
|
|
const TEXT_CAPABLE_CLASSES = new Set([
|
|
"Prompt (LoraManager)",
|
|
"Text (LoraManager)",
|
|
"CLIPTextEncode",
|
|
]);
|
|
|
|
app.registerExtension({
|
|
name: "LoraManager.WorkflowRegistry",
|
|
|
|
setup() {
|
|
ensureLmStyles();
|
|
|
|
api.addEventListener("lora_registry_refresh", () => {
|
|
this.refreshRegistry();
|
|
});
|
|
|
|
api.addEventListener("lm_widget_update", (event) => {
|
|
this.applyWidgetUpdate(event?.detail ?? {});
|
|
});
|
|
|
|
// React to marker changes from the Node Marker extension
|
|
window.addEventListener("lm_marker_changed", () => {
|
|
this.refreshRegistry();
|
|
});
|
|
},
|
|
|
|
async refreshRegistry() {
|
|
try {
|
|
const workflowNodes = [];
|
|
const nodeEntries = getAllGraphNodes(app.graph);
|
|
|
|
for (const { graph, node } of nodeEntries) {
|
|
if (!node) {
|
|
continue;
|
|
}
|
|
|
|
const widgetNames = Array.isArray(node.widgets)
|
|
? node.widgets
|
|
.map((widget) => widget?.name)
|
|
.filter((name) => typeof name === "string" && name.length > 0)
|
|
: [];
|
|
|
|
const supportsLora = LORA_NODE_CLASSES.has(node.comfyClass);
|
|
const hasTargetWidget = widgetNames.some((name) => TARGET_WIDGET_NAMES.has(name));
|
|
const hasTextWidget = TEXT_CAPABLE_CLASSES.has(node.comfyClass);
|
|
const markerRole = node.properties?.lm_marker_role ?? null;
|
|
|
|
// Skip nodes with no relevant capability UNLESS they are marked
|
|
if (!supportsLora && !hasTargetWidget && !hasTextWidget && !markerRole) {
|
|
continue;
|
|
}
|
|
|
|
const reference = getNodeReference(node);
|
|
if (!reference) {
|
|
continue;
|
|
}
|
|
|
|
const graphName =
|
|
typeof graph?.name === "string" && graph.name.trim() ? graph.name : null;
|
|
|
|
workflowNodes.push({
|
|
node_id: reference.node_id,
|
|
graph_id: reference.graph_id,
|
|
graph_name: graphName,
|
|
bgcolor: node.bgcolor ?? node.color ?? null,
|
|
title: node.title || node.comfyClass,
|
|
type: node.comfyClass,
|
|
comfy_class: node.comfyClass,
|
|
mode: node.mode,
|
|
marker_role: markerRole,
|
|
capabilities: {
|
|
supports_lora: supportsLora,
|
|
has_text_widget: hasTextWidget,
|
|
widget_names: widgetNames,
|
|
},
|
|
});
|
|
}
|
|
|
|
const response = await fetch("/api/lm/register-nodes", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
body: JSON.stringify({ nodes: workflowNodes }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.warn("LoRA Manager: failed to register workflow nodes", response.statusText);
|
|
} else {
|
|
console.debug(
|
|
`LoRA Manager: registered ${workflowNodes.length} workflow nodes`
|
|
);
|
|
}
|
|
} catch (error) {
|
|
console.error("LoRA Manager: error refreshing workflow registry", error);
|
|
}
|
|
},
|
|
|
|
applyWidgetUpdate(message) {
|
|
const nodeId = message?.node_id ?? message?.id;
|
|
const graphId = message?.graph_id;
|
|
const action = message?.action;
|
|
const widgetName = message?.widget_name;
|
|
const value = message?.value;
|
|
const mode = message?.mode ?? "replace";
|
|
|
|
if (nodeId == null || (!action && !widgetName)) {
|
|
console.warn("LoRA Manager: invalid widget update payload", message);
|
|
return;
|
|
}
|
|
|
|
const node = getNodeFromGraph(graphId, nodeId);
|
|
if (!node) {
|
|
console.warn(
|
|
"LoRA Manager: target node not found for widget update",
|
|
graphId ?? "root",
|
|
nodeId
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!Array.isArray(node.widgets)) {
|
|
console.warn("LoRA Manager: node does not expose widgets", node);
|
|
return;
|
|
}
|
|
|
|
// ---- Resolve target widget ----
|
|
let targetWidget = null;
|
|
|
|
if (action === "inject_text") {
|
|
// Find the first text-capable widget by type.
|
|
// Normalise to lowercase for case-insensitive matching.
|
|
const TEXT_TYPES = new Set(["string", "customtext"]);
|
|
targetWidget = node.widgets.find((w) => {
|
|
const t = typeof w?.type === "string" ? w.type.toLowerCase() : "";
|
|
if (TEXT_TYPES.has(t)) return true;
|
|
// Broad fallback for unknown composite types.
|
|
if (t.includes("string")) {
|
|
return true;
|
|
}
|
|
return false;
|
|
});
|
|
if (!targetWidget) {
|
|
// Last resort: pick the first widget that is not a hidden/internal type
|
|
targetWidget = node.widgets.find((w) => w?.name && !w.name.startsWith("_"));
|
|
if (!targetWidget) {
|
|
console.warn(
|
|
"LoRA Manager: no suitable widget for inject_text on node",
|
|
node.id
|
|
);
|
|
return;
|
|
}
|
|
}
|
|
} else if (widgetName) {
|
|
// Legacy: find widget by name
|
|
targetWidget = node.widgets.find((w) => w?.name === widgetName);
|
|
if (!targetWidget) {
|
|
console.warn(
|
|
"LoRA Manager: target widget not found on node",
|
|
widgetName,
|
|
node
|
|
);
|
|
return;
|
|
}
|
|
} else {
|
|
console.warn("LoRA Manager: no action or widget_name in payload", message);
|
|
return;
|
|
}
|
|
|
|
// ---- Update widget value ----
|
|
const widgetIndex = node.widgets.indexOf(targetWidget);
|
|
let newValue = value;
|
|
|
|
if (mode === "append") {
|
|
const separator =
|
|
targetWidget.value && targetWidget.value.length > 0 ? " " : "";
|
|
newValue = targetWidget.value + separator + value;
|
|
}
|
|
|
|
targetWidget.value = newValue;
|
|
|
|
if (
|
|
Array.isArray(node.widgets_values) &&
|
|
widgetIndex >= 0 &&
|
|
node.widgets_values.length > widgetIndex
|
|
) {
|
|
node.widgets_values[widgetIndex] = newValue;
|
|
}
|
|
|
|
if (typeof targetWidget.callback === "function") {
|
|
try {
|
|
targetWidget.callback(newValue);
|
|
} catch (callbackError) {
|
|
console.error("LoRA Manager: widget callback failed", callbackError);
|
|
}
|
|
}
|
|
|
|
if (typeof node.setDirtyCanvas === "function") {
|
|
node.setDirtyCanvas(true);
|
|
}
|
|
|
|
if (typeof app.graph?.setDirtyCanvas === "function") {
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
// ---- Visual cue: briefly highlight the updated widget ----
|
|
this.flashWidget(node, targetWidget);
|
|
},
|
|
|
|
/**
|
|
* Add a temporary visual highlight to a widget after its value is updated.
|
|
* - Vue Nodes mode: change value text color on all non-button elements
|
|
* - Canvas mode: define text_color on widget instance (value text only)
|
|
* Highlight fades after 10 seconds or on hover (Vue mode only).
|
|
*/
|
|
flashWidget(node, widget) {
|
|
const FLASH_DURATION = 3000;
|
|
const flashEnd = Date.now() + FLASH_DURATION;
|
|
const nodeId = node.id;
|
|
|
|
// Colors consistent with canvas mode
|
|
const VALUE_COLOR = '#66B3FF';
|
|
|
|
// Helper: find the widget row in the DOM (by label text matching widget name)
|
|
const findRowEl = () => {
|
|
const container = document.querySelector(`[data-node-id="${nodeId}"]`);
|
|
if (!container) return null;
|
|
const all = container.querySelectorAll('[data-testid="node-widget"]');
|
|
for (const w of all) {
|
|
const label = w.querySelector('[data-testid="widget-layout-field-label"]');
|
|
if (label && label.textContent.trim() === widget.name) {
|
|
return w;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Helper: get label and ring elements from a widget row
|
|
const getLabelAndRing = (row) => {
|
|
if (!row) return { labelEl: null, ringEl: null };
|
|
const labelEl = row.querySelector('[data-testid="widget-layout-field-label"]');
|
|
const ringEl = labelEl?.nextElementSibling
|
|
|| row.querySelector('.flex-1.relative.min-w-0')
|
|
|| row.querySelector('.rounded-lg.transition-all')
|
|
|| null;
|
|
return { labelEl, ringEl };
|
|
};
|
|
|
|
const applyFlash = (row) => {
|
|
if (!row) return;
|
|
const { ringEl } = getLabelAndRing(row);
|
|
if (ringEl) {
|
|
const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
|
|
if (innerRing) {
|
|
// Target value-displaying elements for all widget types:
|
|
// NumberWidget: spinbutton input
|
|
// ComboWidget: combobox button
|
|
// Text widgets (CLIPTextEncode, Prompt, etc.): textarea / text input
|
|
innerRing.querySelectorAll(
|
|
'input, textarea, [role="combobox"]'
|
|
).forEach(el => {
|
|
el.style.color = VALUE_COLOR;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
const removeFlash = (row) => {
|
|
if (!row) return;
|
|
const { ringEl } = getLabelAndRing(row);
|
|
if (ringEl) {
|
|
const innerRing = ringEl.querySelector('.rounded-lg.transition-all');
|
|
if (innerRing) {
|
|
// Clear color from all inputs/textarea/combobox
|
|
innerRing.querySelectorAll(
|
|
'input, textarea, [role="combobox"]'
|
|
).forEach(el => {
|
|
el.style.color = '';
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- Try Vue Nodes mode first ---
|
|
const nodeEl = document.querySelector(`[data-node-id="${nodeId}"]`);
|
|
if (nodeEl) {
|
|
// Apply immediately
|
|
const initialRow = findRowEl();
|
|
applyFlash(initialRow);
|
|
|
|
// rAF loop: re-apply after Vue re-renders
|
|
let rafId = null;
|
|
const poll = () => {
|
|
if (Date.now() >= flashEnd) {
|
|
const lastRow = findRowEl();
|
|
removeFlash(lastRow);
|
|
rafId = null;
|
|
return;
|
|
}
|
|
const currentRow = findRowEl();
|
|
applyFlash(currentRow);
|
|
rafId = requestAnimationFrame(poll);
|
|
};
|
|
rafId = requestAnimationFrame(poll);
|
|
|
|
// Cleanup timeout
|
|
const timeoutId = setTimeout(() => {
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
const lastRow = findRowEl();
|
|
removeFlash(lastRow);
|
|
}, FLASH_DURATION);
|
|
|
|
// Hover dismissal via event delegation on node container
|
|
const hoverHandler = (e) => {
|
|
const row = findRowEl();
|
|
if (row && row.contains(e.target)) {
|
|
clearTimeout(timeoutId);
|
|
if (rafId) cancelAnimationFrame(rafId);
|
|
removeFlash(row);
|
|
nodeEl.removeEventListener('mouseover', hoverHandler);
|
|
}
|
|
};
|
|
nodeEl.addEventListener('mouseover', hoverHandler);
|
|
|
|
return; // Vue mode done
|
|
}
|
|
|
|
// --- Canvas mode: change widget value text color via instance property shadowing ---
|
|
// BaseWidget reads text_color (value) from prototype getter. Defining an own
|
|
// property on the instance shadows the getter without monkey-patching.
|
|
// Works for ALL widget types — only value text is changed, label is left alone.
|
|
Object.defineProperty(widget, 'text_color', {
|
|
value: VALUE_COLOR,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
|
|
if (typeof node.setDirtyCanvas === "function") {
|
|
node.setDirtyCanvas(true);
|
|
}
|
|
|
|
// Track this widget so it gets restored alongside others on the same node
|
|
if (!node._lmFlashedWidgets) node._lmFlashedWidgets = [];
|
|
if (!node._lmFlashedWidgets.includes(widget)) {
|
|
node._lmFlashedWidgets.push(widget);
|
|
}
|
|
|
|
// Single per-node timer that restores ALL flashed widgets at once.
|
|
// Subsequent calls reset the timer but don't orphan previous widgets.
|
|
if (node._lmFlashCleanup) {
|
|
clearTimeout(node._lmFlashCleanup);
|
|
}
|
|
node._lmFlashCleanup = setTimeout(() => {
|
|
for (const w of (node._lmFlashedWidgets || [])) {
|
|
delete w.text_color;
|
|
delete w.secondary_text_color;
|
|
}
|
|
delete node._lmFlashedWidgets;
|
|
delete node._lmFlashCleanup;
|
|
if (typeof node.setDirtyCanvas === "function") {
|
|
node.setDirtyCanvas(true);
|
|
}
|
|
}, FLASH_DURATION);
|
|
},
|
|
});
|