/* * 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} 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} 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} 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|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} 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} 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} */ 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]; } }