REMIX-5815: Add workflow display metadata and typed workflows
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
3.0.2
|
||||
3.1.0
|
||||
|
||||
+2
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = [
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">▾</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">
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user