483 lines
16 KiB
JavaScript
483 lines
16 KiB
JavaScript
/*
|
|
* SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*
|
|
* 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
|
|
*
|
|
* https://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.
|
|
*/
|
|
|
|
/**
|
|
* Node Controller
|
|
*
|
|
* Handles RTX Remix node setup, UI configuration, and API event handling.
|
|
* Follows the dependency rule: controllers → cores → stores → utils
|
|
*/
|
|
|
|
import { api } from "../../../scripts/api.js";
|
|
import { cloneTemplate, bindTemplateData } from "../utils/html.js";
|
|
import { TEMPLATE_IDS, REMIX_KEYS, EVENTS } from "../utils/constants.js";
|
|
|
|
/** Cache for frontend configs fetched from Python backend */
|
|
const configCache = new Map();
|
|
|
|
/** Symbol keys for private properties on external objects */
|
|
const SETUP_COMPLETE = Symbol("rtxRemixSetup");
|
|
const ORIG_TYPE = Symbol("origType");
|
|
const ORIG_COMPUTE_SIZE = Symbol("origComputeSize");
|
|
const ORIG_HAS_COMPUTE_SIZE = Symbol("origHasComputeSize");
|
|
|
|
/**
|
|
* Fetch frontend UI config for a node class from the backend API.
|
|
* Results are cached to avoid redundant network requests.
|
|
*
|
|
* @param {string} nodeClass - The ComfyUI node class name (e.g., "RTXRemixDownloadModel")
|
|
* @returns {Promise<Object|null>} The UI config object or null if fetch fails
|
|
*/
|
|
async function fetchFrontendConfig(nodeClass) {
|
|
if (configCache.has(nodeClass)) {
|
|
return configCache.get(nodeClass);
|
|
}
|
|
|
|
try {
|
|
const response = await api.fetchApi(`/rtx-remix/v1/ui/${nodeClass}`);
|
|
if (!response.ok) return null;
|
|
|
|
const config = await response.json();
|
|
configCache.set(nodeClass, config);
|
|
return config;
|
|
} catch (e) {
|
|
console.warn(`Failed to fetch frontend config for ${nodeClass}:`, e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Display a modal info dialog with the given title and HTML content.
|
|
* Dialog can be closed by clicking the close button or the overlay background.
|
|
*
|
|
* @param {string} title - The dialog title text
|
|
* @param {string} content - HTML content to display in the dialog body
|
|
*/
|
|
function showInfoDialog(title, content) {
|
|
const dialog = cloneTemplate(TEMPLATE_IDS.INFO_DIALOG);
|
|
if (!dialog) return;
|
|
|
|
bindTemplateData(dialog, { title });
|
|
|
|
const contentEl = dialog.querySelector('[data-element="content"]');
|
|
if (contentEl) contentEl.innerHTML = content;
|
|
|
|
const close = () => dialog.remove();
|
|
dialog.querySelector('[data-action="close"]')?.addEventListener("click", close);
|
|
dialog.querySelector(".rtx-remix-info-overlay")?.addEventListener("click", (e) => {
|
|
if (e.target === e.currentTarget) close();
|
|
});
|
|
|
|
document.body.appendChild(dialog);
|
|
}
|
|
|
|
/**
|
|
* Update widget visibility based on declarative visibility rules.
|
|
* Widgets are shown/hidden by changing their type to "converted-widget" and
|
|
* overriding computeSize to collapse them.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Array<Object>} visibilityRules - Array of visibility rule objects containing:
|
|
* - source_field: Widget name to watch for changes
|
|
* - mapping: Object mapping source values to arrays of field names to show
|
|
* - show_when_filled: Array of field names to show when source has any value
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
function updateVisibility(node, visibilityRules, app) {
|
|
const controlledFields = new Set();
|
|
const visibleFields = new Set();
|
|
|
|
for (const { source_field, mapping, show_when_filled } of visibilityRules) {
|
|
const sourceValue = node.widgets?.find((w) => w.name === source_field)?.value;
|
|
|
|
// Register all controlled fields
|
|
if (mapping) {
|
|
Object.values(mapping)
|
|
.flat()
|
|
.forEach((f) => controlledFields.add(f));
|
|
}
|
|
show_when_filled?.forEach((f) => controlledFields.add(f));
|
|
|
|
// Determine which fields should be visible
|
|
if (show_when_filled?.length && sourceValue != null && sourceValue !== "") {
|
|
show_when_filled.forEach((f) => visibleFields.add(f));
|
|
}
|
|
|
|
if (mapping) {
|
|
// Handle both direct value match and boolean string conversion
|
|
const matchedFields =
|
|
mapping[sourceValue] ?? (typeof sourceValue === "boolean" ? mapping[String(sourceValue)] : null);
|
|
matchedFields?.forEach((f) => visibleFields.add(f));
|
|
}
|
|
}
|
|
|
|
// Apply visibility to widgets
|
|
for (const widget of node.widgets ?? []) {
|
|
if (!controlledFields.has(widget.name)) continue;
|
|
|
|
// Preserve original widget state only once
|
|
if (!(ORIG_TYPE in widget)) {
|
|
widget[ORIG_TYPE] = widget.type;
|
|
}
|
|
if (!(ORIG_COMPUTE_SIZE in widget)) {
|
|
widget[ORIG_COMPUTE_SIZE] = widget.computeSize;
|
|
widget[ORIG_HAS_COMPUTE_SIZE] = Object.prototype.hasOwnProperty.call(widget, "computeSize");
|
|
}
|
|
|
|
const visible = visibleFields.has(widget.name);
|
|
widget.hidden = !visible;
|
|
widget.type = visible ? widget[ORIG_TYPE] : "converted-widget";
|
|
if (visible) {
|
|
if (widget[ORIG_HAS_COMPUTE_SIZE]) {
|
|
widget.computeSize = widget[ORIG_COMPUTE_SIZE];
|
|
} else {
|
|
delete widget.computeSize;
|
|
}
|
|
} else {
|
|
widget.computeSize = () => [0, -4];
|
|
}
|
|
}
|
|
|
|
node.setSize(node.computeSize());
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Detect the model source type from a URL by matching against known host patterns.
|
|
*
|
|
* @param {string} url - The URL to analyze
|
|
* @param {Object<string, string>} hostPatterns - Map of source names to hostname patterns
|
|
* @returns {string} The detected source name or "custom" if no match
|
|
*/
|
|
function detectSourceFromUrl(url, hostPatterns) {
|
|
try {
|
|
const hostname = new URL(url).hostname.toLowerCase();
|
|
for (const [source, pattern] of Object.entries(hostPatterns)) {
|
|
if (hostname.includes(pattern)) return source;
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
return "custom";
|
|
}
|
|
|
|
/**
|
|
* Parse a HuggingFace URL to extract repository ID and filename.
|
|
*
|
|
* Supported formats:
|
|
* - https://huggingface.co/{org}/{repo}/resolve/main/{path}
|
|
* - https://huggingface.co/{org}/{repo}/blob/main/{path}
|
|
*
|
|
* @param {string} url - HuggingFace URL to parse
|
|
* @returns {{repo_id: string, filename: string}|null} Parsed data or null if invalid
|
|
*/
|
|
function parseHuggingFaceUrl(url) {
|
|
try {
|
|
const parts = new URL(url).pathname.split("/").filter(Boolean);
|
|
if (parts.length < 5 || !["resolve", "blob", "tree"].includes(parts[2])) return null;
|
|
return { repo_id: `${parts[0]}/${parts[1]}`, filename: parts.slice(4).join("/") };
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a CivitAI URL to extract the model version ID.
|
|
*
|
|
* Supported formats:
|
|
* - https://civitai.com/api/download/models/{version_id}
|
|
* - https://civitai.com/models/{id}?modelVersionId={version_id}
|
|
*
|
|
* @param {string} url - CivitAI URL to parse
|
|
* @returns {{version_id: string}|null} Parsed data or null if invalid
|
|
*/
|
|
function parseCivitAIUrl(url) {
|
|
try {
|
|
const urlObj = new URL(url);
|
|
const apiMatch = urlObj.pathname.match(/\/api\/download\/models\/(\d+)/);
|
|
if (apiMatch) return { version_id: apiMatch[1] };
|
|
|
|
if (urlObj.pathname.startsWith("/models/")) {
|
|
const versionId = urlObj.searchParams.get("modelVersionId");
|
|
if (versionId) return { version_id: versionId };
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Extract the filename from a URL's path.
|
|
*
|
|
* @param {string} url - URL to extract filename from
|
|
* @returns {string|null} The decoded filename or null if not found
|
|
*/
|
|
function extractFilenameFromUrl(url) {
|
|
try {
|
|
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
if (segments.length === 0) return null;
|
|
const filename = decodeURIComponent(segments.at(-1)).split("?")[0];
|
|
return filename || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Map of source types to their URL parser functions */
|
|
const URL_PARSERS = {
|
|
huggingface: parseHuggingFaceUrl,
|
|
civitai: parseCivitAIUrl,
|
|
};
|
|
|
|
/**
|
|
* Parse a URL based on detected source and populate node widgets with extracted data.
|
|
* Also resets widgets specified in the parser config's reset array.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {string} url - The URL to parse
|
|
* @param {string} source - The detected source type (huggingface, civitai, custom)
|
|
* @param {Object} parsers - Parser configurations keyed by source type
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
function parseAndPopulateUrl(node, url, source, parsers, app) {
|
|
const config = parsers[source];
|
|
if (!config) return;
|
|
|
|
// Parse URL using source-specific parser or fallback to filename extraction
|
|
const parser = URL_PARSERS[source];
|
|
const parsed = parser ? parser(url) : { filename: extractFilenameFromUrl(url) };
|
|
|
|
// Populate widgets with parsed values
|
|
if (parsed && config.populate) {
|
|
for (const [key, widgetName] of Object.entries(config.populate)) {
|
|
const widget = node.widgets?.find((w) => w.name === widgetName);
|
|
if (widget && parsed[key]) widget.value = parsed[key];
|
|
}
|
|
}
|
|
|
|
// Reset specified widgets
|
|
config.reset?.forEach((name) => {
|
|
const widget = node.widgets?.find((w) => w.name === name);
|
|
if (widget) widget.value = "";
|
|
});
|
|
|
|
app.graph.setDirtyCanvas(true, true);
|
|
}
|
|
|
|
/**
|
|
* Setup URL input handler that auto-detects source and populates fields.
|
|
* Hooks into the URL widget's callback to process URL changes.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Object} config - URL handler configuration containing:
|
|
* - source_field: Name of widget to store detected source
|
|
* - host_patterns: Map of source names to hostname patterns
|
|
* - parsers: Parser configurations for each source type
|
|
* @param {Array<Object>|null} visibilityRules - Optional visibility rules to apply after URL change
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
function setupUrlHandler(node, { source_field, host_patterns, parsers }, visibilityRules, app) {
|
|
const urlWidget = node.widgets?.find((w) => w.name === "url");
|
|
const sourceWidget = node.widgets?.find((w) => w.name === source_field);
|
|
if (!urlWidget || !sourceWidget) return;
|
|
|
|
let previousUrl = urlWidget.value || "";
|
|
let processing = false;
|
|
|
|
const originalCallback = urlWidget.callback;
|
|
urlWidget.callback = (value) => {
|
|
originalCallback?.apply(urlWidget, [value]);
|
|
if (!value || processing || value === previousUrl) return;
|
|
|
|
processing = true;
|
|
previousUrl = value;
|
|
|
|
try {
|
|
const source = detectSourceFromUrl(value, host_patterns);
|
|
if (sourceWidget.value !== source) sourceWidget.value = source;
|
|
|
|
parseAndPopulateUrl(node, value, source, parsers, app);
|
|
if (visibilityRules) updateVisibility(node, visibilityRules, app);
|
|
} finally {
|
|
processing = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Setup a dynamic info button that shows context-sensitive help dialogs.
|
|
* The button label and content change based on the source widget's value.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Object} config - Info button configuration containing:
|
|
* - source_field: Name of widget that determines which help to show
|
|
* - configs: Map of source values to {label, title, template} objects
|
|
*/
|
|
function setupInfoButton(node, { source_field, configs }) {
|
|
const sourceWidget = node.widgets?.find((w) => w.name === source_field);
|
|
if (!sourceWidget) return;
|
|
|
|
const getConfig = () => configs[sourceWidget.value || ""] ?? configs[""] ?? null;
|
|
const initialConfig = getConfig();
|
|
if (!initialConfig) return;
|
|
|
|
const infoButton = node.addWidget("button", initialConfig.label, null, () => {
|
|
const cfg = getConfig();
|
|
if (!cfg) return;
|
|
const content = cloneTemplate(cfg.template)?.innerHTML ?? "";
|
|
showInfoDialog(cfg.title, content);
|
|
});
|
|
infoButton.serializeValue = () => undefined;
|
|
|
|
// Update button label when source changes
|
|
const originalCallback = sourceWidget.callback;
|
|
sourceWidget.callback = (value) => {
|
|
originalCallback?.apply(sourceWidget, [value]);
|
|
const cfg = getConfig();
|
|
if (cfg) infoButton.name = cfg.label;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Setup automatic visibility updates by hooking into source widget callbacks.
|
|
* Triggers updateVisibility whenever any source field changes.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Array<Object>} visibilityRules - Array of visibility rule configurations
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
function setupVisibility(node, visibilityRules, app) {
|
|
const sourceFields = new Set(visibilityRules.map((r) => r.source_field));
|
|
|
|
for (const fieldName of sourceFields) {
|
|
const widget = node.widgets?.find((w) => w.name === fieldName);
|
|
if (!widget) continue;
|
|
|
|
const originalCallback = widget.callback;
|
|
widget.callback = (value) => {
|
|
originalCallback?.apply(widget, [value]);
|
|
updateVisibility(node, visibilityRules, app);
|
|
};
|
|
}
|
|
|
|
// Apply initial visibility
|
|
updateVisibility(node, visibilityRules, app);
|
|
}
|
|
|
|
/**
|
|
* Setup reset rules that clear specified widgets when source fields change.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Array<Object>} resetRules - Array of reset rule objects containing:
|
|
* - source_field: Widget name to watch for changes
|
|
* - reset_fields: Array of widget names to clear when source changes
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
function setupResetRules(node, resetRules, app) {
|
|
for (const { source_field, reset_fields } of resetRules) {
|
|
const sourceWidget = node.widgets?.find((w) => w.name === source_field);
|
|
if (!sourceWidget || !reset_fields?.length) continue;
|
|
|
|
let previousValue = sourceWidget.value ?? "";
|
|
|
|
const originalCallback = sourceWidget.callback;
|
|
sourceWidget.callback = (value) => {
|
|
originalCallback?.apply(sourceWidget, [value]);
|
|
|
|
// Skip if value hasn't actually changed
|
|
if (value === previousValue) return;
|
|
|
|
// Reset specified fields
|
|
for (const name of reset_fields) {
|
|
const w = node.widgets?.find((w) => w.name === name);
|
|
if (w) w.value = "";
|
|
}
|
|
|
|
previousValue = value;
|
|
app.graph.setDirtyCanvas(true, true);
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Main setup function for RTX Remix nodes.
|
|
* Fetches UI configuration from the backend and applies interactive behaviors
|
|
* including URL handling, visibility rules, and info buttons.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance to configure
|
|
* @param {Object} app - The ComfyUI app instance
|
|
* @returns {Promise<void>}
|
|
*/
|
|
export async function setupNode(node, app) {
|
|
if (node[SETUP_COMPLETE]) return;
|
|
node[SETUP_COMPLETE] = true;
|
|
|
|
const config = await fetchFrontendConfig(node.comfyClass);
|
|
if (!config) return;
|
|
|
|
if (config.url_handler) setupUrlHandler(node, config.url_handler, config.visibility_rules, app);
|
|
if (config.visibility_rules) setupVisibility(node, config.visibility_rules, app);
|
|
if (config.reset_rules) setupResetRules(node, config.reset_rules, app);
|
|
if (config.info_button) setupInfoButton(node, config.info_button);
|
|
}
|
|
|
|
/**
|
|
* Initialize API event listeners for node input updates from the backend.
|
|
* Handles real-time value updates pushed from Python to the frontend.
|
|
*
|
|
* @param {Object} app - The ComfyUI app instance
|
|
*/
|
|
export function setupApiListeners(app) {
|
|
app.api.addEventListener(EVENTS.UPDATE_NODE_INPUT, (event) => {
|
|
const { node_id, input_name, value } = event.detail;
|
|
const node = app.graph.getNodeById(node_id);
|
|
if (!node) return;
|
|
|
|
const widget = node.widgets?.find((w) => w.name === input_name);
|
|
if (!widget) return;
|
|
|
|
widget.value = value;
|
|
widget.callback?.(value);
|
|
app.graph.setDirtyCanvas(true, true);
|
|
app.graph.change?.();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load RTX Remix metadata when a node is restored from a saved workflow.
|
|
* Transfers persisted metadata from nodeData to the live node properties.
|
|
*
|
|
* @param {Object} node - The LiteGraph node instance
|
|
* @param {Object} nodeData - The serialized node data from the workflow
|
|
*/
|
|
export function loadNodeMetadata(node, nodeData) {
|
|
const remixData = nodeData.properties?.[REMIX_KEYS.ROOT];
|
|
if (!remixData) return;
|
|
|
|
node.properties ??= {};
|
|
node.properties[REMIX_KEYS.ROOT] ??= {};
|
|
|
|
const { INPUTS, OUTPUT } = REMIX_KEYS.STRUCTURE;
|
|
if (remixData[INPUTS]) {
|
|
node.properties[REMIX_KEYS.ROOT][INPUTS] = remixData[INPUTS];
|
|
}
|
|
if (remixData[OUTPUT]) {
|
|
node.properties[REMIX_KEYS.ROOT][OUTPUT] = remixData[OUTPUT];
|
|
}
|
|
}
|