/* * SPDX-FileCopyrightText: Copyright (c) 2025 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. */ import { api } from "../../../scripts/api.js"; import { API_ENDPOINTS, COMFYUI_INPUT_TYPE_MAP, COMFYUI_OUTPUT_TYPE_MAP, EVENTS, REMIX_TYPE, REMIX_KEYS, TEMPLATE_IDS, } from "../utils/constants.js"; import { cloneTemplate, bindTemplateData } from "../utils/html.js"; import { createSlotLabelCell, createInputCell, createTextCell, createSelectCell, createButtonCell, setRowDataAttributes, } from "./slotRowController.js"; import { addRemixMetadataToPrompt, applySlotEditsToGraphNodes, deriveWorkflowFilename, extractTaggedSlots, getApplicableMetadataFields, updateGroupOrder, } from "../cores/workflowExportCore.js"; import { getGroupOrder, getWorkflowInfo, setWorkflowInfo } from "../stores/graphStore.js"; import { createGroupedList } from "./groupedListController.js"; import { updateInputMetadata } from "../cores/metadataEditorCore.js"; import { createGroupPicker } from "./groupPickerController.js"; import { closePopover, isPopoverOpenFor, openPopoverWithContent } from "./popoverController.js"; import { cleanupDeletedInputs } from "../cores/presetCore.js"; import { handlePendingChangesBeforeAction } from "./presetSidebarController.js"; /** * Initialize the export controller - registers event listeners. * @param {Object} app - ComfyUI app instance */ export function initExportController(app) { app.api.addEventListener(EVENTS.EXPORT_WORKFLOW_REQUESTED, () => exportWorkflow(app)); } /** * Export workflow handler - retrieves current workflow name and shows export dialog * @param {Object} app - ComfyUI app instance */ export async function exportWorkflow(app) { // Check for pending preset changes before export const canProceed = await handlePendingChangesBeforeAction(app); if (!canProceed) { return; // User cancelled, don't show export dialog } // Prefill from the graph metadata, falling back to the active workflow filename const info = getWorkflowInfo(app); const displayName = info.displayName || app.extensionManager?.workflow?.activeWorkflow?.filename || ""; // The workflow types and their categories are owned by the backend enum - never hardcode them here let typeCategories = []; try { const response = await api.fetchApi(API_ENDPOINTS.WORKFLOW_TYPES); const payload = await response.json(); typeCategories = payload?.categories || []; } catch (error) { console.error("Failed to load RTX Remix workflow types:", error); } // Show custom export dialog which handles the entire export process await showExportDialog({ app, typeCategories, displayName, description: info.description, workflowType: info.workflowType, }); } /** * Create metadata accordion with dynamic fields based on slot context * @param {Object} options - Accordion options * @param {Object} options.app - ComfyUI app instance (for group picker) * @param {Object} options.context - Slot context * @param {Object} options.slotData - Slot data * @param {boolean} [options.includeGroup=true] - Whether to include the group field * @returns {HTMLElement} Accordion element */ function createMetadataAccordion({ app, context, slotData, includeGroup = true }) { const accordion = cloneTemplate(TEMPLATE_IDS.METADATA_ACCORDION); if (!accordion) { console.error("Failed to load metadata accordion template"); return null; } const form = accordion.querySelector('[data-element="form"]'); // Get applicable fields const applicableFields = getApplicableMetadataFields(context); // Create field for each applicable metadata applicableFields.forEach((fieldConfig) => { // Skip group field if not included if (!includeGroup && fieldConfig.key === REMIX_KEYS.PROPERTY.ADDITIONAL_DATA.GROUP) { return; } const fieldDiv = document.createElement("div"); fieldDiv.className = "rtx-remix-metadata-field"; const label = document.createElement("label"); label.className = "rtx-remix-metadata-label"; label.textContent = fieldConfig.label; fieldDiv.appendChild(label); // Get current value from slotData additional_data or use computed default const currentValue = slotData.additionalData?.[fieldConfig.key] ?? fieldConfig.computedDefault; // Use group picker for the group field if (fieldConfig.key === REMIX_KEYS.PROPERTY.ADDITIONAL_DATA.GROUP) { const picker = createGroupPicker({ app, currentValue: currentValue || "", onChange: (newGroup) => { // Value is stored in data attribute for form extraction picker.dataset.selectedGroup = newGroup || ""; }, }); picker.dataset.fieldKey = fieldConfig.key; picker.dataset.nodeId = context.nodeId; picker.dataset.slotName = context.slotName || ""; picker.dataset.isInput = context.isInput; picker.dataset.isAdditionalData = "true"; picker.dataset.selectedGroup = currentValue || ""; fieldDiv.appendChild(picker); } else { // Standard input field let input; if (fieldConfig.inputType === "textarea") { input = document.createElement("textarea"); input.className = "rtx-remix-metadata-input"; input.rows = 3; } else { input = document.createElement("input"); input.className = "rtx-remix-metadata-input"; input.type = fieldConfig.inputType; } input.dataset.fieldKey = fieldConfig.key; input.dataset.nodeId = context.nodeId; input.dataset.slotName = context.slotName || ""; input.dataset.isInput = context.isInput; if (currentValue !== null && currentValue !== undefined) { input.value = currentValue; } fieldDiv.appendChild(input); } form.appendChild(fieldDiv); }); return accordion; } /** * Shows a rich export dialog for RTX Remix workflows and handles the entire export process * @param {Object} options - Dialog options * @param {Object} options.app - The ComfyUI app instance * @param {Array<{name: string, types: Array<{value: string, description: string}>}>} [options.typeCategories] * - Selectable workflow types grouped by category, each type with its tooltip * @param {string} [options.displayName] - Prefilled workflow display name * @param {string} [options.description] - Prefilled workflow description * @param {string} [options.workflowType] - Prefilled workflow type value * @returns {Promise} - True if export succeeded, false if cancelled or failed */ export async function showExportDialog({ app, typeCategories = [], displayName = "", description = "", workflowType = "", } = {}) { return new Promise((resolve) => { // Clone the dialog template const overlay = cloneTemplate(TEMPLATE_IDS.EXPORT_DIALOG); if (!overlay) { console.error("Failed to load export dialog template"); resolve(false); return; } // Bind assets (icon) bindTemplateData(overlay, {}); // Get references to elements const dialog = overlay.querySelector(".rtx-remix-dialog"); const displayNameInput = overlay.querySelector('[data-input="display-name"]'); const typeTrigger = overlay.querySelector('[data-input="workflow-type"]'); const typeValueLabel = overlay.querySelector('[data-element="workflow-type-value"]'); const descriptionInput = overlay.querySelector('[data-input="description"]'); const displayNameError = overlay.querySelector('[data-element="display-name-error"]'); const typeError = overlay.querySelector('[data-element="workflow-type-error"]'); const validationMsg = overlay.querySelector('[data-element="validation"]'); const infoMsg = overlay.querySelector('[data-element="info"]'); const cancelBtn = overlay.querySelector('[data-action="cancel"]'); const exportBtn = overlay.querySelector('[data-action="export"]'); // Slots section elements const slotsSection = overlay.querySelector('[data-element="slots-section"]'); const inputsWrapper = overlay.querySelector('[data-element="inputs-wrapper"]'); const outputsWrapper = overlay.querySelector('[data-element="outputs-wrapper"]'); // Note: inputs use grouped list (no inputsTbody), outputs use traditional tbody const outputsTbody = overlay.querySelector('[data-element="outputs-tbody"]'); let isLoading = false; // Set up initial values displayNameInput.value = displayName; descriptionInput.value = description; // A stale or unknown stored value shows the placeholder, forcing a re-pick const typeDescriptions = new Map( typeCategories.flatMap(({ types }) => types.map(({ value, description }) => [value, description || ""])) ); let selectedType = typeDescriptions.has(workflowType) ? workflowType : ""; function renderSelectedType() { typeValueLabel.textContent = selectedType || "Select a type..."; typeValueLabel.classList.toggle("placeholder", selectedType === ""); // The type explains itself on hover, so the dialog spends no line on it typeTrigger.title = typeDescriptions.get(selectedType) || ""; } renderSelectedType(); // A native