feat: add workflow node registry phase 2
This commit is contained in:
@@ -4,6 +4,7 @@ from copy import deepcopy
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.core.process_steps import StepName
|
from app.core.process_steps import StepName
|
||||||
|
from app.domains.rendering.workflow_node_registry import get_node_type_for_step
|
||||||
|
|
||||||
|
|
||||||
WorkflowPresetType = str
|
WorkflowPresetType = str
|
||||||
@@ -16,18 +17,10 @@ _PRESET_TYPES = {
|
|||||||
"custom",
|
"custom",
|
||||||
}
|
}
|
||||||
|
|
||||||
_STEP_TO_NODE_TYPE: dict[str, str] = {
|
|
||||||
StepName.RESOLVE_STEP_PATH.value: "inputNode",
|
|
||||||
StepName.STL_CACHE_GENERATE.value: "convertNode",
|
|
||||||
StepName.BLENDER_STILL.value: "renderNode",
|
|
||||||
StepName.BLENDER_TURNTABLE.value: "renderFramesNode",
|
|
||||||
StepName.OUTPUT_SAVE.value: "outputNode",
|
|
||||||
StepName.EXPORT_BLEND.value: "outputNode",
|
|
||||||
}
|
|
||||||
|
|
||||||
_NODE_TYPE_TO_STEP: dict[str, str] = {
|
_NODE_TYPE_TO_STEP: dict[str, str] = {
|
||||||
"inputNode": StepName.RESOLVE_STEP_PATH.value,
|
"inputNode": StepName.RESOLVE_STEP_PATH.value,
|
||||||
"convertNode": StepName.STL_CACHE_GENERATE.value,
|
"convertNode": StepName.STL_CACHE_GENERATE.value,
|
||||||
|
"processNode": StepName.ORDER_LINE_SETUP.value,
|
||||||
"renderNode": StepName.BLENDER_STILL.value,
|
"renderNode": StepName.BLENDER_STILL.value,
|
||||||
"renderFramesNode": StepName.BLENDER_TURNTABLE.value,
|
"renderFramesNode": StepName.BLENDER_TURNTABLE.value,
|
||||||
"ffmpegNode": StepName.OUTPUT_SAVE.value,
|
"ffmpegNode": StepName.OUTPUT_SAVE.value,
|
||||||
@@ -50,7 +43,7 @@ def _make_node(
|
|||||||
"step": step.value,
|
"step": step.value,
|
||||||
"params": deepcopy(params or {}),
|
"params": deepcopy(params or {}),
|
||||||
"ui": {
|
"ui": {
|
||||||
"type": node_type or _STEP_TO_NODE_TYPE.get(step.value),
|
"type": node_type or get_node_type_for_step(step.value),
|
||||||
"position": {"x": x, "y": y},
|
"position": {"x": x, "y": y},
|
||||||
"label": label,
|
"label": label,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,417 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.core.process_steps import StepName
|
||||||
|
|
||||||
|
|
||||||
|
StepCategory = Literal["input", "processing", "rendering", "output"]
|
||||||
|
FieldType = Literal["number", "select", "boolean"]
|
||||||
|
ExecutionKind = Literal["native", "bridge"]
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeFieldOption(BaseModel):
|
||||||
|
value: str | int | float | bool
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeFieldDefinition(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
type: FieldType
|
||||||
|
description: str = ""
|
||||||
|
section: str = "General"
|
||||||
|
default: Any = None
|
||||||
|
min: float | None = None
|
||||||
|
max: float | None = None
|
||||||
|
step: float | None = None
|
||||||
|
unit: str | None = None
|
||||||
|
options: list[WorkflowNodeFieldOption] = []
|
||||||
|
|
||||||
|
|
||||||
|
class WorkflowNodeDefinition(BaseModel):
|
||||||
|
step: str
|
||||||
|
label: str
|
||||||
|
category: StepCategory
|
||||||
|
description: str
|
||||||
|
node_type: str
|
||||||
|
icon: str
|
||||||
|
defaults: dict[str, Any] = {}
|
||||||
|
fields: list[WorkflowNodeFieldDefinition] = []
|
||||||
|
execution_kind: ExecutionKind = "native"
|
||||||
|
legacy_compatible: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def _field(
|
||||||
|
key: str,
|
||||||
|
label: str,
|
||||||
|
field_type: FieldType,
|
||||||
|
*,
|
||||||
|
description: str = "",
|
||||||
|
section: str = "General",
|
||||||
|
default: Any = None,
|
||||||
|
min: float | None = None,
|
||||||
|
max: float | None = None,
|
||||||
|
step: float | None = None,
|
||||||
|
unit: str | None = None,
|
||||||
|
options: list[tuple[str | int | float | bool, str]] | None = None,
|
||||||
|
) -> WorkflowNodeFieldDefinition:
|
||||||
|
return WorkflowNodeFieldDefinition(
|
||||||
|
key=key,
|
||||||
|
label=label,
|
||||||
|
type=field_type,
|
||||||
|
description=description,
|
||||||
|
section=section,
|
||||||
|
default=default,
|
||||||
|
min=min,
|
||||||
|
max=max,
|
||||||
|
step=step,
|
||||||
|
unit=unit,
|
||||||
|
options=[
|
||||||
|
WorkflowNodeFieldOption(value=value, label=option_label)
|
||||||
|
for value, option_label in (options or [])
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _definition(
|
||||||
|
step: StepName,
|
||||||
|
label: str,
|
||||||
|
category: StepCategory,
|
||||||
|
description: str,
|
||||||
|
*,
|
||||||
|
node_type: str,
|
||||||
|
icon: str,
|
||||||
|
defaults: dict[str, Any] | None = None,
|
||||||
|
fields: list[WorkflowNodeFieldDefinition] | None = None,
|
||||||
|
execution_kind: ExecutionKind = "native",
|
||||||
|
) -> WorkflowNodeDefinition:
|
||||||
|
return WorkflowNodeDefinition(
|
||||||
|
step=step.value,
|
||||||
|
label=label,
|
||||||
|
category=category,
|
||||||
|
description=description,
|
||||||
|
node_type=node_type,
|
||||||
|
icon=icon,
|
||||||
|
defaults=defaults or {},
|
||||||
|
fields=fields or [],
|
||||||
|
execution_kind=execution_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_NODE_DEFINITIONS: list[WorkflowNodeDefinition] = [
|
||||||
|
_definition(
|
||||||
|
StepName.RESOLVE_STEP_PATH,
|
||||||
|
"Resolve STEP Path",
|
||||||
|
"input",
|
||||||
|
"Locate the STEP file on disk from the CAD file record.",
|
||||||
|
node_type="inputNode",
|
||||||
|
icon="file-up",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.OCC_OBJECT_EXTRACT,
|
||||||
|
"Extract STEP Objects",
|
||||||
|
"processing",
|
||||||
|
"Extract part objects and metadata from the STEP file via OCC/cadquery.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.OCC_GLB_EXPORT,
|
||||||
|
"Export GLB",
|
||||||
|
"processing",
|
||||||
|
"Convert STEP geometry into GLB for previews and downstream rendering.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="refresh-cw",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.GLB_BBOX,
|
||||||
|
"Compute Bounding Box",
|
||||||
|
"processing",
|
||||||
|
"Compute the model bounding box from the exported GLB for framing decisions.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.MATERIAL_MAP_RESOLVE,
|
||||||
|
"Resolve Material Map",
|
||||||
|
"processing",
|
||||||
|
"Map raw part material names to HartOMat material records via aliases.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.AUTO_POPULATE_MATERIALS,
|
||||||
|
"Auto Populate Materials",
|
||||||
|
"processing",
|
||||||
|
"Create missing material records for newly discovered part materials.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.BLENDER_RENDER,
|
||||||
|
"Render Thumbnail (Blender)",
|
||||||
|
"rendering",
|
||||||
|
"Render a thumbnail image with Blender.",
|
||||||
|
node_type="renderNode",
|
||||||
|
icon="camera",
|
||||||
|
defaults={"render_engine": "cycles", "samples": 64, "width": 512, "height": 512},
|
||||||
|
fields=[
|
||||||
|
_field(
|
||||||
|
"render_engine",
|
||||||
|
"Render Engine",
|
||||||
|
"select",
|
||||||
|
description="Renderer backend for this Blender job.",
|
||||||
|
section="Render",
|
||||||
|
default="cycles",
|
||||||
|
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
"samples",
|
||||||
|
"Samples",
|
||||||
|
"number",
|
||||||
|
description="Quality samples for the render.",
|
||||||
|
section="Render",
|
||||||
|
default=64,
|
||||||
|
min=1,
|
||||||
|
max=4096,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
_field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.THREEJS_RENDER,
|
||||||
|
"Render Thumbnail (Three.js)",
|
||||||
|
"rendering",
|
||||||
|
"Render a thumbnail image with the headless Three.js renderer.",
|
||||||
|
node_type="renderNode",
|
||||||
|
icon="camera",
|
||||||
|
defaults={"width": 512, "height": 512, "transparent_bg": True},
|
||||||
|
fields=[
|
||||||
|
_field("width", "Width", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field("height", "Height", "number", section="Output", default=512, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field(
|
||||||
|
"transparent_bg",
|
||||||
|
"Transparent Background",
|
||||||
|
"boolean",
|
||||||
|
description="Render with alpha channel instead of an opaque background.",
|
||||||
|
section="Output",
|
||||||
|
default=True,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.THUMBNAIL_SAVE,
|
||||||
|
"Save Thumbnail",
|
||||||
|
"output",
|
||||||
|
"Persist the generated thumbnail back onto the CAD file record.",
|
||||||
|
node_type="outputNode",
|
||||||
|
icon="download",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.ORDER_LINE_SETUP,
|
||||||
|
"Order Line Setup",
|
||||||
|
"processing",
|
||||||
|
"Validate order-line inputs and prepare the render job context.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.RESOLVE_TEMPLATE,
|
||||||
|
"Resolve Template",
|
||||||
|
"processing",
|
||||||
|
"Resolve the render template for the order line and output type.",
|
||||||
|
node_type="processNode",
|
||||||
|
icon="layers",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.BLENDER_STILL,
|
||||||
|
"Render Still",
|
||||||
|
"rendering",
|
||||||
|
"Render a production still image with Blender.",
|
||||||
|
node_type="renderNode",
|
||||||
|
icon="camera",
|
||||||
|
defaults={"render_engine": "cycles", "samples": 256, "width": 2048, "height": 2048, "rotation_z": 0},
|
||||||
|
fields=[
|
||||||
|
_field(
|
||||||
|
"render_engine",
|
||||||
|
"Render Engine",
|
||||||
|
"select",
|
||||||
|
description="Renderer backend for the still render.",
|
||||||
|
section="Render",
|
||||||
|
default="cycles",
|
||||||
|
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
"samples",
|
||||||
|
"Samples",
|
||||||
|
"number",
|
||||||
|
description="Quality samples for the still render.",
|
||||||
|
section="Render",
|
||||||
|
default=256,
|
||||||
|
min=1,
|
||||||
|
max=4096,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field(
|
||||||
|
"rotation_z",
|
||||||
|
"Rotation Z",
|
||||||
|
"number",
|
||||||
|
description="Additional Z-axis rotation in degrees.",
|
||||||
|
section="Camera",
|
||||||
|
default=0,
|
||||||
|
min=-360,
|
||||||
|
max=360,
|
||||||
|
step=1,
|
||||||
|
unit="deg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.BLENDER_TURNTABLE,
|
||||||
|
"Render Turntable",
|
||||||
|
"rendering",
|
||||||
|
"Render an animated turntable sequence with Blender.",
|
||||||
|
node_type="renderFramesNode",
|
||||||
|
icon="film",
|
||||||
|
defaults={
|
||||||
|
"render_engine": "cycles",
|
||||||
|
"samples": 64,
|
||||||
|
"width": 2048,
|
||||||
|
"height": 2048,
|
||||||
|
"fps": 24,
|
||||||
|
"duration_s": 5,
|
||||||
|
"rotation_z": 0,
|
||||||
|
},
|
||||||
|
fields=[
|
||||||
|
_field(
|
||||||
|
"render_engine",
|
||||||
|
"Render Engine",
|
||||||
|
"select",
|
||||||
|
description="Renderer backend for the turntable job.",
|
||||||
|
section="Render",
|
||||||
|
default="cycles",
|
||||||
|
options=[("cycles", "Cycles"), ("eevee", "EEVEE")],
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
"samples",
|
||||||
|
"Samples",
|
||||||
|
"number",
|
||||||
|
description="Quality samples for each frame.",
|
||||||
|
section="Render",
|
||||||
|
default=64,
|
||||||
|
min=1,
|
||||||
|
max=4096,
|
||||||
|
step=1,
|
||||||
|
),
|
||||||
|
_field("width", "Width", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field("height", "Height", "number", section="Output", default=2048, min=64, max=8192, step=1, unit="px"),
|
||||||
|
_field("fps", "FPS", "number", section="Animation", default=24, min=1, max=120, step=1),
|
||||||
|
_field(
|
||||||
|
"duration_s",
|
||||||
|
"Duration",
|
||||||
|
"number",
|
||||||
|
description="Length of the animation clip.",
|
||||||
|
section="Animation",
|
||||||
|
default=5,
|
||||||
|
min=1,
|
||||||
|
max=120,
|
||||||
|
step=1,
|
||||||
|
unit="s",
|
||||||
|
),
|
||||||
|
_field(
|
||||||
|
"rotation_z",
|
||||||
|
"Rotation Z",
|
||||||
|
"number",
|
||||||
|
description="Base Z-axis rotation before the turntable motion is applied.",
|
||||||
|
section="Camera",
|
||||||
|
default=0,
|
||||||
|
min=-360,
|
||||||
|
max=360,
|
||||||
|
step=1,
|
||||||
|
unit="deg",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.OUTPUT_SAVE,
|
||||||
|
"Save Output",
|
||||||
|
"output",
|
||||||
|
"Persist the rendered output file and create the media record.",
|
||||||
|
node_type="outputNode",
|
||||||
|
icon="download",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.EXPORT_BLEND,
|
||||||
|
"Export Blend",
|
||||||
|
"output",
|
||||||
|
"Persist the generated .blend file as a downloadable media asset.",
|
||||||
|
node_type="outputNode",
|
||||||
|
icon="download",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.STL_CACHE_GENERATE,
|
||||||
|
"Generate STL Cache",
|
||||||
|
"processing",
|
||||||
|
"Generate and cache STL derivatives next to the STEP source.",
|
||||||
|
node_type="convertNode",
|
||||||
|
icon="refresh-cw",
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
_definition(
|
||||||
|
StepName.NOTIFY,
|
||||||
|
"Notify",
|
||||||
|
"output",
|
||||||
|
"Emit a user-visible notification for workflow completion or failure.",
|
||||||
|
node_type="outputNode",
|
||||||
|
icon="bell",
|
||||||
|
defaults={"channel": "audit_log"},
|
||||||
|
fields=[
|
||||||
|
_field(
|
||||||
|
"channel",
|
||||||
|
"Channel",
|
||||||
|
"select",
|
||||||
|
description="Notification target channel.",
|
||||||
|
section="Notification",
|
||||||
|
default="audit_log",
|
||||||
|
options=[("audit_log", "Audit Log")],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
execution_kind="bridge",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
_NODE_DEFINITION_BY_STEP = {definition.step: definition for definition in _NODE_DEFINITIONS}
|
||||||
|
|
||||||
|
|
||||||
|
def list_node_definitions() -> list[WorkflowNodeDefinition]:
|
||||||
|
return list(_NODE_DEFINITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_definition(step: StepName | str) -> WorkflowNodeDefinition | None:
|
||||||
|
step_value = step.value if isinstance(step, StepName) else step
|
||||||
|
return _NODE_DEFINITION_BY_STEP.get(step_value)
|
||||||
|
|
||||||
|
|
||||||
|
def get_node_type_for_step(step: StepName | str) -> str | None:
|
||||||
|
definition = get_node_definition(step)
|
||||||
|
return definition.node_type if definition else None
|
||||||
@@ -1,16 +1,5 @@
|
|||||||
"""Workflow definition CRUD API.
|
"""Workflow definition CRUD API."""
|
||||||
|
|
||||||
Endpoints:
|
|
||||||
GET /api/workflows/ — list all workflow definitions (admin/PM)
|
|
||||||
GET /api/workflows/pipeline-steps — list available pipeline step definitions
|
|
||||||
GET /api/workflows/{id} — get single definition (admin/PM)
|
|
||||||
POST /api/workflows/ — create definition (admin only)
|
|
||||||
PUT /api/workflows/{id} — update definition (admin only)
|
|
||||||
DELETE /api/workflows/{id} — delete definition (admin only)
|
|
||||||
GET /api/workflows/{id}/runs — list runs for a definition (admin/PM)
|
|
||||||
"""
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel, ValidationError
|
||||||
@@ -29,53 +18,12 @@ from app.domains.rendering.schemas import (
|
|||||||
WorkflowRunOut,
|
WorkflowRunOut,
|
||||||
)
|
)
|
||||||
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
|
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
|
||||||
|
from app.domains.rendering.workflow_node_registry import (
|
||||||
|
StepCategory,
|
||||||
|
WorkflowNodeDefinition,
|
||||||
|
list_node_definitions,
|
||||||
|
)
|
||||||
from app.domains.rendering.workflow_schema import WorkflowConfig
|
from app.domains.rendering.workflow_schema import WorkflowConfig
|
||||||
from app.core.process_steps import StepName
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pipeline-step metadata helpers ──────────────────────────────────────────
|
|
||||||
|
|
||||||
StepCategory = Literal["input", "processing", "rendering", "output"]
|
|
||||||
|
|
||||||
_STEP_CATEGORIES: dict[StepName, StepCategory] = {
|
|
||||||
StepName.RESOLVE_STEP_PATH: "input",
|
|
||||||
StepName.OCC_OBJECT_EXTRACT: "processing",
|
|
||||||
StepName.OCC_GLB_EXPORT: "processing",
|
|
||||||
StepName.GLB_BBOX: "processing",
|
|
||||||
StepName.MATERIAL_MAP_RESOLVE: "processing",
|
|
||||||
StepName.AUTO_POPULATE_MATERIALS: "processing",
|
|
||||||
StepName.BLENDER_RENDER: "rendering",
|
|
||||||
StepName.THREEJS_RENDER: "rendering",
|
|
||||||
StepName.THUMBNAIL_SAVE: "output",
|
|
||||||
StepName.ORDER_LINE_SETUP: "processing",
|
|
||||||
StepName.RESOLVE_TEMPLATE: "processing",
|
|
||||||
StepName.BLENDER_STILL: "rendering",
|
|
||||||
StepName.BLENDER_TURNTABLE: "rendering",
|
|
||||||
StepName.OUTPUT_SAVE: "output",
|
|
||||||
StepName.EXPORT_BLEND: "output",
|
|
||||||
StepName.STL_CACHE_GENERATE: "processing",
|
|
||||||
StepName.NOTIFY: "output",
|
|
||||||
}
|
|
||||||
|
|
||||||
_STEP_DESCRIPTIONS: dict[StepName, str] = {
|
|
||||||
StepName.RESOLVE_STEP_PATH: "Locate the STEP file on disk from the CadFile record",
|
|
||||||
StepName.OCC_OBJECT_EXTRACT: "Extract part objects and metadata from the STEP file using cadquery/OCC",
|
|
||||||
StepName.OCC_GLB_EXPORT: "Convert STEP geometry to glTF/GLB via cadquery",
|
|
||||||
StepName.GLB_BBOX: "Compute bounding-box from the exported GLB for camera framing",
|
|
||||||
StepName.MATERIAL_MAP_RESOLVE: "Resolve raw part-material names to HARTOMAT library materials via alias table",
|
|
||||||
StepName.AUTO_POPULATE_MATERIALS: "Auto-create Material records for any newly discovered part names",
|
|
||||||
StepName.BLENDER_RENDER: "Render a thumbnail PNG using Blender (Cycles or EEVEE)",
|
|
||||||
StepName.THREEJS_RENDER: "Render a thumbnail PNG using Three.js / Playwright headless browser",
|
|
||||||
StepName.THUMBNAIL_SAVE: "Persist the rendered thumbnail bytes to the CadFile record",
|
|
||||||
StepName.ORDER_LINE_SETUP: "Validate and prepare an order line for rendering (check STEP path, output type)",
|
|
||||||
StepName.RESOLVE_TEMPLATE: "Look up the matching RenderTemplate for the order line's category + output type",
|
|
||||||
StepName.BLENDER_STILL: "Render a production still image (PNG) via Blender HTTP micro-service",
|
|
||||||
StepName.BLENDER_TURNTABLE: "Render all turntable animation frames via Blender HTTP micro-service",
|
|
||||||
StepName.OUTPUT_SAVE: "Upload the rendered output file to storage and create a MediaAsset record",
|
|
||||||
StepName.EXPORT_BLEND: "Save the production .blend file as a downloadable MediaAsset",
|
|
||||||
StepName.STL_CACHE_GENERATE: "Convert STEP → STL (low + high quality) and cache next to the STEP file",
|
|
||||||
StepName.NOTIFY: "Emit a user notification via the audit-log notification channel",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PipelineStepOut(BaseModel):
|
class PipelineStepOut(BaseModel):
|
||||||
@@ -88,6 +36,11 @@ class PipelineStepOut(BaseModel):
|
|||||||
class PipelineStepsResponse(BaseModel):
|
class PipelineStepsResponse(BaseModel):
|
||||||
steps: list[PipelineStepOut]
|
steps: list[PipelineStepOut]
|
||||||
|
|
||||||
|
|
||||||
|
class NodeDefinitionsResponse(BaseModel):
|
||||||
|
definitions: list[WorkflowNodeDefinition]
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||||
|
|
||||||
|
|
||||||
@@ -102,19 +55,25 @@ def _workflow_to_out(wf: WorkflowDefinition) -> WorkflowDefinitionOut:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/node-definitions", response_model=NodeDefinitionsResponse)
|
||||||
|
async def get_node_definitions(
|
||||||
|
_user: User = Depends(require_admin_or_pm),
|
||||||
|
):
|
||||||
|
return NodeDefinitionsResponse(definitions=list_node_definitions())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/pipeline-steps", response_model=PipelineStepsResponse)
|
@router.get("/pipeline-steps", response_model=PipelineStepsResponse)
|
||||||
async def get_pipeline_steps(
|
async def get_pipeline_steps(
|
||||||
_user: User = Depends(require_admin_or_pm),
|
_user: User = Depends(require_admin_or_pm),
|
||||||
):
|
):
|
||||||
"""Return all available pipeline step definitions for the workflow editor."""
|
|
||||||
steps = [
|
steps = [
|
||||||
PipelineStepOut(
|
PipelineStepOut(
|
||||||
name=step.value,
|
name=definition.step,
|
||||||
label=step.value.replace("_", " ").title(),
|
label=definition.label,
|
||||||
category=_STEP_CATEGORIES.get(step, "processing"),
|
category=definition.category,
|
||||||
description=_STEP_DESCRIPTIONS.get(step, ""),
|
description=definition.description,
|
||||||
)
|
)
|
||||||
for step in StepName
|
for definition in list_node_definitions()
|
||||||
]
|
]
|
||||||
return PipelineStepsResponse(steps=steps)
|
return PipelineStepsResponse(steps=steps)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.process_steps import StepName
|
||||||
|
from app.domains.rendering.workflow_node_registry import (
|
||||||
|
get_node_definition,
|
||||||
|
list_node_definitions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_node_registry_covers_all_step_names():
|
||||||
|
registered_steps = {definition.step for definition in list_node_definitions()}
|
||||||
|
expected_steps = {step.value for step in StepName}
|
||||||
|
|
||||||
|
assert registered_steps == expected_steps
|
||||||
|
|
||||||
|
|
||||||
|
def test_turntable_node_definition_exposes_expected_schema():
|
||||||
|
definition = get_node_definition(StepName.BLENDER_TURNTABLE)
|
||||||
|
|
||||||
|
assert definition is not None
|
||||||
|
assert definition.node_type == "renderFramesNode"
|
||||||
|
assert definition.defaults["fps"] == 24
|
||||||
|
assert definition.defaults["duration_s"] == 5
|
||||||
|
assert {field.key for field in definition.fields} >= {
|
||||||
|
"render_engine",
|
||||||
|
"samples",
|
||||||
|
"width",
|
||||||
|
"height",
|
||||||
|
"fps",
|
||||||
|
"duration_s",
|
||||||
|
"rotation_z",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_node_definitions_endpoint_returns_registry(client, auth_headers):
|
||||||
|
response = await client.get("/api/workflows/node-definitions", headers=auth_headers)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
body = response.json()
|
||||||
|
assert len(body["definitions"]) == len(StepName)
|
||||||
|
|
||||||
|
blender_still = next(
|
||||||
|
definition for definition in body["definitions"] if definition["step"] == StepName.BLENDER_STILL.value
|
||||||
|
)
|
||||||
|
assert blender_still["node_type"] == "renderNode"
|
||||||
|
assert blender_still["defaults"]["render_engine"] == "cycles"
|
||||||
@@ -4,18 +4,18 @@
|
|||||||
|
|
||||||
### Phase 1
|
### Phase 1
|
||||||
|
|
||||||
- [ ] Canonical workflow schema finalized
|
- [x] Canonical workflow schema finalized
|
||||||
- [ ] Frontend and backend workflow types aligned
|
- [x] Frontend and backend workflow types aligned
|
||||||
- [ ] Preset workflow migration helpers added
|
- [x] Preset workflow migration helpers added
|
||||||
- [ ] Tests added for legacy preset conversion
|
- [x] Tests added for legacy preset conversion
|
||||||
- [ ] Legacy dispatch remains default
|
- [x] Legacy dispatch remains default
|
||||||
|
|
||||||
### Phase 2
|
### Phase 2
|
||||||
|
|
||||||
- [ ] Node registry implemented
|
- [x] Node registry implemented
|
||||||
- [ ] Node definitions API available
|
- [x] Node definitions API available
|
||||||
- [ ] All required nodes have settings schemas
|
- [x] All required nodes have settings schemas
|
||||||
- [ ] Editor consumes node definitions from backend
|
- [x] Editor consumes node definitions from backend
|
||||||
|
|
||||||
### Phase 3
|
### Phase 3
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
Bring `/workflows` to full production parity with the existing legacy render pipeline without breaking the current legacy path at any time.
|
Bring `/workflows` to full production parity with the existing legacy render pipeline without breaking the current legacy path at any time.
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
- Phase 1 completed on canonical config storage, preset migration, and legacy-safe runtime extraction.
|
||||||
|
- Phase 2 completed on backend node registry, node definitions API, and schema-driven editor palette/settings.
|
||||||
|
- Next execution target: Phase 3 legacy step extraction for runtime parity.
|
||||||
|
|
||||||
## Non-Negotiables
|
## Non-Negotiables
|
||||||
|
|
||||||
- The legacy render path remains operational throughout the migration.
|
- The legacy render path remains operational throughout the migration.
|
||||||
|
|||||||
@@ -101,9 +101,47 @@ export const deleteWorkflow = (id: string): Promise<void> =>
|
|||||||
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
|
export const getWorkflowRuns = (workflowId: string): Promise<WorkflowRun[]> =>
|
||||||
api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
|
api.get(`/workflows/${workflowId}/runs`).then(r => r.data)
|
||||||
|
|
||||||
// ─── Pipeline Steps ───────────────────────────────────────────────────────────
|
// ─── Node Definitions / Pipeline Steps ───────────────────────────────────────
|
||||||
|
|
||||||
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
|
export type StepCategory = 'input' | 'processing' | 'rendering' | 'output'
|
||||||
|
export type WorkflowNodeFieldType = 'number' | 'select' | 'boolean'
|
||||||
|
export type WorkflowNodeExecutionKind = 'native' | 'bridge'
|
||||||
|
|
||||||
|
export interface WorkflowNodeFieldOption {
|
||||||
|
value: string | number | boolean
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowNodeFieldDefinition {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: WorkflowNodeFieldType
|
||||||
|
description: string
|
||||||
|
section: string
|
||||||
|
default: unknown
|
||||||
|
min: number | null
|
||||||
|
max: number | null
|
||||||
|
step: number | null
|
||||||
|
unit: string | null
|
||||||
|
options: WorkflowNodeFieldOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowNodeDefinition {
|
||||||
|
step: string
|
||||||
|
label: string
|
||||||
|
category: StepCategory
|
||||||
|
description: string
|
||||||
|
node_type: string
|
||||||
|
icon: string
|
||||||
|
defaults: WorkflowParams
|
||||||
|
fields: WorkflowNodeFieldDefinition[]
|
||||||
|
execution_kind: WorkflowNodeExecutionKind
|
||||||
|
legacy_compatible: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkflowNodeDefinitionsResponse {
|
||||||
|
definitions: WorkflowNodeDefinition[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface PipelineStep {
|
export interface PipelineStep {
|
||||||
name: string
|
name: string
|
||||||
@@ -116,6 +154,9 @@ export interface PipelineStepsResponse {
|
|||||||
steps: PipelineStep[]
|
steps: PipelineStep[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getNodeDefinitions = (): Promise<WorkflowNodeDefinitionsResponse> =>
|
||||||
|
api.get('/workflows/node-definitions').then(r => r.data)
|
||||||
|
|
||||||
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
||||||
api.get('/workflows/pipeline-steps').then(r => r.data)
|
api.get('/workflows/pipeline-steps').then(r => r.data)
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback, useRef, DragEvent } from 'react'
|
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react'
|
||||||
import {
|
import {
|
||||||
ReactFlow,
|
ReactFlow,
|
||||||
Background,
|
Background,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
createWorkflow,
|
createWorkflow,
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
deleteWorkflow,
|
deleteWorkflow,
|
||||||
getPipelineSteps,
|
getNodeDefinitions,
|
||||||
createPresetWorkflowConfig,
|
createPresetWorkflowConfig,
|
||||||
getWorkflowPresetType,
|
getWorkflowPresetType,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
@@ -30,8 +30,9 @@ import {
|
|||||||
type WorkflowEdge,
|
type WorkflowEdge,
|
||||||
type WorkflowPresetType,
|
type WorkflowPresetType,
|
||||||
type WorkflowParams,
|
type WorkflowParams,
|
||||||
type PipelineStep,
|
|
||||||
type StepCategory,
|
type StepCategory,
|
||||||
|
type WorkflowNodeDefinition,
|
||||||
|
type WorkflowNodeFieldDefinition,
|
||||||
} from '../api/workflows'
|
} from '../api/workflows'
|
||||||
import {
|
import {
|
||||||
FileUp,
|
FileUp,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
Film,
|
Film,
|
||||||
Layers,
|
Layers,
|
||||||
Download,
|
Download,
|
||||||
|
Bell,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
@@ -59,15 +61,49 @@ function normalizeWorkflowParams(params: WorkflowParams): WorkflowParams {
|
|||||||
return normalized
|
return normalized
|
||||||
}
|
}
|
||||||
|
|
||||||
function getResolutionSelection(params: WorkflowParams): number {
|
type WorkflowCanvasNodeData = {
|
||||||
const resolution = Array.isArray(params.resolution) ? params.resolution : undefined
|
label: string
|
||||||
if (resolution && typeof resolution[0] === 'number') {
|
params: WorkflowParams
|
||||||
return Number(resolution[0])
|
step: string
|
||||||
|
description?: string
|
||||||
|
icon?: string
|
||||||
|
category?: StepCategory
|
||||||
}
|
}
|
||||||
if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) {
|
|
||||||
return params.width
|
function renderWorkflowIcon(iconName?: string, size = 14) {
|
||||||
|
switch (iconName) {
|
||||||
|
case 'file-up':
|
||||||
|
return <FileUp size={size} />
|
||||||
|
case 'film':
|
||||||
|
return <Film size={size} />
|
||||||
|
case 'layers':
|
||||||
|
return <Layers size={size} />
|
||||||
|
case 'download':
|
||||||
|
return <Download size={size} />
|
||||||
|
case 'bell':
|
||||||
|
return <Bell size={size} />
|
||||||
|
case 'camera':
|
||||||
|
return <Camera size={size} />
|
||||||
|
case 'refresh-cw':
|
||||||
|
default:
|
||||||
|
return <RefreshCw size={size} />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildNodeData(
|
||||||
|
step: string,
|
||||||
|
params: WorkflowParams = {},
|
||||||
|
definition?: WorkflowNodeDefinition,
|
||||||
|
overrides?: Partial<WorkflowCanvasNodeData>,
|
||||||
|
): WorkflowCanvasNodeData {
|
||||||
|
return {
|
||||||
|
label: overrides?.label ?? definition?.label ?? inferNodeLabel(step),
|
||||||
|
params: normalizeWorkflowParams(params),
|
||||||
|
step,
|
||||||
|
description: overrides?.description ?? definition?.description,
|
||||||
|
icon: overrides?.icon ?? definition?.icon,
|
||||||
|
category: overrides?.category ?? definition?.category,
|
||||||
}
|
}
|
||||||
return 2048
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||||
@@ -75,14 +111,14 @@ function getResolutionSelection(params: WorkflowParams): number {
|
|||||||
interface BaseNodeProps {
|
interface BaseNodeProps {
|
||||||
label: string
|
label: string
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
color: string
|
accentClass: string
|
||||||
description?: string
|
description?: string
|
||||||
selected?: boolean
|
selected?: boolean
|
||||||
hasSource?: boolean
|
hasSource?: boolean
|
||||||
hasTarget?: boolean
|
hasTarget?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function BaseNode({ label, icon, color, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
|
function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
|
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
|
||||||
@@ -92,7 +128,7 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
|
|||||||
{hasTarget && (
|
{hasTarget && (
|
||||||
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
||||||
)}
|
)}
|
||||||
<div className={`flex items-center gap-2 mb-1 text-${color}-600`}>
|
<div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
|
||||||
{icon}
|
{icon}
|
||||||
<span className="font-medium text-sm">{label}</span>
|
<span className="font-medium text-sm">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -104,76 +140,80 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function InputNode({ selected }: { selected?: boolean }) {
|
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
label="STEP Input"
|
label={data.label}
|
||||||
icon={<FileUp size={14} />}
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
color="green"
|
accentClass="text-green-600"
|
||||||
description="STEP file input"
|
description={data.description}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
hasTarget={false}
|
hasTarget={false}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConvertNode({ selected }: { selected?: boolean }) {
|
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
label="STL Conversion"
|
label={data.label}
|
||||||
icon={<RefreshCw size={14} />}
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
color="blue"
|
accentClass="text-blue-600"
|
||||||
description="STEP → STL (cadquery)"
|
description={data.description}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) {
|
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
|
return (
|
||||||
|
<BaseNode
|
||||||
|
label={data.label}
|
||||||
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
|
accentClass="text-sky-600"
|
||||||
|
description={data.description}
|
||||||
|
selected={selected}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
const params = data.params ?? {}
|
const params = data.params ?? {}
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
label={data.label ?? 'Still Render'}
|
label={data.label}
|
||||||
icon={<Camera size={14} />}
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
color="orange"
|
accentClass="text-orange-600"
|
||||||
description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined}
|
description={
|
||||||
|
params.render_engine
|
||||||
|
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
||||||
|
: data.description
|
||||||
|
}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function RenderFramesNode({ data, selected }: { data: { params?: WorkflowParams }; selected?: boolean }) {
|
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
const params = data.params ?? {}
|
const params = data.params ?? {}
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
label="Frames Render"
|
label={data.label}
|
||||||
icon={<Film size={14} />}
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
color="orange"
|
accentClass="text-orange-600"
|
||||||
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined}
|
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description}
|
||||||
selected={selected}
|
selected={selected}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function FFmpegNode({ selected }: { selected?: boolean }) {
|
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<BaseNode
|
<BaseNode
|
||||||
label="FFmpeg Composite"
|
label={data.label}
|
||||||
icon={<Layers size={14} />}
|
icon={renderWorkflowIcon(data.icon)}
|
||||||
color="purple"
|
accentClass="text-slate-600"
|
||||||
description="Frames → MP4"
|
description={data.description}
|
||||||
selected={selected}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) {
|
|
||||||
return (
|
|
||||||
<BaseNode
|
|
||||||
label={data.label ?? 'Output'}
|
|
||||||
icon={<Download size={14} />}
|
|
||||||
color="gray"
|
|
||||||
description="Output file"
|
|
||||||
selected={selected}
|
selected={selected}
|
||||||
hasSource={false}
|
hasSource={false}
|
||||||
/>
|
/>
|
||||||
@@ -183,9 +223,9 @@ function OutputNode({ data, selected }: { data: { label?: string }; selected?: b
|
|||||||
const nodeTypes: NodeTypes = {
|
const nodeTypes: NodeTypes = {
|
||||||
inputNode: InputNode as any,
|
inputNode: InputNode as any,
|
||||||
convertNode: ConvertNode as any,
|
convertNode: ConvertNode as any,
|
||||||
|
processNode: ProcessNode as any,
|
||||||
renderNode: RenderNode as any,
|
renderNode: RenderNode as any,
|
||||||
renderFramesNode: RenderFramesNode as any,
|
renderFramesNode: RenderFramesNode as any,
|
||||||
ffmpegNode: FFmpegNode as any,
|
|
||||||
outputNode: OutputNode as any,
|
outputNode: OutputNode as any,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,7 +233,11 @@ function inferNodeType(step: string): string {
|
|||||||
if (step === 'resolve_step_path') return 'inputNode'
|
if (step === 'resolve_step_path') return 'inputNode'
|
||||||
if (step === 'stl_cache_generate') return 'convertNode'
|
if (step === 'stl_cache_generate') return 'convertNode'
|
||||||
if (step === 'blender_turntable') return 'renderFramesNode'
|
if (step === 'blender_turntable') return 'renderFramesNode'
|
||||||
if (step === 'output_save' || step === 'export_blend') return 'outputNode'
|
if (step === 'output_save' || step === 'export_blend' || step === 'notify' || step === 'thumbnail_save') return 'outputNode'
|
||||||
|
if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode'
|
||||||
|
if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') {
|
||||||
|
return 'processNode'
|
||||||
|
}
|
||||||
return 'renderNode'
|
return 'renderNode'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,22 +251,24 @@ function inferNodeLabel(step: string): string {
|
|||||||
function inferStepFromNodeType(type?: string): string {
|
function inferStepFromNodeType(type?: string): string {
|
||||||
if (type === 'inputNode') return 'resolve_step_path'
|
if (type === 'inputNode') return 'resolve_step_path'
|
||||||
if (type === 'convertNode') return 'stl_cache_generate'
|
if (type === 'convertNode') return 'stl_cache_generate'
|
||||||
|
if (type === 'processNode') return 'order_line_setup'
|
||||||
if (type === 'renderFramesNode') return 'blender_turntable'
|
if (type === 'renderFramesNode') return 'blender_turntable'
|
||||||
if (type === 'outputNode') return 'output_save'
|
if (type === 'outputNode') return 'output_save'
|
||||||
return 'blender_still'
|
return 'blender_still'
|
||||||
}
|
}
|
||||||
|
|
||||||
function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } {
|
function workflowToGraph(
|
||||||
|
config: WorkflowConfig,
|
||||||
|
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
|
||||||
|
): { nodes: Node[]; edges: Edge[] } {
|
||||||
return {
|
return {
|
||||||
nodes: config.nodes.map(node => ({
|
nodes: config.nodes.map(node => ({
|
||||||
id: node.id,
|
id: node.id,
|
||||||
type: node.ui?.type ?? inferNodeType(node.step),
|
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
|
||||||
position: node.ui?.position ?? { x: 0, y: 0 },
|
position: node.ui?.position ?? { x: 0, y: 0 },
|
||||||
data: {
|
data: buildNodeData(node.step, node.params ?? {}, nodeDefinitionsByStep[node.step], {
|
||||||
label: node.ui?.label ?? inferNodeLabel(node.step),
|
label: node.ui?.label ?? undefined,
|
||||||
params: node.params ?? {},
|
}),
|
||||||
step: node.step,
|
|
||||||
},
|
|
||||||
})),
|
})),
|
||||||
edges: config.edges.map((edge, index) => ({
|
edges: config.edges.map((edge, index) => ({
|
||||||
id: `e_${edge.from}_${edge.to}_${index}`,
|
id: `e_${edge.from}_${edge.to}_${index}`,
|
||||||
@@ -234,149 +280,155 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[]
|
|||||||
|
|
||||||
// ─── Config Sidepanel ─────────────────────────────────────────────────────────
|
// ─── Config Sidepanel ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) {
|
||||||
|
return fields.reduce<Record<string, WorkflowNodeFieldDefinition[]>>((sections, field) => {
|
||||||
|
const section = field.section || 'General'
|
||||||
|
sections[section] = [...(sections[section] ?? []), field]
|
||||||
|
return sections
|
||||||
|
}, {})
|
||||||
|
}
|
||||||
|
|
||||||
function ConfigSidepanel({
|
function ConfigSidepanel({
|
||||||
params,
|
params,
|
||||||
onChange,
|
onChange,
|
||||||
pipelineStep,
|
nodeDefinition,
|
||||||
onPipelineStepChange,
|
step,
|
||||||
pipelineSteps,
|
onStepChange,
|
||||||
|
nodeDefinitions,
|
||||||
}: {
|
}: {
|
||||||
params: WorkflowParams
|
params: WorkflowParams
|
||||||
onChange: (p: WorkflowParams) => void
|
onChange: (p: WorkflowParams) => void
|
||||||
pipelineStep?: string
|
nodeDefinition?: WorkflowNodeDefinition
|
||||||
onPipelineStepChange?: (step: string) => void
|
step?: string
|
||||||
pipelineSteps: PipelineStep[]
|
onStepChange?: (step: string) => void
|
||||||
|
nodeDefinitions: WorkflowNodeDefinition[]
|
||||||
}) {
|
}) {
|
||||||
|
const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => {
|
||||||
|
onChange(
|
||||||
|
normalizeWorkflowParams({
|
||||||
|
...params,
|
||||||
|
[field.key]: value,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const rawValue = event.target.value
|
||||||
|
if (rawValue === '') {
|
||||||
|
const nextParams = { ...params }
|
||||||
|
delete nextParams[field.key]
|
||||||
|
onChange(nextParams)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateField(field, Number(rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto">
|
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto">
|
||||||
<h3 className="font-semibold text-content">Node Configuration</h3>
|
<h3 className="font-semibold text-content">Node Configuration</h3>
|
||||||
|
|
||||||
{/* Pipeline Step binding */}
|
{nodeDefinitions.length > 0 && onStepChange && (
|
||||||
{pipelineSteps.length > 0 && onPipelineStepChange && (
|
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm text-content-secondary mb-2 block">Pipeline Step</label>
|
<label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
|
||||||
<select
|
<select
|
||||||
value={pipelineStep ?? ''}
|
value={step ?? ''}
|
||||||
onChange={e => onPipelineStepChange(e.target.value)}
|
onChange={event => onStepChange(event.target.value)}
|
||||||
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
>
|
>
|
||||||
<option value="">(not bound)</option>
|
{nodeDefinitions.map(definition => (
|
||||||
{pipelineSteps.map(s => (
|
<option key={definition.step} value={definition.step}>
|
||||||
<option key={s.name} value={s.name}>
|
{definition.label}
|
||||||
{s.label}
|
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{pipelineStep && (
|
{nodeDefinition && (
|
||||||
<p className="text-xs text-content-muted mt-1">
|
<div className="mt-2 space-y-1">
|
||||||
{pipelineSteps.find(s => s.name === pipelineStep)?.description ?? ''}
|
<p className="text-xs text-content-muted">{nodeDefinition.description}</p>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
||||||
|
nodeDefinition.execution_kind === 'bridge'
|
||||||
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||||
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(fieldsBySection).length === 0 && (
|
||||||
|
<p className="text-sm text-content-muted">
|
||||||
|
This node currently has no configurable settings in the editor.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
{Object.entries(fieldsBySection).map(([section, fields]) => (
|
||||||
|
<div key={section} className="space-y-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
||||||
|
{section}
|
||||||
|
</h4>
|
||||||
|
{fields.map(field => {
|
||||||
|
const rawValue = params[field.key]
|
||||||
|
const value = rawValue ?? field.default
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field.key}>
|
||||||
|
<label className="text-sm text-content-secondary mb-1 block">
|
||||||
|
{field.label}
|
||||||
|
{field.unit ? ` (${field.unit})` : ''}
|
||||||
|
</label>
|
||||||
|
{field.type === 'select' && (
|
||||||
|
<select
|
||||||
|
value={String(value ?? '')}
|
||||||
|
onChange={event => updateField(field, event.target.value)}
|
||||||
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
>
|
||||||
|
{field.options.map(option => (
|
||||||
|
<option key={String(option.value)} value={String(option.value)}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
)}
|
)}
|
||||||
|
{field.type === 'number' && (
|
||||||
{/* Render Engine */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-content-secondary mb-2 block">Render Engine</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{(['cycles', 'eevee'] as const).map(eng => (
|
|
||||||
<button
|
|
||||||
key={eng}
|
|
||||||
onClick={() => onChange({ ...params, render_engine: eng })}
|
|
||||||
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
||||||
(params.render_engine ?? 'cycles') === eng
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{eng === 'cycles' ? 'Cycles' : 'EEVEE'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Samples */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-content-secondary mb-2 block">
|
|
||||||
Samples: <span className="font-semibold text-content">{params.samples ?? 256}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="number"
|
||||||
min={1}
|
min={field.min ?? undefined}
|
||||||
max={4096}
|
max={field.max ?? undefined}
|
||||||
step={1}
|
step={field.step ?? undefined}
|
||||||
value={params.samples ?? 256}
|
value={typeof value === 'number' ? value : value == null ? '' : Number(value)}
|
||||||
onChange={e => onChange({ ...params, samples: Number(e.target.value) })}
|
onChange={event => handleNumberChange(field, event)}
|
||||||
className="w-full accent-accent"
|
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-content-muted mt-1">
|
)}
|
||||||
<span>1</span>
|
{field.type === 'boolean' && (
|
||||||
<span>4096</span>
|
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resolution */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-content-secondary mb-2 block">Resolution</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
|
|
||||||
<button
|
|
||||||
key={w}
|
|
||||||
onClick={() => onChange(normalizeWorkflowParams({ ...params, resolution: [w, w] }))}
|
|
||||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
||||||
getResolutionSelection(params) === w
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{w}px
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* FPS (only relevant for animation nodes) */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-content-secondary mb-2 block">
|
|
||||||
FPS: <span className="font-semibold text-content">{params.fps ?? 24}</span>
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
{[12, 24, 30, 60].map(fps => (
|
|
||||||
<button
|
|
||||||
key={fps}
|
|
||||||
onClick={() => onChange({ ...params, fps })}
|
|
||||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
||||||
(params.fps ?? 24) === fps
|
|
||||||
? 'bg-accent text-white'
|
|
||||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{fps}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Duration */}
|
|
||||||
<div>
|
|
||||||
<label className="text-sm text-content-secondary mb-2 block">
|
|
||||||
Duration (s): <span className="font-semibold text-content">{params.duration_s ?? 5}</span>
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="checkbox"
|
||||||
min={1}
|
checked={Boolean(value)}
|
||||||
max={30}
|
onChange={event => updateField(field, event.target.checked)}
|
||||||
step={1}
|
className="accent-accent"
|
||||||
value={params.duration_s ?? 5}
|
|
||||||
onChange={e => onChange({ ...params, duration_s: Number(e.target.value) })}
|
|
||||||
className="w-full accent-accent"
|
|
||||||
/>
|
/>
|
||||||
|
<span>{Boolean(value) ? 'Enabled' : 'Disabled'}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{field.description && (
|
||||||
|
<p className="mt-1 text-xs text-content-muted">{field.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Pipeline Steps Panel ─────────────────────────────────────────────────────
|
// ─── Node Definitions Panel ───────────────────────────────────────────────────
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<StepCategory, string> = {
|
const CATEGORY_LABELS: Record<StepCategory, string> = {
|
||||||
input: 'Input',
|
input: 'Input',
|
||||||
@@ -392,12 +444,12 @@ const CATEGORY_COLORS: Record<StepCategory, string> = {
|
|||||||
output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||||
}
|
}
|
||||||
|
|
||||||
function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) {
|
||||||
const [expanded, setExpanded] = useState<StepCategory | null>(null)
|
const [expanded, setExpanded] = useState<StepCategory | null>(null)
|
||||||
|
|
||||||
const grouped = steps.reduce<Record<StepCategory, PipelineStep[]>>(
|
const grouped = definitions.reduce<Record<StepCategory, WorkflowNodeDefinition[]>>(
|
||||||
(acc, step) => {
|
(acc, definition) => {
|
||||||
acc[step.category] = [...(acc[step.category] ?? []), step]
|
acc[definition.category] = [...(acc[definition.category] ?? []), definition]
|
||||||
return acc
|
return acc
|
||||||
},
|
},
|
||||||
{ input: [], processing: [], rendering: [], output: [] },
|
{ input: [], processing: [], rendering: [], output: [] },
|
||||||
@@ -408,7 +460,7 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
|||||||
return (
|
return (
|
||||||
<div className="border-t border-border-default pt-3 mt-3">
|
<div className="border-t border-border-default pt-3 mt-3">
|
||||||
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
|
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
|
||||||
Pipeline Steps
|
Available Nodes
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{categories.map(cat => (
|
{categories.map(cat => (
|
||||||
@@ -424,14 +476,26 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
|||||||
</button>
|
</button>
|
||||||
{expanded === cat && (
|
{expanded === cat && (
|
||||||
<div className="ml-2 mt-1 space-y-1">
|
<div className="ml-2 mt-1 space-y-1">
|
||||||
{grouped[cat].map(step => (
|
{grouped[cat].map(definition => (
|
||||||
<div
|
<div
|
||||||
key={step.name}
|
key={definition.step}
|
||||||
className="text-xs bg-surface-hover rounded px-2 py-1.5"
|
className="text-xs bg-surface-hover rounded px-2 py-1.5"
|
||||||
title={step.description}
|
title={definition.description}
|
||||||
>
|
>
|
||||||
<p className="font-mono text-content-secondary truncate">{step.name}</p>
|
<div className="flex items-center justify-between gap-2">
|
||||||
<p className="text-content-muted mt-0.5 line-clamp-2">{step.description}</p>
|
<p className="font-medium text-content-secondary truncate">{definition.label}</p>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
||||||
|
definition.execution_kind === 'bridge'
|
||||||
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
||||||
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-content-muted truncate mt-0.5">{definition.step}</p>
|
||||||
|
<p className="text-content-muted mt-0.5 line-clamp-2">{definition.description}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -443,16 +507,6 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Node Palette ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const NODE_PALETTE = [
|
|
||||||
{ type: 'convertNode', label: 'STEP→STL', icon: <RefreshCw size={14} /> },
|
|
||||||
{ type: 'renderNode', label: 'Still Render', icon: <Camera size={14} /> },
|
|
||||||
{ type: 'renderFramesNode', label: 'Frame Render', icon: <Film size={14} /> },
|
|
||||||
{ type: 'ffmpegNode', label: 'FFmpeg', icon: <Layers size={14} /> },
|
|
||||||
{ type: 'outputNode', label: 'Output', icon: <Download size={14} /> },
|
|
||||||
]
|
|
||||||
|
|
||||||
// ─── New Workflow Modal ───────────────────────────────────────────────────────
|
// ─── New Workflow Modal ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface NewWorkflowModalProps {
|
interface NewWorkflowModalProps {
|
||||||
@@ -543,19 +597,26 @@ interface FlowCanvasProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||||
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config)
|
const { data: nodeDefinitionsData } = useQuery({
|
||||||
|
queryKey: ['workflow-node-definitions'],
|
||||||
|
queryFn: getNodeDefinitions,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
})
|
||||||
|
const nodeDefinitions = nodeDefinitionsData?.definitions ?? []
|
||||||
|
const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition]))
|
||||||
|
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||||
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
|
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
||||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
||||||
|
|
||||||
const { data: pipelineStepsData } = useQuery({
|
useEffect(() => {
|
||||||
queryKey: ['pipeline-steps'],
|
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
||||||
queryFn: getPipelineSteps,
|
setNodes(graph.nodes)
|
||||||
staleTime: 5 * 60 * 1000,
|
setEdges(graph.edges)
|
||||||
})
|
setSelectedNodeId(null)
|
||||||
const pipelineSteps = pipelineStepsData?.steps ?? []
|
}, [nodeDefinitionsData, setEdges, setNodes, workflow.config])
|
||||||
|
|
||||||
const onConnect = useCallback(
|
const onConnect = useCallback(
|
||||||
(connection: Connection) => setEdges(eds => addEdge(connection, eds)),
|
(connection: Connection) => setEdges(eds => addEdge(connection, eds)),
|
||||||
@@ -586,15 +647,24 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
|
|
||||||
const handlePipelineStepChange = useCallback(
|
const handlePipelineStepChange = useCallback(
|
||||||
(stepName: string) => {
|
(stepName: string) => {
|
||||||
|
const definition = nodeDefinitionsByStep[stepName]
|
||||||
setNodes(nds =>
|
setNodes(nds =>
|
||||||
nds.map(n => {
|
nds.map(n => {
|
||||||
if (n.id === selectedNodeId) {
|
if (n.id === selectedNodeId) {
|
||||||
|
const currentData = (n.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName)
|
||||||
return {
|
return {
|
||||||
...n,
|
...n,
|
||||||
|
type: definition?.node_type ?? inferNodeType(stepName),
|
||||||
data: {
|
data: {
|
||||||
...n.data,
|
...buildNodeData(
|
||||||
|
stepName || inferStepFromNodeType(n.type),
|
||||||
|
{
|
||||||
|
...(definition?.defaults ?? {}),
|
||||||
|
...currentData.params,
|
||||||
|
},
|
||||||
|
definition,
|
||||||
|
),
|
||||||
step: stepName || inferStepFromNodeType(n.type),
|
step: stepName || inferStepFromNodeType(n.type),
|
||||||
label: (n.data as any).label ?? inferNodeLabel(stepName),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,7 +672,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
[selectedNodeId, setNodes],
|
[nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
// Drag-drop new nodes from palette
|
// Drag-drop new nodes from palette
|
||||||
@@ -614,8 +684,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
const onDrop = useCallback(
|
const onDrop = useCallback(
|
||||||
(event: DragEvent<HTMLDivElement>) => {
|
(event: DragEvent<HTMLDivElement>) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const type = event.dataTransfer.getData('application/reactflow')
|
const step = event.dataTransfer.getData('application/workflow-step')
|
||||||
if (!type || !reactFlowInstance) return
|
if (!step || !reactFlowInstance) return
|
||||||
|
|
||||||
|
const definition = nodeDefinitionsByStep[step]
|
||||||
|
const type = definition?.node_type ?? inferNodeType(step)
|
||||||
|
|
||||||
const position = reactFlowInstance.screenToFlowPosition({
|
const position = reactFlowInstance.screenToFlowPosition({
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
@@ -623,18 +696,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const newNode: Node = {
|
const newNode: Node = {
|
||||||
id: `${type}_${Date.now()}`,
|
id: `${step}_${Date.now()}`,
|
||||||
type,
|
type,
|
||||||
position,
|
position,
|
||||||
data: {
|
data: buildNodeData(step, definition?.defaults ?? {}, definition),
|
||||||
label: type,
|
|
||||||
params: {},
|
|
||||||
step: inferStepFromNodeType(type),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
setNodes(nds => [...nds, newNode])
|
setNodes(nds => [...nds, newNode])
|
||||||
},
|
},
|
||||||
[reactFlowInstance, setNodes],
|
[nodeDefinitionsByStep, reactFlowInstance, setNodes],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -667,20 +736,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 min-h-0">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{/* Canvas Toolbar */}
|
{/* Canvas Toolbar */}
|
||||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-surface">
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-default bg-surface overflow-x-auto">
|
||||||
<span className="text-sm font-medium text-content-secondary mr-2">Nodes</span>
|
<span className="text-sm font-medium text-content-secondary mr-2 whitespace-nowrap">Nodes</span>
|
||||||
{NODE_PALETTE.map(item => (
|
{nodeDefinitions.map(definition => (
|
||||||
<div
|
<div
|
||||||
key={item.type}
|
key={definition.step}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.setData('application/reactflow', item.type)
|
e.dataTransfer.setData('application/workflow-step', definition.step)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none"
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none whitespace-nowrap"
|
||||||
|
title={definition.description}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{renderWorkflowIcon(definition.icon)}
|
||||||
{item.label}
|
{definition.label}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="ml-auto">
|
<div className="ml-auto">
|
||||||
@@ -722,14 +792,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
|||||||
<ConfigSidepanel
|
<ConfigSidepanel
|
||||||
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
||||||
onChange={handleParamsChange}
|
onChange={handleParamsChange}
|
||||||
pipelineStep={(selectedNode.data as any).step as string | undefined}
|
step={(selectedNode.data as any).step as string | undefined}
|
||||||
onPipelineStepChange={handlePipelineStepChange}
|
onStepChange={handlePipelineStepChange}
|
||||||
pipelineSteps={pipelineSteps}
|
nodeDefinition={nodeDefinitionsByStep[((selectedNode.data as any).step as string | undefined) ?? '']}
|
||||||
|
nodeDefinitions={nodeDefinitions}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!selectedNode && pipelineSteps.length > 0 && (
|
{!selectedNode && nodeDefinitions.length > 0 && (
|
||||||
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
|
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
|
||||||
<PipelineStepsPanel steps={pipelineSteps} />
|
<NodeDefinitionsPanel definitions={nodeDefinitions} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user