feat: stabilize workflow phase 1 foundation
This commit is contained in:
@@ -680,35 +680,36 @@ async def seed_workflows(
|
||||
):
|
||||
"""Create the standard workflow definitions if they do not already exist."""
|
||||
from app.domains.rendering.models import WorkflowDefinition
|
||||
from app.domains.rendering.workflow_config_utils import build_preset_workflow_config
|
||||
|
||||
STANDARD_WORKFLOWS = [
|
||||
{
|
||||
"name": "Still Image — Cycles",
|
||||
"config": {
|
||||
"type": "still",
|
||||
"params": {"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
|
||||
},
|
||||
"config": build_preset_workflow_config(
|
||||
"still",
|
||||
{"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Still Image — EEVEE",
|
||||
"config": {
|
||||
"type": "still",
|
||||
"params": {"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]},
|
||||
},
|
||||
"config": build_preset_workflow_config(
|
||||
"still",
|
||||
{"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]},
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Turntable Animation",
|
||||
"config": {
|
||||
"type": "turntable",
|
||||
"params": {"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5},
|
||||
},
|
||||
"config": build_preset_workflow_config(
|
||||
"turntable",
|
||||
{"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5},
|
||||
),
|
||||
},
|
||||
{
|
||||
"name": "Multi-Angle (0° / 45° / 90°)",
|
||||
"config": {
|
||||
"type": "multi_angle",
|
||||
"params": {"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
|
||||
},
|
||||
"config": build_preset_workflow_config(
|
||||
"multi_angle",
|
||||
{"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1093,4 +1094,3 @@ async def get_dashboard_stats(
|
||||
product_stats=product_stats,
|
||||
order_status=order_status,
|
||||
)
|
||||
|
||||
|
||||
@@ -77,8 +77,17 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
|
||||
)
|
||||
return _legacy_dispatch(order_line_id)
|
||||
|
||||
workflow_type = wf_def.config.get("type")
|
||||
params = wf_def.config.get("params", {})
|
||||
from app.domains.rendering.workflow_config_utils import extract_runtime_workflow
|
||||
|
||||
workflow_type, params = extract_runtime_workflow(wf_def.config)
|
||||
if workflow_type is None or workflow_type == "custom":
|
||||
logger.warning(
|
||||
"order_line %s: workflow_definition_id %s has no supported preset runtime, "
|
||||
"falling back to legacy dispatch",
|
||||
order_line_id,
|
||||
wf_def.id,
|
||||
)
|
||||
return _legacy_dispatch(order_line_id)
|
||||
|
||||
logger.info(
|
||||
"order_line %s: dispatching via WorkflowDefinition %s (type=%s)",
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from app.core.process_steps import StepName
|
||||
|
||||
|
||||
WorkflowPresetType = str
|
||||
|
||||
_PRESET_TYPES = {
|
||||
"still",
|
||||
"turntable",
|
||||
"multi_angle",
|
||||
"still_with_exports",
|
||||
"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] = {
|
||||
"inputNode": StepName.RESOLVE_STEP_PATH.value,
|
||||
"convertNode": StepName.STL_CACHE_GENERATE.value,
|
||||
"renderNode": StepName.BLENDER_STILL.value,
|
||||
"renderFramesNode": StepName.BLENDER_TURNTABLE.value,
|
||||
"ffmpegNode": StepName.OUTPUT_SAVE.value,
|
||||
"outputNode": StepName.OUTPUT_SAVE.value,
|
||||
}
|
||||
|
||||
|
||||
def _make_node(
|
||||
node_id: str,
|
||||
step: StepName,
|
||||
x: int,
|
||||
y: int,
|
||||
*,
|
||||
params: dict[str, Any] | None = None,
|
||||
node_type: str | None = None,
|
||||
label: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": node_id,
|
||||
"step": step.value,
|
||||
"params": deepcopy(params or {}),
|
||||
"ui": {
|
||||
"type": node_type or _STEP_TO_NODE_TYPE.get(step.value),
|
||||
"position": {"x": x, "y": y},
|
||||
"label": label,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _resolution_to_dimensions(params: dict[str, Any]) -> dict[str, Any]:
|
||||
merged = deepcopy(params)
|
||||
resolution = merged.pop("resolution", None)
|
||||
if isinstance(resolution, (list, tuple)) and len(resolution) == 2:
|
||||
merged.setdefault("width", int(resolution[0]))
|
||||
merged.setdefault("height", int(resolution[1]))
|
||||
return merged
|
||||
|
||||
|
||||
def build_preset_workflow_config(
|
||||
preset_type: WorkflowPresetType,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if preset_type not in _PRESET_TYPES:
|
||||
raise ValueError(f"Unknown workflow preset type: {preset_type!r}")
|
||||
|
||||
params = deepcopy(params or {})
|
||||
render_params = _resolution_to_dimensions(params)
|
||||
|
||||
if preset_type == "still":
|
||||
nodes = [
|
||||
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"),
|
||||
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"),
|
||||
_make_node(
|
||||
"render",
|
||||
StepName.BLENDER_STILL,
|
||||
440,
|
||||
100,
|
||||
params=render_params,
|
||||
node_type="renderNode",
|
||||
label="Still Render",
|
||||
),
|
||||
_make_node("output", StepName.OUTPUT_SAVE, 660, 100, label="Save Output"),
|
||||
]
|
||||
edges = [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
{"from": "render", "to": "output"},
|
||||
]
|
||||
elif preset_type == "turntable":
|
||||
nodes = [
|
||||
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"),
|
||||
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"),
|
||||
_make_node(
|
||||
"turntable",
|
||||
StepName.BLENDER_TURNTABLE,
|
||||
440,
|
||||
100,
|
||||
params=render_params,
|
||||
node_type="renderFramesNode",
|
||||
label="Turntable Render",
|
||||
),
|
||||
_make_node("output", StepName.OUTPUT_SAVE, 660, 100, label="Save Output"),
|
||||
]
|
||||
edges = [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "turntable"},
|
||||
{"from": "turntable", "to": "output"},
|
||||
]
|
||||
elif preset_type == "multi_angle":
|
||||
angles = params.get("angles") or [0, 45, 90]
|
||||
shared = deepcopy(render_params)
|
||||
shared.pop("angles", None)
|
||||
nodes = [
|
||||
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 195, label="Order Line Setup"),
|
||||
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 195, label="Resolve Template"),
|
||||
]
|
||||
edges = [
|
||||
{"from": "setup", "to": "template"},
|
||||
]
|
||||
for index, angle in enumerate(angles):
|
||||
node_id = f"render_{index}"
|
||||
node_params = {**shared, "rotation_z": float(angle)}
|
||||
nodes.append(
|
||||
_make_node(
|
||||
node_id,
|
||||
StepName.BLENDER_STILL,
|
||||
440,
|
||||
index * 130,
|
||||
params=node_params,
|
||||
node_type="renderNode",
|
||||
label=f"Render {angle}°",
|
||||
)
|
||||
)
|
||||
edges.append({"from": "template", "to": node_id})
|
||||
nodes.append(_make_node("output", StepName.OUTPUT_SAVE, 700, 195, label="Save Output"))
|
||||
edges.extend({"from": f"render_{index}", "to": "output"} for index, _ in enumerate(angles))
|
||||
elif preset_type == "still_with_exports":
|
||||
nodes = [
|
||||
_make_node("setup", StepName.ORDER_LINE_SETUP, 0, 100, label="Order Line Setup"),
|
||||
_make_node("template", StepName.RESOLVE_TEMPLATE, 220, 100, label="Resolve Template"),
|
||||
_make_node(
|
||||
"render",
|
||||
StepName.BLENDER_STILL,
|
||||
440,
|
||||
100,
|
||||
params=render_params,
|
||||
node_type="renderNode",
|
||||
label="Still Render",
|
||||
),
|
||||
_make_node("output", StepName.OUTPUT_SAVE, 660, 70, label="Save Output"),
|
||||
_make_node("blend", StepName.EXPORT_BLEND, 660, 160, label="Export Blend"),
|
||||
]
|
||||
edges = [
|
||||
{"from": "setup", "to": "template"},
|
||||
{"from": "template", "to": "render"},
|
||||
{"from": "render", "to": "output"},
|
||||
{"from": "render", "to": "blend"},
|
||||
]
|
||||
else:
|
||||
nodes = [
|
||||
_make_node("setup", StepName.ORDER_LINE_SETUP, 120, 140, label="Order Line Setup"),
|
||||
]
|
||||
edges = []
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"ui": {"preset": preset_type},
|
||||
}
|
||||
|
||||
|
||||
def _canonicalize_legacy_custom_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
legacy_nodes = raw.get("nodes") or []
|
||||
legacy_edges = raw.get("edges") or []
|
||||
nodes: list[dict[str, Any]] = []
|
||||
for legacy_node in legacy_nodes:
|
||||
data = legacy_node.get("data") or {}
|
||||
node_type = legacy_node.get("type")
|
||||
step_name = data.get("pipeline_step") or _NODE_TYPE_TO_STEP.get(node_type) or StepName.BLENDER_STILL.value
|
||||
nodes.append(
|
||||
{
|
||||
"id": legacy_node["id"],
|
||||
"step": step_name,
|
||||
"params": deepcopy(data.get("params") or {}),
|
||||
"ui": {
|
||||
"type": node_type,
|
||||
"position": deepcopy(legacy_node.get("position") or {"x": 0, "y": 0}),
|
||||
"label": data.get("label"),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
edges: list[dict[str, Any]] = []
|
||||
for index, legacy_edge in enumerate(legacy_edges):
|
||||
source = legacy_edge.get("source") or legacy_edge.get("from")
|
||||
target = legacy_edge.get("target") or legacy_edge.get("to")
|
||||
if not source or not target:
|
||||
continue
|
||||
edges.append(
|
||||
{
|
||||
"id": legacy_edge.get("id") or f"edge_{index}",
|
||||
"from": source,
|
||||
"to": target,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"version": 1,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"ui": {"preset": "custom"},
|
||||
}
|
||||
|
||||
|
||||
def canonicalize_workflow_config(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("Workflow config must be a JSON object")
|
||||
|
||||
if "version" in raw and "nodes" in raw:
|
||||
normalized = deepcopy(raw)
|
||||
normalized.setdefault("edges", [])
|
||||
return normalized
|
||||
|
||||
workflow_type = raw.get("type")
|
||||
if workflow_type in _PRESET_TYPES:
|
||||
if workflow_type == "custom":
|
||||
return _canonicalize_legacy_custom_config(raw)
|
||||
return build_preset_workflow_config(workflow_type, raw.get("params") or {})
|
||||
|
||||
raise ValueError("Unsupported workflow config format")
|
||||
|
||||
|
||||
def get_workflow_preset_type(config: dict[str, Any]) -> str | None:
|
||||
canonical = canonicalize_workflow_config(config)
|
||||
ui = canonical.get("ui") or {}
|
||||
preset = ui.get("preset")
|
||||
if preset in _PRESET_TYPES:
|
||||
return preset
|
||||
return None
|
||||
|
||||
|
||||
def extract_runtime_workflow(config: dict[str, Any]) -> tuple[str | None, dict[str, Any]]:
|
||||
canonical = canonicalize_workflow_config(config)
|
||||
preset = get_workflow_preset_type(canonical)
|
||||
if preset is None or preset == "custom":
|
||||
return preset, {}
|
||||
|
||||
nodes = canonical.get("nodes") or []
|
||||
|
||||
if preset in {"still", "still_with_exports"}:
|
||||
for node in nodes:
|
||||
if node.get("step") == StepName.BLENDER_STILL.value:
|
||||
return preset, _resolution_to_dimensions(node.get("params") or {})
|
||||
return preset, {}
|
||||
|
||||
if preset == "turntable":
|
||||
for node in nodes:
|
||||
if node.get("step") == StepName.BLENDER_TURNTABLE.value:
|
||||
return preset, _resolution_to_dimensions(node.get("params") or {})
|
||||
return preset, {}
|
||||
|
||||
if preset == "multi_angle":
|
||||
render_nodes = [node for node in nodes if node.get("step") == StepName.BLENDER_STILL.value]
|
||||
if not render_nodes:
|
||||
return preset, {}
|
||||
first_params = _resolution_to_dimensions(render_nodes[0].get("params") or {})
|
||||
angles = [
|
||||
float((node.get("params") or {}).get("rotation_z", 0))
|
||||
for node in render_nodes
|
||||
]
|
||||
first_params["angles"] = angles
|
||||
first_params.pop("rotation_z", None)
|
||||
return preset, first_params
|
||||
|
||||
return preset, {}
|
||||
@@ -28,6 +28,7 @@ from app.domains.rendering.schemas import (
|
||||
WorkflowDefinitionOut,
|
||||
WorkflowRunOut,
|
||||
)
|
||||
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
|
||||
from app.domains.rendering.workflow_schema import WorkflowConfig
|
||||
from app.core.process_steps import StepName
|
||||
|
||||
@@ -90,6 +91,17 @@ class PipelineStepsResponse(BaseModel):
|
||||
router = APIRouter(prefix="/api/workflows", tags=["workflows"])
|
||||
|
||||
|
||||
def _workflow_to_out(wf: WorkflowDefinition) -> WorkflowDefinitionOut:
|
||||
return WorkflowDefinitionOut(
|
||||
id=wf.id,
|
||||
name=wf.name,
|
||||
output_type_id=wf.output_type_id,
|
||||
config=canonicalize_workflow_config(wf.config),
|
||||
is_active=wf.is_active,
|
||||
created_at=wf.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/pipeline-steps", response_model=PipelineStepsResponse)
|
||||
async def get_pipeline_steps(
|
||||
_user: User = Depends(require_admin_or_pm),
|
||||
@@ -115,7 +127,7 @@ async def list_workflows(
|
||||
result = await db.execute(
|
||||
select(WorkflowDefinition).order_by(WorkflowDefinition.created_at)
|
||||
)
|
||||
return result.scalars().all()
|
||||
return [_workflow_to_out(wf) for wf in result.scalars().all()]
|
||||
|
||||
|
||||
@router.get("/{workflow_id}", response_model=WorkflowDefinitionOut)
|
||||
@@ -130,7 +142,7 @@ async def get_workflow(
|
||||
wf = result.scalar_one_or_none()
|
||||
if not wf:
|
||||
raise HTTPException(status_code=404, detail="Workflow definition not found")
|
||||
return wf
|
||||
return _workflow_to_out(wf)
|
||||
|
||||
|
||||
@router.post("", response_model=WorkflowDefinitionOut, status_code=201)
|
||||
@@ -139,21 +151,23 @@ async def create_workflow(
|
||||
_user: User = Depends(require_global_admin),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
normalized_config = canonicalize_workflow_config(body.config)
|
||||
if body.config:
|
||||
try:
|
||||
WorkflowConfig.model_validate(body.config)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}")
|
||||
WorkflowConfig.model_validate(normalized_config)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
detail = exc.errors() if isinstance(exc, ValidationError) else str(exc)
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {detail}")
|
||||
wf = WorkflowDefinition(
|
||||
name=body.name,
|
||||
output_type_id=body.output_type_id,
|
||||
config=body.config,
|
||||
config=normalized_config,
|
||||
is_active=body.is_active,
|
||||
)
|
||||
db.add(wf)
|
||||
await db.commit()
|
||||
await db.refresh(wf)
|
||||
return wf
|
||||
return _workflow_to_out(wf)
|
||||
|
||||
|
||||
@router.put("/{workflow_id}", response_model=WorkflowDefinitionOut)
|
||||
@@ -174,16 +188,18 @@ async def update_workflow(
|
||||
wf.name = body.name
|
||||
if body.config is not None:
|
||||
try:
|
||||
WorkflowConfig.model_validate(body.config)
|
||||
except ValidationError as exc:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}")
|
||||
wf.config = body.config
|
||||
normalized_config = canonicalize_workflow_config(body.config)
|
||||
WorkflowConfig.model_validate(normalized_config)
|
||||
except (ValidationError, ValueError) as exc:
|
||||
detail = exc.errors() if isinstance(exc, ValidationError) else str(exc)
|
||||
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {detail}")
|
||||
wf.config = normalized_config
|
||||
if body.is_active is not None:
|
||||
wf.is_active = body.is_active
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(wf)
|
||||
return wf
|
||||
return _workflow_to_out(wf)
|
||||
|
||||
|
||||
@router.delete("/{workflow_id}", status_code=204)
|
||||
|
||||
@@ -21,10 +21,22 @@ from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from app.core.process_steps import StepName
|
||||
|
||||
|
||||
class WorkflowPosition(BaseModel):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class WorkflowNodeUI(BaseModel):
|
||||
type: str | None = None
|
||||
position: WorkflowPosition | None = None
|
||||
label: str | None = None
|
||||
|
||||
|
||||
class WorkflowNode(BaseModel):
|
||||
id: str
|
||||
step: StepName # validated against the StepName StrEnum
|
||||
params: dict = {}
|
||||
ui: WorkflowNodeUI | None = None
|
||||
|
||||
|
||||
class WorkflowEdge(BaseModel):
|
||||
@@ -35,10 +47,15 @@ class WorkflowEdge(BaseModel):
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
class WorkflowUI(BaseModel):
|
||||
preset: str | None = None
|
||||
|
||||
|
||||
class WorkflowConfig(BaseModel):
|
||||
version: int = 1
|
||||
nodes: list[WorkflowNode]
|
||||
edges: list[WorkflowEdge] = []
|
||||
ui: WorkflowUI | None = None
|
||||
|
||||
@field_validator("nodes")
|
||||
@classmethod
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
from app.domains.rendering.workflow_config_utils import (
|
||||
build_preset_workflow_config,
|
||||
canonicalize_workflow_config,
|
||||
extract_runtime_workflow,
|
||||
)
|
||||
|
||||
|
||||
def test_build_preset_workflow_config_creates_canonical_dag():
|
||||
config = build_preset_workflow_config(
|
||||
"still",
|
||||
{"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
|
||||
)
|
||||
|
||||
assert config["version"] == 1
|
||||
assert config["ui"]["preset"] == "still"
|
||||
assert [node["step"] for node in config["nodes"]] == [
|
||||
"order_line_setup",
|
||||
"resolve_template",
|
||||
"blender_still",
|
||||
"output_save",
|
||||
]
|
||||
render_node = next(node for node in config["nodes"] if node["step"] == "blender_still")
|
||||
assert render_node["params"]["width"] == 1920
|
||||
assert render_node["params"]["height"] == 1080
|
||||
|
||||
|
||||
def test_canonicalize_workflow_config_migrates_legacy_preset():
|
||||
legacy = {
|
||||
"type": "turntable",
|
||||
"params": {"render_engine": "cycles", "samples": 64, "fps": 24},
|
||||
}
|
||||
|
||||
canonical = canonicalize_workflow_config(legacy)
|
||||
|
||||
assert canonical["version"] == 1
|
||||
assert canonical["ui"]["preset"] == "turntable"
|
||||
assert any(node["step"] == "blender_turntable" for node in canonical["nodes"])
|
||||
|
||||
|
||||
def test_extract_runtime_workflow_uses_canonical_render_node_params():
|
||||
config = build_preset_workflow_config(
|
||||
"multi_angle",
|
||||
{"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
|
||||
)
|
||||
|
||||
preset, params = extract_runtime_workflow(config)
|
||||
|
||||
assert preset == "multi_angle"
|
||||
assert params["render_engine"] == "cycles"
|
||||
assert params["samples"] == 128
|
||||
assert params["angles"] == [0.0, 45.0, 90.0]
|
||||
|
||||
|
||||
def test_canonicalize_legacy_custom_config_preserves_edges():
|
||||
legacy = {
|
||||
"type": "custom",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "input",
|
||||
"type": "inputNode",
|
||||
"position": {"x": 0, "y": 0},
|
||||
"data": {"label": "Input", "pipeline_step": "resolve_step_path", "params": {}},
|
||||
},
|
||||
{
|
||||
"id": "render",
|
||||
"type": "renderNode",
|
||||
"position": {"x": 200, "y": 0},
|
||||
"data": {"label": "Render", "pipeline_step": "blender_still", "params": {"resolution": [1024, 1024]}},
|
||||
},
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "input", "target": "render"},
|
||||
],
|
||||
}
|
||||
|
||||
canonical = canonicalize_workflow_config(legacy)
|
||||
|
||||
assert canonical["ui"]["preset"] == "custom"
|
||||
assert canonical["edges"] == [{"id": "e1", "from": "input", "to": "render"}]
|
||||
|
||||
|
||||
def test_extract_runtime_workflow_converts_resolution_to_dimensions():
|
||||
config = build_preset_workflow_config(
|
||||
"turntable",
|
||||
{"resolution": [1920, 1080], "fps": 24},
|
||||
)
|
||||
|
||||
preset, params = extract_runtime_workflow(config)
|
||||
|
||||
assert preset == "turntable"
|
||||
assert params["width"] == 1920
|
||||
assert params["height"] == 1080
|
||||
assert "resolution" not in params
|
||||
+178
-10
@@ -1,5 +1,7 @@
|
||||
import api from './client'
|
||||
|
||||
export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
|
||||
|
||||
export interface WorkflowDefinition {
|
||||
id: string
|
||||
name: string
|
||||
@@ -10,25 +12,45 @@ export interface WorkflowDefinition {
|
||||
}
|
||||
|
||||
export interface WorkflowConfig {
|
||||
type: 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
|
||||
params: WorkflowParams
|
||||
nodes?: WorkflowNode[]
|
||||
version: number
|
||||
nodes: WorkflowNode[]
|
||||
edges: WorkflowEdge[]
|
||||
ui?: WorkflowUi
|
||||
}
|
||||
|
||||
export interface WorkflowParams {
|
||||
[key: string]: unknown
|
||||
render_engine?: 'cycles' | 'eevee'
|
||||
samples?: number
|
||||
resolution?: [number, number]
|
||||
fps?: number
|
||||
duration_s?: number
|
||||
angles?: number[]
|
||||
rotation_z?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string
|
||||
type: string
|
||||
position: { x: number; y: number }
|
||||
data: Record<string, unknown>
|
||||
step: string
|
||||
params: WorkflowParams
|
||||
ui?: WorkflowNodeUi
|
||||
}
|
||||
|
||||
export interface WorkflowNodeUi {
|
||||
type?: string
|
||||
position?: { x: number; y: number }
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface WorkflowEdge {
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export interface WorkflowUi {
|
||||
preset?: WorkflowPresetType
|
||||
}
|
||||
|
||||
export interface WorkflowCreate {
|
||||
@@ -62,16 +84,16 @@ export interface WorkflowNodeResult {
|
||||
}
|
||||
|
||||
export const getWorkflows = (): Promise<WorkflowDefinition[]> =>
|
||||
api.get('/workflows').then(r => r.data)
|
||||
api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition))
|
||||
|
||||
export const getWorkflow = (id: string): Promise<WorkflowDefinition> =>
|
||||
api.get(`/workflows/${id}`).then(r => r.data)
|
||||
api.get(`/workflows/${id}`).then(r => normalizeWorkflowDefinition(r.data))
|
||||
|
||||
export const createWorkflow = (data: WorkflowCreate): Promise<WorkflowDefinition> =>
|
||||
api.post('/workflows', data).then(r => r.data)
|
||||
api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data))
|
||||
|
||||
export const updateWorkflow = (id: string, data: Partial<WorkflowCreate>): Promise<WorkflowDefinition> =>
|
||||
api.put(`/workflows/${id}`, data).then(r => r.data)
|
||||
api.put(`/workflows/${id}`, data).then(r => normalizeWorkflowDefinition(r.data))
|
||||
|
||||
export const deleteWorkflow = (id: string): Promise<void> =>
|
||||
api.delete(`/workflows/${id}`).then(() => undefined)
|
||||
@@ -96,3 +118,149 @@ export interface PipelineStepsResponse {
|
||||
|
||||
export const getPipelineSteps = (): Promise<PipelineStepsResponse> =>
|
||||
api.get('/workflows/pipeline-steps').then(r => r.data)
|
||||
|
||||
function migratePresetConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
const renderParams = { ...params }
|
||||
const resolution = Array.isArray(renderParams.resolution) ? renderParams.resolution : undefined
|
||||
if (resolution && resolution.length === 2) {
|
||||
renderParams.width = Number(resolution[0])
|
||||
renderParams.height = Number(resolution[1])
|
||||
delete renderParams.resolution
|
||||
}
|
||||
|
||||
if (type === 'still') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'render', to: 'output' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'turntable') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'turntable', step: 'blender_turntable', params: renderParams, ui: { type: 'renderFramesNode', label: 'Turntable Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 100 } } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'template', to: 'turntable' },
|
||||
{ from: 'turntable', to: 'output' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'multi_angle') {
|
||||
const angles = (params.angles ?? [0, 45, 90]).map(Number)
|
||||
const sharedParams = { ...renderParams }
|
||||
delete sharedParams.angles
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 195 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 195 } } },
|
||||
...angles.map((angle, index) => ({
|
||||
id: `render_${index}`,
|
||||
step: 'blender_still',
|
||||
params: { ...sharedParams, rotation_z: angle },
|
||||
ui: { type: 'renderNode', label: `Render ${angle}°`, position: { x: 440, y: index * 130 } },
|
||||
})),
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 700, y: 195 } } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
...angles.map((_, index) => ({ from: 'template', to: `render_${index}` })),
|
||||
...angles.map((_, index) => ({ from: `render_${index}`, to: 'output' })),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'still_with_exports') {
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: type },
|
||||
nodes: [
|
||||
{ id: 'setup', step: 'order_line_setup', params: {}, ui: { label: 'Order Line Setup', position: { x: 0, y: 100 } } },
|
||||
{ id: 'template', step: 'resolve_template', params: {}, ui: { label: 'Resolve Template', position: { x: 220, y: 100 } } },
|
||||
{ id: 'render', step: 'blender_still', params: renderParams, ui: { type: 'renderNode', label: 'Still Render', position: { x: 440, y: 100 } } },
|
||||
{ id: 'output', step: 'output_save', params: {}, ui: { type: 'outputNode', label: 'Save Output', position: { x: 660, y: 70 } } },
|
||||
{ id: 'blend', step: 'export_blend', params: {}, ui: { type: 'outputNode', label: 'Export Blend', position: { x: 660, y: 160 } } },
|
||||
],
|
||||
edges: [
|
||||
{ from: 'setup', to: 'template' },
|
||||
{ from: 'template', to: 'render' },
|
||||
{ from: 'render', to: 'output' },
|
||||
{ from: 'render', to: 'blend' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
ui: { preset: 'custom' },
|
||||
nodes: [
|
||||
{
|
||||
id: 'setup',
|
||||
step: 'order_line_setup',
|
||||
params: {},
|
||||
ui: { label: 'Order Line Setup', position: { x: 120, y: 140 } },
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWorkflowDefinition(raw: WorkflowDefinition): WorkflowDefinition {
|
||||
return {
|
||||
...raw,
|
||||
config: normalizeWorkflowConfig(raw.config as unknown as Record<string, unknown>),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeWorkflowConfig(raw: Record<string, unknown>): WorkflowConfig {
|
||||
if ('version' in raw && Array.isArray(raw.nodes)) {
|
||||
return {
|
||||
version: Number(raw.version ?? 1),
|
||||
nodes: (raw.nodes as WorkflowNode[]).map(node => ({
|
||||
...node,
|
||||
params: { ...(node.params ?? {}) },
|
||||
})),
|
||||
edges: Array.isArray(raw.edges) ? (raw.edges as WorkflowEdge[]) : [],
|
||||
ui: raw.ui as WorkflowUi | undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof raw.type === 'string') {
|
||||
return migratePresetConfig(raw.type as WorkflowPresetType, (raw.params as WorkflowParams | undefined) ?? {})
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
ui: { preset: 'custom' },
|
||||
}
|
||||
}
|
||||
|
||||
export function createPresetWorkflowConfig(type: WorkflowPresetType, params: WorkflowParams = {}): WorkflowConfig {
|
||||
return migratePresetConfig(type, params)
|
||||
}
|
||||
|
||||
export function getWorkflowPresetType(config: WorkflowConfig): WorkflowPresetType {
|
||||
return config.ui?.preset ?? 'custom'
|
||||
}
|
||||
|
||||
@@ -23,8 +23,12 @@ import {
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
getPipelineSteps,
|
||||
createPresetWorkflowConfig,
|
||||
getWorkflowPresetType,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowConfig,
|
||||
type WorkflowEdge,
|
||||
type WorkflowPresetType,
|
||||
type WorkflowParams,
|
||||
type PipelineStep,
|
||||
type StepCategory,
|
||||
@@ -44,6 +48,28 @@ import {
|
||||
} from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
function normalizeWorkflowParams(params: WorkflowParams): WorkflowParams {
|
||||
const normalized = { ...params }
|
||||
const resolution = Array.isArray(normalized.resolution) ? normalized.resolution : undefined
|
||||
if (resolution && resolution.length === 2) {
|
||||
normalized.width = Number(resolution[0])
|
||||
normalized.height = Number(resolution[1])
|
||||
delete normalized.resolution
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getResolutionSelection(params: WorkflowParams): number {
|
||||
const resolution = Array.isArray(params.resolution) ? params.resolution : undefined
|
||||
if (resolution && typeof resolution[0] === 'number') {
|
||||
return Number(resolution[0])
|
||||
}
|
||||
if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) {
|
||||
return params.width
|
||||
}
|
||||
return 2048
|
||||
}
|
||||
|
||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||
|
||||
interface BaseNodeProps {
|
||||
@@ -163,71 +189,47 @@ const nodeTypes: NodeTypes = {
|
||||
outputNode: OutputNode as any,
|
||||
}
|
||||
|
||||
// ─── Workflow → Graph conversion ─────────────────────────────────────────────
|
||||
function inferNodeType(step: string): string {
|
||||
if (step === 'resolve_step_path') return 'inputNode'
|
||||
if (step === 'stl_cache_generate') return 'convertNode'
|
||||
if (step === 'blender_turntable') return 'renderFramesNode'
|
||||
if (step === 'output_save' || step === 'export_blend') return 'outputNode'
|
||||
return 'renderNode'
|
||||
}
|
||||
|
||||
function inferNodeLabel(step: string): string {
|
||||
return step
|
||||
.split('_')
|
||||
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function inferStepFromNodeType(type?: string): string {
|
||||
if (type === 'inputNode') return 'resolve_step_path'
|
||||
if (type === 'convertNode') return 'stl_cache_generate'
|
||||
if (type === 'renderFramesNode') return 'blender_turntable'
|
||||
if (type === 'outputNode') return 'output_save'
|
||||
return 'blender_still'
|
||||
}
|
||||
|
||||
function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } {
|
||||
const Y = 100
|
||||
|
||||
if (config.type === 'still') {
|
||||
const nodes: Node[] = [
|
||||
{ id: 'input', type: 'inputNode', position: { x: 0, y: Y }, data: { label: 'STEP Input' } },
|
||||
{ id: 'convert', type: 'convertNode', position: { x: 220, y: Y }, data: { label: 'STL Conversion' } },
|
||||
{ id: 'render', type: 'renderNode', position: { x: 440, y: Y }, data: { label: 'Still Render', params: config.params } },
|
||||
{ id: 'output', type: 'outputNode', position: { x: 660, y: Y }, data: { label: 'PNG Output' } },
|
||||
]
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e1', source: 'input', target: 'convert' },
|
||||
{ id: 'e2', source: 'convert', target: 'render' },
|
||||
{ id: 'e3', source: 'render', target: 'output' },
|
||||
]
|
||||
return { nodes, edges }
|
||||
return {
|
||||
nodes: config.nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: node.ui?.type ?? inferNodeType(node.step),
|
||||
position: node.ui?.position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.ui?.label ?? inferNodeLabel(node.step),
|
||||
params: node.params ?? {},
|
||||
step: node.step,
|
||||
},
|
||||
})),
|
||||
edges: config.edges.map((edge, index) => ({
|
||||
id: `e_${edge.from}_${edge.to}_${index}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
})),
|
||||
}
|
||||
|
||||
if (config.type === 'turntable') {
|
||||
const nodes: Node[] = [
|
||||
{ id: 'input', type: 'inputNode', position: { x: 0, y: Y }, data: {} },
|
||||
{ id: 'convert', type: 'convertNode', position: { x: 220, y: Y }, data: {} },
|
||||
{ id: 'frames', type: 'renderFramesNode', position: { x: 440, y: Y }, data: { params: config.params } },
|
||||
{ id: 'ffmpeg', type: 'ffmpegNode', position: { x: 660, y: Y }, data: {} },
|
||||
{ id: 'output', type: 'outputNode', position: { x: 880, y: Y }, data: { label: 'MP4 Output' } },
|
||||
]
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e1', source: 'input', target: 'convert' },
|
||||
{ id: 'e2', source: 'convert', target: 'frames' },
|
||||
{ id: 'e3', source: 'frames', target: 'ffmpeg' },
|
||||
{ id: 'e4', source: 'ffmpeg', target: 'output' },
|
||||
]
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
if (config.type === 'multi_angle') {
|
||||
const angles = config.params.angles ?? [0, 45, 90]
|
||||
const renderNodes: Node[] = angles.map((angle, i) => ({
|
||||
id: `render_${i}`,
|
||||
type: 'renderNode',
|
||||
position: { x: 440, y: i * 130 },
|
||||
data: { label: `Render ${angle}°`, params: { ...config.params, camera_angle: angle } },
|
||||
}))
|
||||
const nodes: Node[] = [
|
||||
{ id: 'input', type: 'inputNode', position: { x: 0, y: angles.length * 65 }, data: {} },
|
||||
{ id: 'convert', type: 'convertNode', position: { x: 220, y: angles.length * 65 }, data: {} },
|
||||
...renderNodes,
|
||||
{ id: 'output', type: 'outputNode', position: { x: 700, y: angles.length * 65 }, data: {} },
|
||||
]
|
||||
const edges: Edge[] = [
|
||||
{ id: 'e_in', source: 'input', target: 'convert' },
|
||||
...angles.map((_, i) => ({ id: `e_conv_${i}`, source: 'convert', target: `render_${i}` })),
|
||||
...angles.map((_, i) => ({ id: `e_out_${i}`, source: `render_${i}`, target: 'output' })),
|
||||
]
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
// custom: use nodes from config if present
|
||||
if (config.nodes && config.nodes.length > 0) {
|
||||
return { nodes: config.nodes as Node[], edges: [] }
|
||||
}
|
||||
|
||||
return { nodes: [], edges: [] }
|
||||
}
|
||||
|
||||
// ─── Config Sidepanel ─────────────────────────────────────────────────────────
|
||||
@@ -320,9 +322,9 @@ function ConfigSidepanel({
|
||||
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => onChange({ ...params, resolution: [w, w] })}
|
||||
onClick={() => onChange(normalizeWorkflowParams({ ...params, resolution: [w, w] }))}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
(params.resolution?.[0] ?? 2048) === w
|
||||
getResolutionSelection(params) === w
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
||||
}`}
|
||||
@@ -455,13 +457,13 @@ const NODE_PALETTE = [
|
||||
|
||||
interface NewWorkflowModalProps {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, type: WorkflowConfig['type']) => void
|
||||
onCreate: (name: string, type: WorkflowPresetType) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<WorkflowConfig['type']>('still')
|
||||
const [type, setType] = useState<WorkflowPresetType>('still')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
@@ -494,7 +496,7 @@ function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProp
|
||||
{ value: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' },
|
||||
{ value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' },
|
||||
{ value: 'custom', label: 'Custom', desc: 'Free canvas' },
|
||||
] as { value: WorkflowConfig['type']; label: string; desc: string }[]).map(opt => (
|
||||
] as { value: WorkflowPresetType; label: string; desc: string }[]).map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setType(opt.value)}
|
||||
@@ -545,7 +547,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initNodes)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [params, setParams] = useState<WorkflowParams>(workflow.config.params)
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
||||
|
||||
@@ -563,8 +564,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeId(node.id)
|
||||
const nodeParams = (node.data as any).params as WorkflowParams | undefined
|
||||
if (nodeParams) setParams(nodeParams)
|
||||
}, [])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -573,11 +572,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
const handleParamsChange = useCallback(
|
||||
(newParams: WorkflowParams) => {
|
||||
setParams(newParams)
|
||||
setNodes(nds =>
|
||||
nds.map(n => {
|
||||
if (n.id === selectedNodeId) {
|
||||
return { ...n, data: { ...n.data, params: newParams } }
|
||||
return { ...n, data: { ...n.data, params: normalizeWorkflowParams(newParams) } }
|
||||
}
|
||||
return n
|
||||
}),
|
||||
@@ -591,7 +589,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
setNodes(nds =>
|
||||
nds.map(n => {
|
||||
if (n.id === selectedNodeId) {
|
||||
return { ...n, data: { ...n.data, pipeline_step: stepName || undefined } }
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
step: stepName || inferStepFromNodeType(n.type),
|
||||
label: (n.data as any).label ?? inferNodeLabel(stepName),
|
||||
},
|
||||
}
|
||||
}
|
||||
return n
|
||||
}),
|
||||
@@ -621,7 +626,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
id: `${type}_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: { label: type },
|
||||
data: {
|
||||
label: type,
|
||||
params: {},
|
||||
step: inferStepFromNodeType(type),
|
||||
},
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
},
|
||||
@@ -630,9 +639,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedConfig: WorkflowConfig = {
|
||||
...workflow.config,
|
||||
params,
|
||||
nodes: nodes as any,
|
||||
version: workflow.config.version ?? 1,
|
||||
ui: workflow.config.ui,
|
||||
nodes: nodes.map(node => ({
|
||||
id: node.id,
|
||||
step: ((node.data as any).step as string | undefined) ?? inferStepFromNodeType(node.type),
|
||||
params: normalizeWorkflowParams((((node.data as any).params as WorkflowParams | undefined) ?? {})),
|
||||
ui: {
|
||||
type: node.type,
|
||||
position: node.position,
|
||||
label: ((node.data as any).label as string | undefined) ?? inferNodeLabel(((node.data as any).step as string | undefined) ?? inferStepFromNodeType(node.type)),
|
||||
},
|
||||
})),
|
||||
edges: edges.map(edge => ({
|
||||
from: edge.source,
|
||||
to: edge.target,
|
||||
})) as WorkflowEdge[],
|
||||
}
|
||||
onSave(updatedConfig)
|
||||
}
|
||||
@@ -698,9 +720,9 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
{selectedNode && (
|
||||
<ConfigSidepanel
|
||||
params={params}
|
||||
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
||||
onChange={handleParamsChange}
|
||||
pipelineStep={(selectedNode.data as any).pipeline_step as string | undefined}
|
||||
pipelineStep={(selectedNode.data as any).step as string | undefined}
|
||||
onPipelineStepChange={handlePipelineStepChange}
|
||||
pipelineSteps={pipelineSteps}
|
||||
/>
|
||||
@@ -758,7 +780,7 @@ export default function WorkflowEditor() {
|
||||
onError: () => toast.error('Failed to delete workflow'),
|
||||
})
|
||||
|
||||
const handleCreate = (name: string, type: WorkflowConfig['type']) => {
|
||||
const handleCreate = (name: string, type: WorkflowPresetType) => {
|
||||
const defaultParams: WorkflowParams =
|
||||
type === 'turntable'
|
||||
? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 }
|
||||
@@ -768,14 +790,14 @@ export default function WorkflowEditor() {
|
||||
|
||||
createMutation.mutate({
|
||||
name,
|
||||
config: { type, params: defaultParams },
|
||||
config: createPresetWorkflowConfig(type, defaultParams),
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null
|
||||
|
||||
const typeLabel: Record<WorkflowConfig['type'], string> = {
|
||||
const typeLabel: Record<WorkflowPresetType, string> = {
|
||||
still: 'Still',
|
||||
turntable: 'Turntable',
|
||||
multi_angle: 'Multi-Angle',
|
||||
@@ -783,7 +805,7 @@ export default function WorkflowEditor() {
|
||||
custom: 'Custom',
|
||||
}
|
||||
|
||||
const typeBadgeColor: Record<WorkflowConfig['type'], string> = {
|
||||
const typeBadgeColor: Record<WorkflowPresetType, string> = {
|
||||
still: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
turntable: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
multi_angle: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
@@ -827,7 +849,9 @@ export default function WorkflowEditor() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{workflows.map(wf => (
|
||||
{workflows.map(wf => {
|
||||
const presetType = getWorkflowPresetType(wf.config)
|
||||
return (
|
||||
<button
|
||||
key={wf.id}
|
||||
onClick={() => setSelectedId(wf.id)}
|
||||
@@ -862,16 +886,17 @@ export default function WorkflowEditor() {
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block mt-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
typeBadgeColor[wf.config.type]
|
||||
typeBadgeColor[presetType]
|
||||
}`}
|
||||
>
|
||||
{typeLabel[wf.config.type]}
|
||||
{typeLabel[presetType]}
|
||||
</span>
|
||||
{!wf.is_active && (
|
||||
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user