From 7bc3ec0874fe83ea809d3d5f449e458b13a7520c Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Trottier Date: Wed, 19 Aug 2026 17:11:32 -0700 Subject: [PATCH] REMIX-5815: Add workflow display metadata and typed workflows --- CHANGELOG.md | 25 +++ README.md | 3 +- VERSION.md | 2 +- api/__init__.py | 3 +- api/constants.py | 44 ++++- api/enums.py | 19 +- api/workflows.py | 210 ++++++++++++++++++--- pyproject.toml | 2 +- web/controllers/exportDialogController.js | 216 ++++++++++++++++------ web/cores/workflowExportCore.js | 16 ++ web/resources/styles/dialogs.css | 82 ++++++++ web/resources/templates/exportDialog.html | 43 ++++- web/stores/graphStore.js | 33 ++++ web/utils/constants.js | 7 + workflows/PBRFusion4_v1.0.2.json | 5 + workflows/integration_pbrify.json | 7 +- 16 files changed, 624 insertions(+), 93 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9985a5..832c330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 902de0f..60e3ca7 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/VERSION.md b/VERSION.md index b502146..fd2a018 100644 --- a/VERSION.md +++ b/VERSION.md @@ -1 +1 @@ -3.0.2 +3.1.0 diff --git a/api/__init__.py b/api/__init__.py index d195b72..6188c7e 100644 --- a/api/__init__.py +++ b/api/__init__.py @@ -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", ] diff --git a/api/constants.py b/api/constants.py index 492076c..27a3a2a 100644 --- a/api/constants.py +++ b/api/constants.py @@ -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}" diff --git a/api/enums.py b/api/enums.py index 5c0b629..e770dba 100644 --- a/api/enums.py +++ b/api/enums.py @@ -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" diff --git a/api/workflows.py b/api/workflows.py index c58df74..f5f62f9 100644 --- a/api/workflows.py +++ b/api/workflows.py @@ -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, } ) diff --git a/pyproject.toml b/pyproject.toml index 26c4461..93e2b2a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/web/controllers/exportDialogController.js b/web/controllers/exportDialogController.js index 1d7d989..b4e6146 100644 --- a/web/controllers/exportDialogController.js +++ b/web/controllers/exportDialogController.js @@ -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} - 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 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); diff --git a/web/resources/templates/exportDialog.html b/web/resources/templates/exportDialog.html index 5b3bc04..7d70805 100644 --- a/web/resources/templates/exportDialog.html +++ b/web/resources/templates/exportDialog.html @@ -26,14 +26,47 @@
-

Workflow Filename

+

Workflow Details

- + + +
+
+
+ + +
+
+
+ +
-
- The .json extension will be added automatically at the end of the workflow name. -
+
Saved as workflow.json
diff --git a/web/stores/graphStore.js b/web/stores/graphStore.js index e1e15f0..8e7e2bd 100644 --- a/web/stores/graphStore.js +++ b/web/stores/graphStore.js @@ -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?.(); +} diff --git a/web/utils/constants.js b/web/utils/constants.js index dc867f0..f1c16eb 100644 --- a/web/utils/constants.js +++ b/web/utils/constants.js @@ -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 diff --git a/workflows/PBRFusion4_v1.0.2.json b/workflows/PBRFusion4_v1.0.2.json index d5d124a..65e2a28 100644 --- a/workflows/PBRFusion4_v1.0.2.json +++ b/workflows/PBRFusion4_v1.0.2.json @@ -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 diff --git a/workflows/integration_pbrify.json b/workflows/integration_pbrify.json index 2e83c3b..94971e7 100644 --- a/workflows/integration_pbrify.json +++ b/workflows/integration_pbrify.json @@ -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 } \ No newline at end of file