REMIX-5815: Add workflow display metadata and typed workflows

This commit is contained in:
Pierre-Olivier Trottier
2026-08-19 17:11:32 -07:00
parent ec5e6c99b9
commit 7bc3ec0874
16 changed files with 624 additions and 93 deletions
+25
View File
@@ -15,6 +15,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Removed
## [3.1.0] - 2026-08-19
### Added
- **Workflow Details**: The export dialog collects a display name, a workflow type and a description. The description
is the workflow tooltip in the RTX Remix Toolkit, and the `.json` file name is derived from the display name
- **Workflow Types**: New `GET /rtx-remix/v1/workflows/types` endpoint listing the workflow types grouped by category
(Generation, Upscaling, Miscellaneous, Other), each with its own description
- **Workflow Metadata in the Listing**: `GET /rtx-remix/v1/workflows` returns `displayName`, `description` and
`workflowType` for every workflow. Workflows without metadata report their file name and no type
- **Workflow Listing Filters**: `GET /rtx-remix/v1/workflows` accepts `pathType`, `sourceType`, `workflowType` and
`search`, each repeatable and comma-separated
- **Bundled Workflows**: PBR Fusion 4 and PBRify carry display metadata
### Changed
- **Export Dialog**: The workflow file name field is replaced by Display Name, Workflow Type and Description. Display
name and workflow type are mandatory, so Export stays disabled until both are set
- **Workflow Save Endpoint**: `POST /rtx-remix/v1/workflows/save` takes `displayName`, `description` and
`workflowType` instead of `name`, and derives the file name from the display name
### Fixed
- Workflow names that point outside the workflow directory are now rejected when reading a workflow
## [3.0.2] - 2026-08-06
### Fixed
+2 -1
View File
@@ -189,7 +189,7 @@ Once you've tagged your inputs and outputs, export the workflow for use in the R
- Options to edit export names and RTX Remix types
- Additional metadata fields (description, min/max values, texture types for outputs, etc.)
4. Configure texture types for outputs if needed (especially important when using standard save nodes instead of `RTX Remix Save Texture`)
5. Enter a filename for your workflow
5. Enter the **Display Name** (the name shown in the RTX Remix Toolkit; the `.json` filename is derived from it), pick a **Workflow Type**, and optionally add a **Description** (used as the workflow tooltip in the RTX Remix Toolkit)
6. Click **"Export"**
The exported workflow is saved to the `user/rtx-remix/workflows/` and `user/rtx-remix/api_workflows/` directories in the ComfyUI installation and includes:
@@ -207,6 +207,7 @@ The exported workflow is saved to the `user/rtx-remix/workflows/` and `user/rtx-
- **Reorder inputs/outputs** - Drag and drop rows to change order within a group
- **Reorder groups** - Drag group headers to change the order of entire groups
- **Expand metadata** - Click the chevron to see and edit additional fields
- **Author workflow details** - Display name, workflow type and description are stored in the workflow and returned by the workflow list endpoint
### Using Your Workflow in the RTX Remix Toolkit
+1 -1
View File
@@ -1 +1 @@
3.0.2
3.1.0
+2 -1
View File
@@ -16,12 +16,13 @@
"""
from .ui import get_all_ui, get_node_ui
from .workflows import get_workflow, list_workflows, save_workflow
from .workflows import get_workflow, list_workflow_types, list_workflows, save_workflow
__all__ = [
"get_all_ui",
"get_node_ui",
"get_workflow",
"list_workflow_types",
"list_workflows",
"save_workflow",
]
+43 -1
View File
@@ -15,13 +15,55 @@
* limitations under the License.
"""
__all__ = ["get_base_url", "WORKFLOWS_PREFIX", "API_WORKFLOWS_DIR", "WORKFLOWS_DIR", "REMIX_USER_ID"]
__all__ = [
"API_WORKFLOWS_DIR",
"DESCRIPTION_KEY",
"DISPLAY_NAME_KEY",
"REMIX_EXTRA_KEY",
"REMIX_USER_ID",
"WORKFLOWS_DIR",
"WORKFLOWS_PREFIX",
"WORKFLOW_TYPES_BY_CATEGORY",
"WORKFLOW_TYPE_KEY",
"get_base_url",
]
from .enums import WorkflowType
WORKFLOWS_PREFIX = "workflows"
API_WORKFLOWS_DIR = "api_workflows"
WORKFLOWS_DIR = "workflows"
REMIX_USER_ID = "rtx-remix"
# Keys of the export metadata, stored under extra[REMIX_EXTRA_KEY] of a full workflow
REMIX_EXTRA_KEY = "rtx-remix"
DISPLAY_NAME_KEY = "displayName"
DESCRIPTION_KEY = "description"
WORKFLOW_TYPE_KEY = "workflowType"
# Display order of the picker: category -> type -> the tooltip shown next to the type. Every member
# of WorkflowType must appear exactly once.
WORKFLOW_TYPES_BY_CATEGORY: dict[str, dict[WorkflowType, str]] = {
"Generation": {
WorkflowType.ASSET_GENERATION: "Creates a full asset, including its mesh and textures, from any mix of inputs.",
WorkflowType.MATERIAL_GENERATION: "Creates a PBR material from any mix of inputs, such as a texture or prompt.",
},
"Upscaling": {
WorkflowType.ASSET_UPSCALING: "Increases the detail of a full asset, including its mesh and textures.",
WorkflowType.MESH_UPSCALING: "Increases the geometric detail of a mesh.",
WorkflowType.TEXTURE_UPSCALING: "Increases the detail of a texture.",
},
"Miscellaneous": {
WorkflowType.ASSET_TAGGING: "Takes a material, a texture or a full asset and returns tags for it.",
},
"Other": {
WorkflowType.OTHER: "Anything that no other type describes.",
},
}
# A type missing from the map would silently vanish from the picker, so fail loudly at import.
assert tuple(WorkflowType) == tuple(item for types in WORKFLOW_TYPES_BY_CATEGORY.values() for item in types)
def get_base_url(version: int = 1) -> str:
return f"/rtx-remix/v{version}"
+18 -1
View File
@@ -15,7 +15,7 @@
* limitations under the License.
"""
__all__ = ["PathType", "SourceType"]
__all__ = ["PathType", "SourceType", "WorkflowType"]
from enum import Enum
@@ -32,3 +32,20 @@ class SourceType(Enum):
USER = "user"
RTX_REMIX = "rtx-remix"
class WorkflowType(Enum):
"""Enumeration for the workflow types authored at export time.
The value is both the serialized form and the label shown in the RTX Remix Toolkit picker, so
nothing derives a display string from it. Add members here only, and describe them in
WORKFLOW_TYPES_BY_CATEGORY.
"""
ASSET_GENERATION = "Asset Generation"
MATERIAL_GENERATION = "Material Generation"
ASSET_UPSCALING = "Asset Upscaling"
MESH_UPSCALING = "Mesh Upscaling"
TEXTURE_UPSCALING = "Texture Upscaling"
ASSET_TAGGING = "Asset Tagging"
OTHER = "Other"
+187 -23
View File
@@ -15,9 +15,10 @@
* limitations under the License.
"""
__all__ = ["list_workflows", "get_workflow", "save_workflow"]
__all__ = ["list_workflow_types", "list_workflows", "get_workflow", "save_workflow"]
import json
import re
from pathlib import Path
from typing import Any
@@ -26,8 +27,19 @@ from aiohttp import web
import folder_paths
from server import PromptServer
from .constants import get_base_url, WORKFLOWS_PREFIX, API_WORKFLOWS_DIR, WORKFLOWS_DIR, REMIX_USER_ID
from .enums import PathType, SourceType
from .constants import (
get_base_url,
API_WORKFLOWS_DIR,
DESCRIPTION_KEY,
DISPLAY_NAME_KEY,
REMIX_EXTRA_KEY,
REMIX_USER_ID,
WORKFLOWS_DIR,
WORKFLOWS_PREFIX,
WORKFLOW_TYPE_KEY,
WORKFLOW_TYPES_BY_CATEGORY,
)
from .enums import PathType, SourceType, WorkflowType
prompt_server = PromptServer.instance
routes = prompt_server.routes
@@ -150,15 +162,105 @@ def _save_workflow_file(file_path: Path, workflow_data: dict[str, Any]):
json.dump(workflow_data, f, indent=2)
def _safe_stem(workflow_name: str) -> str | None:
"""Return a filename stem safe to join with a workflow directory, or None if it escapes it."""
stem = workflow_name.strip()
if stem.lower().endswith(".json"):
stem = stem[:-5]
if not stem or "/" in stem or "\\" in stem or stem.strip(".") == "":
return None
return stem
def _query_values(request: web.Request, key: str) -> list[str]:
"""Collect repeated and comma-separated query values for a single key."""
return [part.strip() for raw in request.query.getall(key, []) for part in raw.split(",") if part.strip()]
def _derive_filename(display_name: str) -> str:
"""
Derive the on-disk workflow stem from a display name.
"PBR Fusion 4 v1.0.2" -> "pbr_fusion_4_v1_0_2". Names with no ASCII alphanumerics
(for example non-Latin scripts) collapse to "workflow".
"""
return re.sub(r"[^a-z0-9]+", "_", display_name.strip().lower()).strip("_") or "workflow"
def _read_metadata(workflow_data: Any, fallback_display_name: str) -> dict[str, Any]:
"""
Read export metadata from a full workflow, tolerating legacy and malformed data.
Legacy and malformed fallbacks: displayName -> fallback_display_name (the filename stem),
description -> "", workflowType -> None so consumers fall back to their own grouping instead
of guessing a type.
"""
extra = workflow_data.get("extra") if isinstance(workflow_data, dict) else None
remix = extra.get(REMIX_EXTRA_KEY) if isinstance(extra, dict) else None
remix = remix if isinstance(remix, dict) else {}
display_name = remix.get(DISPLAY_NAME_KEY)
description = remix.get(DESCRIPTION_KEY)
try:
workflow_type = WorkflowType(remix.get(WORKFLOW_TYPE_KEY))
except (ValueError, TypeError): # unknown value, missing key, or unhashable garbage
workflow_type = None
return {
DISPLAY_NAME_KEY: (
display_name.strip() if isinstance(display_name, str) and display_name.strip() else fallback_display_name
),
DESCRIPTION_KEY: description if isinstance(description, str) else "",
WORKFLOW_TYPE_KEY: workflow_type.value if workflow_type else None,
}
@routes.get(f"{get_base_url()}/{WORKFLOWS_PREFIX}/types")
async def list_workflow_types(_request: web.Request) -> web.Response:
"""
List the valid workflow types, grouped by category and in display order.
Returns:
JSON response containing a "categories" list of {"name": ..., "types": [...]} entries, where
each type is {"value": ..., "description": ...}. A type value is its own label, so clients
display it as it comes, and the description is its tooltip.
"""
categories = [
{
"name": category,
"types": [{"value": item.value, DESCRIPTION_KEY: description} for item, description in types.items()],
}
for category, types in WORKFLOW_TYPES_BY_CATEGORY.items()
]
return _success_response({"categories": categories})
@routes.get(f"{get_base_url()}/{WORKFLOWS_PREFIX}")
async def list_workflows(_request: web.Request) -> web.Response:
async def list_workflows(request: web.Request) -> web.Response:
"""
List all workflows from both user and rtx-remix node pack directories.
Query Parameters (all optional, repeatable and comma-separated):
pathType: Keep only these path types ("api", "full").
sourceType: Keep only these sources ("user", "rtx-remix").
workflowType: Keep only workflows with these types, matched exactly as the "types" endpoint
spells them, for example "Material Generation". Workflows without a type never match.
search: Case-insensitive substring matched against the filename, display name and
description.
Returns:
JSON response containing workflows organized by path type first,
then by source (user vs rtx-remix)
then by source (user vs rtx-remix). Filtered out buckets are present but empty.
"""
try:
path_filter = {PathType(value.lower()) for value in _query_values(request, "pathType")}
source_filter = {SourceType(value.lower()) for value in _query_values(request, "sourceType")}
type_filter = {WorkflowType(value).value for value in _query_values(request, WORKFLOW_TYPE_KEY)}
except ValueError as e:
return _error_response(f"Invalid filter value: {str(e)}", status=400)
search = request.query.get("search", "").strip().lower()
# Initialize structure: path_type -> source -> list of workflows
workflows = {
PathType.API.value: {
@@ -174,9 +276,29 @@ async def list_workflows(_request: web.Request) -> web.Response:
# Get all workflow directories (path_type -> source -> directory)
all_directories = _get_workflow_directories()
# Metadata only exists in the full workflows (API workflow files are flat prompt dicts with no
# "extra"), so index it by filename stem and reuse it for the matching API entries, which the
# save endpoint always writes under the same stem.
# ponytail: parses every full workflow per call, add an mtime keyed cache if profiling shows it
metadata_by_source: dict[SourceType, dict[str, dict[str, Any]]] = {}
for source, directory in all_directories[PathType.FULL].items():
entries: dict[str, dict[str, Any]] = {}
if (not source_filter or source in source_filter) and directory.exists():
for file_path in sorted(directory.glob("*.json")):
try:
workflow_data = _load_workflow_file(file_path)
except (OSError, IOError, json.JSONDecodeError):
workflow_data = {}
entries[file_path.stem] = _read_metadata(workflow_data, file_path.stem)
metadata_by_source[source] = entries
# Iterate through path types and sources
for path_type, source_dirs in all_directories.items():
if path_filter and path_type not in path_filter:
continue
for source, directory in source_dirs.items():
if source_filter and source not in source_filter:
continue
if not directory.exists():
continue
@@ -184,18 +306,28 @@ async def list_workflows(_request: web.Request) -> web.Response:
try:
stat = file_path.stat()
relative_path = file_path.relative_to(_get_comfyui_directory())
workflows[path_type.value][source.value].append(
{
"name": file_path.stem,
"path": relative_path.as_posix(),
"size": stat.st_size,
"modified": stat.st_mtime,
}
)
except (OSError, IOError):
# Skip files that can't be accessed
continue
metadata = metadata_by_source[source].get(file_path.stem) or _read_metadata({}, file_path.stem)
if type_filter and metadata[WORKFLOW_TYPE_KEY] not in type_filter:
continue
if search:
haystack = f"{file_path.stem} {metadata[DISPLAY_NAME_KEY]} {metadata[DESCRIPTION_KEY]}".lower()
if search not in haystack:
continue
workflows[path_type.value][source.value].append(
{
"name": file_path.stem,
"path": relative_path.as_posix(),
"size": stat.st_size,
"modified": stat.st_mtime,
**metadata,
}
)
return _success_response({"workflows": workflows})
@@ -246,9 +378,10 @@ async def get_workflow(request: web.Request) -> web.Response:
status=400,
)
# Remove extension if provided
if workflow_name.lower().endswith(".json"):
workflow_name = workflow_name[:-5]
stem = _safe_stem(workflow_name)
if stem is None:
return _error_response(f"Invalid workflow name: {workflow_name}", status=400)
workflow_name = stem
all_directories = _get_workflow_directories()
workflow_directory = all_directories[path_type][source_type]
@@ -288,7 +421,9 @@ async def save_workflow(request: web.Request) -> web.Response:
rtx-remix node pack.
Request body:
name: Name of the workflow to save
displayName: User facing workflow name, the filename is derived from it
description: Workflow description, shown as the workflow tooltip in the RTX Remix Toolkit
workflowType: One of the type values returned by the workflow "types" endpoint
workflows: Dictionary containing the workflow data for each path type
Returns:
@@ -299,15 +434,26 @@ async def save_workflow(request: web.Request) -> web.Response:
except json.JSONDecodeError:
return _error_response("Invalid JSON in request body", status=400)
workflow_name = data.get("name", "").strip()
display_name = data.get(DISPLAY_NAME_KEY)
display_name = display_name.strip() if isinstance(display_name, str) else ""
description = data.get(DESCRIPTION_KEY)
description = description.strip() if isinstance(description, str) else ""
type_value = data.get(WORKFLOW_TYPE_KEY)
workflows = data.get("workflows", {})
if not workflow_name:
return _error_response("Workflow name is required", status=400)
if not display_name:
return _error_response("Workflow display name is required", status=400)
# Remove extension if provided
if workflow_name.lower().endswith(".json"):
workflow_name = workflow_name[:-5]
try:
workflow_type = WorkflowType(type_value)
except (ValueError, TypeError):
return _error_response(
f"Invalid workflow type: {type_value}. "
f"Valid workflow types are: {', '.join([item.value for item in WorkflowType])}",
status=400,
)
workflow_name = _derive_filename(display_name)
# Get all workflow directories (path_type -> source -> directory)
all_directories = _get_workflow_directories()
@@ -331,6 +477,21 @@ async def save_workflow(request: web.Request) -> web.Response:
user_workflow_dir = all_directories[path_type][SourceType.USER]
file_path = Path((user_workflow_dir / workflow_name).as_posix() + ".json")
# Stamp the metadata into the full workflow so the workflow list is correct
# regardless of what the client serialized
if path_type is PathType.FULL and isinstance(workflow_data, dict):
extra = workflow_data.get("extra")
if not isinstance(extra, dict):
extra = {}
workflow_data["extra"] = extra
remix_extra = extra.get(REMIX_EXTRA_KEY)
if not isinstance(remix_extra, dict):
remix_extra = {}
extra[REMIX_EXTRA_KEY] = remix_extra
remix_extra[DISPLAY_NAME_KEY] = display_name
remix_extra[DESCRIPTION_KEY] = description
remix_extra[WORKFLOW_TYPE_KEY] = workflow_type.value
_save_workflow_file(file_path, workflow_data)
relative_path = file_path.relative_to(_get_comfyui_directory())
@@ -342,6 +503,9 @@ async def save_workflow(request: web.Request) -> web.Response:
{
"message": "Workflow saved successfully",
"name": workflow_name,
DISPLAY_NAME_KEY: display_name,
DESCRIPTION_KEY: description,
WORKFLOW_TYPE_KEY: workflow_type.value,
"workflows": response_data,
}
)
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "ComfyUI-RTX-Remix"
description = "Use ComfyUI with the RTX Remix Toolkit to remaster classic games."
version = "3.0.2"
version = "3.1.0"
license = { file = "LICENSE" }
requires-python = ">=3.10"
dependencies = [
+158 -58
View File
@@ -37,14 +37,16 @@ import {
import {
addRemixMetadataToPrompt,
applySlotEditsToGraphNodes,
deriveWorkflowFilename,
extractTaggedSlots,
getApplicableMetadataFields,
updateGroupOrder,
} from "../cores/workflowExportCore.js";
import { getGroupOrder } from "../stores/graphStore.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";
@@ -67,20 +69,27 @@ export async function exportWorkflow(app) {
return; // User cancelled, don't show export dialog
}
// Get current workflow filename (without extension)
let workflowName = "workflow";
// 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 || "";
// Use the active workflow filename from the workflow store
const activeWorkflow = app.extensionManager?.workflow?.activeWorkflow;
if (activeWorkflow?.filename) {
// Use filename without extension (the dialog will add .json automatically)
workflowName = 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,
defaultValue: workflowName,
typeCategories,
displayName,
description: info.description,
workflowType: info.workflowType,
});
}
@@ -175,10 +184,20 @@ function createMetadataAccordion({ app, context, slotData, includeGroup = true }
* 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 {string} options.defaultValue - Default filename
* @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<boolean>} - True if export succeeded, false if cancelled or failed
*/
export async function showExportDialog({ app, defaultValue = "workflow" } = {}) {
export async function showExportDialog({
app,
typeCategories = [],
displayName = "",
description = "",
workflowType = "",
} = {}) {
return new Promise((resolve) => {
// Clone the dialog template
const overlay = cloneTemplate(TEMPLATE_IDS.EXPORT_DIALOG);
@@ -193,7 +212,12 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
// Get references to elements
const dialog = overlay.querySelector(".rtx-remix-dialog");
const input = overlay.querySelector('[data-input="filename"]');
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"]');
@@ -206,8 +230,67 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
// 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
input.value = defaultValue;
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 <select> popup cannot render the faded category dividers, so the trigger opens the
// shared popover with a menu of category headers plus their types.
function openTypeMenu() {
const menu = document.createElement("div");
menu.className = "rtx-remix-select-menu";
menu.style.width = `${typeTrigger.offsetWidth}px`;
typeCategories.forEach(({ name, types }) => {
const category = document.createElement("div");
category.className = "rtx-remix-select-category";
category.textContent = name;
menu.appendChild(category);
types.forEach(({ value, description }) => {
const option = document.createElement("button");
option.type = "button";
option.className = "rtx-remix-select-option";
option.classList.toggle("selected", value === selectedType);
option.textContent = value;
option.title = description || "";
option.addEventListener("click", () => {
selectedType = value;
renderSelectedType();
closePopover();
hideValidation();
syncExportState();
});
menu.appendChild(option);
});
});
openPopoverWithContent(typeTrigger, menu);
}
typeTrigger.addEventListener("click", () => {
if (isPopoverOpenFor(typeTrigger)) {
closePopover();
} else if (typeCategories.length > 0) {
openTypeMenu();
}
});
// Track collapsed groups state for inputs (must be before populateSlotsTable call)
const inputsCollapsedGroups = new Set();
@@ -216,46 +299,54 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
const slotData = extractTaggedSlots(app);
populateSlotsTable(slotData);
// Validation function
function validateFilename(filename) {
if (!filename) {
showValidation("Filename cannot be empty");
return false;
}
const invalidChars = /[<>:"|?*\\/]/;
if (invalidChars.test(filename)) {
showValidation("Filename contains invalid characters");
return false;
}
hideValidation();
return true;
}
function showValidation(message) {
validationMsg.textContent = message;
validationMsg.classList.add("show");
input.classList.add("error");
}
function hideValidation() {
validationMsg.classList.remove("show");
input.classList.remove("error");
}
// Real-time validation
input.addEventListener("input", () => {
const filename = input.value.trim();
if (filename) {
validateFilename(filename);
} else {
hideValidation();
function setFieldError(field, element, message) {
element.textContent = message;
element.classList.toggle("show", message !== "");
field.classList.toggle("error", message !== "");
}
// Display name and workflow type are mandatory and there is no sane default for the type, so
// Export stays disabled until both are set and each field states its own problem in place.
function syncExportState() {
const nameMessage = displayNameInput.value.trim() ? "" : "Display name is required";
const typeMessage =
typeCategories.length === 0
? "Workflow types could not be loaded from the ComfyUI server"
: selectedType
? ""
: "Workflow type is required";
setFieldError(displayNameInput, displayNameError, nameMessage);
setFieldError(typeTrigger, typeError, typeMessage);
exportBtn.disabled = isLoading || nameMessage !== "" || typeMessage !== "";
exportBtn.title = isLoading ? "" : nameMessage || typeMessage;
if (!isLoading) {
// The filename is derived, so it can never contain path characters - just show what it will be
infoMsg.textContent = `Saved as ${deriveWorkflowFilename(displayNameInput.value)}.json`;
}
}
displayNameInput.addEventListener("input", () => {
hideValidation();
syncExportState();
});
syncExportState();
// Close dialog function
function closeDialog(result) {
closePopover();
overlay.style.animation = "rtx-fadeOut 0.15s ease-out";
dialog.style.animation = "rtx-slideOut 0.15s ease-out";
@@ -266,9 +357,8 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
}
// Handle export process
async function handleExport(filename) {
// Strip .json extension if present - backend adds it automatically
const workflowName = filename.endsWith(".json") ? filename.slice(0, -5) : filename;
async function handleExport() {
const workflowName = deriveWorkflowFilename(displayNameInput.value);
setLoading(true, "Checking if file exists...");
@@ -297,6 +387,13 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
// Clean up stale metadata (orphaned groups, empty presets) before serialization
cleanupDeletedInputs(app);
// Persist the metadata in the graph so it round trips with the user's own workflow save
setWorkflowInfo(app, {
displayName: displayNameInput.value.trim(),
description: descriptionInput.value.trim(),
workflowType: selectedType,
});
// Now serialize the graph (which has updated metadata)
setLoading(true, "Generating workflow data...");
const promptResult = await app.graphToPrompt();
@@ -312,7 +409,9 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: workflowName,
displayName: displayNameInput.value.trim(),
description: descriptionInput.value.trim(),
workflowType: selectedType,
workflows: {
api: enrichedPrompt,
full: workflowGraph,
@@ -383,9 +482,12 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
// Set loading state
function setLoading(loading, message = "") {
input.disabled = loading;
isLoading = loading;
displayNameInput.disabled = loading;
typeTrigger.disabled = loading;
descriptionInput.disabled = loading;
cancelBtn.disabled = loading;
exportBtn.disabled = loading;
syncExportState();
if (loading) {
const spinner = cloneTemplate(TEMPLATE_IDS.SPINNER);
@@ -402,7 +504,6 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
}
} else {
exportBtn.textContent = "Export";
infoMsg.textContent = "The .json extension will be added automatically if not provided.";
infoMsg.classList.remove("loading");
}
}
@@ -422,31 +523,30 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
function handleKeyDown(e) {
if (e.key === "Escape") {
e.preventDefault();
if (!exportBtn.disabled) {
// The open type menu owns Escape first, so it closes without dismissing the dialog
if (isPopoverOpenFor(typeTrigger)) {
closePopover();
} else if (!isLoading) {
closeDialog(false);
}
} else if (e.key === "Enter") {
} else if (e.key === "Enter" && e.target !== descriptionInput && e.target !== typeTrigger) {
e.preventDefault();
if (!exportBtn.disabled) {
const filename = input.value.trim();
if (validateFilename(filename)) {
handleExport(filename);
}
handleExport();
}
}
}
// Event listeners
cancelBtn.addEventListener("click", () => {
if (!exportBtn.disabled) {
if (!isLoading) {
closeDialog(false);
}
});
exportBtn.addEventListener("click", async () => {
const filename = input.value.trim();
if (validateFilename(filename)) {
await handleExport(filename);
if (!exportBtn.disabled) {
await handleExport();
}
});
@@ -774,8 +874,8 @@ export async function showExportDialog({ app, defaultValue = "workflow" } = {})
// Focus input and select all text
setTimeout(() => {
input.focus();
input.select();
displayNameInput.focus();
displayNameInput.select();
}, 100);
});
}
+16
View File
@@ -41,6 +41,22 @@ export function updateGroupOrder(app, groupOrder) {
// WORKFLOW EXPORT
// ============================================================
/**
* Derive the exported workflow filename (no extension) from its display name.
* Mirrors derive_filename() in utils/workflow_metadata.py - keep both in sync.
* @param {string} displayName - User facing workflow name
* @returns {string} Lowercase underscore separated filename stem
*/
export function deriveWorkflowFilename(displayName) {
return (
(displayName || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "workflow"
);
}
/**
* Add RTX Remix metadata inline to the API workflow prompt
*/
+82
View File
@@ -182,6 +182,88 @@
border-color: var(--status-error);
}
/* Dropdown built from a button plus a popover menu, because a native <select> popup cannot render
the faded category dividers. */
.rtx-remix-select {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
cursor: pointer;
text-align: left;
}
.rtx-remix-select-value.placeholder {
color: var(--text-subtle);
}
.rtx-remix-select-caret {
color: var(--text-subtle);
font-size: var(--font-sm);
}
/* The shared popover sits below the dialog overlay, so lift it when it holds this menu */
.rtx-remix-popover:has(.rtx-remix-select-menu) {
z-index: var(--rtx-z-dropdown);
padding: var(--space-1) 0;
}
.rtx-remix-select-menu {
display: flex;
flex-direction: column;
max-height: 240px;
overflow-y: auto;
}
/* The faded divider line runs inline with the category name, on every category */
.rtx-remix-select-category {
display: flex;
align-items: center;
gap: var(--space-2);
margin-top: var(--space-1);
padding: var(--space-2) var(--space-3) var(--space-1);
color: var(--text-subtle);
font-size: var(--font-xs);
text-transform: uppercase;
letter-spacing: 0.04em;
opacity: 0.75;
}
.rtx-remix-select-category::after {
content: "";
flex: 1;
border-top: var(--border-width-thin) solid var(--border-base);
}
.rtx-remix-select-option {
padding: var(--space-2) var(--space-3);
background: none;
border: none;
color: var(--text-primary);
font-family: inherit;
font-size: var(--font-md);
text-align: left;
cursor: pointer;
}
.rtx-remix-select-option:hover {
background: var(--bg-hover);
}
.rtx-remix-select-option.selected {
color: var(--remix-color);
}
textarea.rtx-remix-input {
resize: vertical;
min-height: 60px;
font-family: inherit;
}
.rtx-remix-required {
color: var(--status-error);
}
.rtx-remix-validation-msg {
margin-top: var(--space-2);
font-size: var(--font-base);
+38 -5
View File
@@ -26,14 +26,47 @@
<!-- Body -->
<div class="rtx-remix-dialog-body">
<h3 class="rtx-remix-section-title">Workflow Filename</h3>
<h3 class="rtx-remix-section-title">Workflow Details</h3>
<div class="rtx-remix-input-container">
<input type="text" class="rtx-remix-input" placeholder="workflow.json" data-input="filename" />
<label class="rtx-remix-metadata-label" for="rtx-remix-display-name">
Display Name <span class="rtx-remix-required" title="Required">*</span>
</label>
<input
id="rtx-remix-display-name"
type="text"
class="rtx-remix-input"
placeholder="Generate PBR Textures"
data-input="display-name"
/>
<div class="rtx-remix-validation-msg" data-element="display-name-error"></div>
</div>
<div class="rtx-remix-input-container">
<label class="rtx-remix-metadata-label" for="rtx-remix-workflow-type">
Workflow Type <span class="rtx-remix-required" title="Required">*</span>
</label>
<button
id="rtx-remix-workflow-type"
type="button"
class="rtx-remix-input rtx-remix-select"
data-input="workflow-type"
>
<span class="rtx-remix-select-value" data-element="workflow-type-value"></span>
<span class="rtx-remix-select-caret">&#9662;</span>
</button>
<div class="rtx-remix-validation-msg" data-element="workflow-type-error"></div>
</div>
<div class="rtx-remix-input-container">
<label class="rtx-remix-metadata-label" for="rtx-remix-description">Description</label>
<textarea
id="rtx-remix-description"
class="rtx-remix-input"
rows="3"
placeholder="Shown as the workflow tooltip in the RTX Remix Toolkit."
data-input="description"
></textarea>
</div>
<div class="rtx-remix-validation-msg" data-element="validation"></div>
<div class="rtx-remix-info-msg" data-element="info">
The .json extension will be added automatically at the end of the workflow name.
</div>
<div class="rtx-remix-info-msg" data-element="info">Saved as workflow.json</div>
<!-- RTX Remix Slots Section -->
<div class="rtx-remix-slots-section" data-element="slots-section">
+33
View File
@@ -101,3 +101,36 @@ export function setGroupOrder(app, groupOrder) {
app.graph.setDirtyCanvas?.(true, true);
app.graph.change?.();
}
// ============================================================
// WORKFLOW INFO
// ============================================================
/**
* Get the workflow display metadata from graph-level metadata.
* @param {Object} app - ComfyUI app instance
* @returns {{displayName: string, description: string, workflowType: string}} Workflow info
*/
export function getWorkflowInfo(app) {
const meta = getGraphMetadata(app);
return {
displayName: meta[REMIX_KEYS.WORKFLOW.DISPLAY_NAME] || "",
description: meta[REMIX_KEYS.WORKFLOW.DESCRIPTION] || "",
workflowType: meta[REMIX_KEYS.WORKFLOW.WORKFLOW_TYPE] || "",
};
}
/**
* Set the workflow display metadata in graph-level metadata.
* @param {Object} app - ComfyUI app instance
* @param {{displayName: string, description: string, workflowType: string}} info - Workflow info
*/
export function setWorkflowInfo(app, { displayName, description, workflowType }) {
const meta = ensureGraphMetadata(app);
if (!meta) return;
meta[REMIX_KEYS.WORKFLOW.DISPLAY_NAME] = displayName;
meta[REMIX_KEYS.WORKFLOW.DESCRIPTION] = description;
meta[REMIX_KEYS.WORKFLOW.WORKFLOW_TYPE] = workflowType;
app.graph.setDirtyCanvas?.(true, true);
app.graph.change?.();
}
+7
View File
@@ -45,6 +45,7 @@ export const SIDEBAR = {
export const API_ENDPOINTS = {
WORKFLOWS_BASE: "/rtx-remix/v1/workflows",
WORKFLOWS_SAVE: "/rtx-remix/v1/workflows/save",
WORKFLOW_TYPES: "/rtx-remix/v1/workflows/types",
};
// Prefix for all RTX Remix nodes
@@ -87,6 +88,12 @@ export const REMIX_KEYS = {
DYNAMIC: {
REF: "ref",
},
WORKFLOW: {
DISPLAY_NAME: "displayName",
DESCRIPTION: "description",
WORKFLOW_TYPE: "workflowType",
},
};
// ComfyUI primitive type names
+5
View File
@@ -7159,6 +7159,11 @@
298.25338979290666,
-1767.0309979428139
]
},
"rtx-remix": {
"displayName": "PBR Fusion 4",
"description": "Upscales a single color texture and creates a complete PBR material from it, using the PBR Fusion 4 AI model.",
"workflowType": "Material Generation"
}
},
"version": 0.4
+6 -1
View File
@@ -2448,7 +2448,12 @@
"id": 440,
"parentId": 12
}
]
],
"rtx-remix": {
"displayName": "PBRify",
"description": "Upscales a single color texture and creates a complete PBR material from it, using the PBRify AI model.",
"workflowType": "Material Generation"
}
},
"version": 0.4
}