From 63e35ce80734cce1761ea3007c536254e1c0f7e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Tue, 7 Apr 2026 08:48:48 +0200 Subject: [PATCH] feat: stabilize workflow phase 1 foundation --- backend/app/api/routers/admin.py | 34 +-- .../app/domains/rendering/dispatch_service.py | 13 +- .../rendering/workflow_config_utils.py | 286 ++++++++++++++++++ .../app/domains/rendering/workflow_router.py | 40 ++- .../app/domains/rendering/workflow_schema.py | 17 ++ .../domains/test_workflow_config_utils.py | 93 ++++++ frontend/src/api/workflows.ts | 188 +++++++++++- frontend/src/pages/WorkflowEditor.tsx | 199 ++++++------ 8 files changed, 742 insertions(+), 128 deletions(-) create mode 100644 backend/app/domains/rendering/workflow_config_utils.py create mode 100644 backend/tests/domains/test_workflow_config_utils.py diff --git a/backend/app/api/routers/admin.py b/backend/app/api/routers/admin.py index c31d4d7..ee9d40e 100644 --- a/backend/app/api/routers/admin.py +++ b/backend/app/api/routers/admin.py @@ -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, ) - diff --git a/backend/app/domains/rendering/dispatch_service.py b/backend/app/domains/rendering/dispatch_service.py index 52a185e..ae7a288 100644 --- a/backend/app/domains/rendering/dispatch_service.py +++ b/backend/app/domains/rendering/dispatch_service.py @@ -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)", diff --git a/backend/app/domains/rendering/workflow_config_utils.py b/backend/app/domains/rendering/workflow_config_utils.py new file mode 100644 index 0000000..94f6575 --- /dev/null +++ b/backend/app/domains/rendering/workflow_config_utils.py @@ -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, {} diff --git a/backend/app/domains/rendering/workflow_router.py b/backend/app/domains/rendering/workflow_router.py index 4b7fd37..554c120 100644 --- a/backend/app/domains/rendering/workflow_router.py +++ b/backend/app/domains/rendering/workflow_router.py @@ -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) diff --git a/backend/app/domains/rendering/workflow_schema.py b/backend/app/domains/rendering/workflow_schema.py index 5cc20b2..1780a97 100644 --- a/backend/app/domains/rendering/workflow_schema.py +++ b/backend/app/domains/rendering/workflow_schema.py @@ -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 diff --git a/backend/tests/domains/test_workflow_config_utils.py b/backend/tests/domains/test_workflow_config_utils.py new file mode 100644 index 0000000..35bdefd --- /dev/null +++ b/backend/tests/domains/test_workflow_config_utils.py @@ -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 diff --git a/frontend/src/api/workflows.ts b/frontend/src/api/workflows.ts index 56efeb2..a879621 100644 --- a/frontend/src/api/workflows.ts +++ b/frontend/src/api/workflows.ts @@ -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 + 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 => - api.get('/workflows').then(r => r.data) + api.get('/workflows').then(r => r.data.map(normalizeWorkflowDefinition)) export const getWorkflow = (id: string): Promise => - api.get(`/workflows/${id}`).then(r => r.data) + api.get(`/workflows/${id}`).then(r => normalizeWorkflowDefinition(r.data)) export const createWorkflow = (data: WorkflowCreate): Promise => - api.post('/workflows', data).then(r => r.data) + api.post('/workflows', data).then(r => normalizeWorkflowDefinition(r.data)) export const updateWorkflow = (id: string, data: Partial): Promise => - 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 => api.delete(`/workflows/${id}`).then(() => undefined) @@ -96,3 +118,149 @@ export interface PipelineStepsResponse { export const getPipelineSteps = (): Promise => 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), + } +} + +export function normalizeWorkflowConfig(raw: Record): 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' +} diff --git a/frontend/src/pages/WorkflowEditor.tsx b/frontend/src/pages/WorkflowEditor.tsx index 658eb2f..96ec842 100644 --- a/frontend/src/pages/WorkflowEditor.tsx +++ b/frontend/src/pages/WorkflowEditor.tsx @@ -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]) => ( )} - {workflows.map(wf => ( + {workflows.map(wf => { + const presetType = getWorkflowPresetType(wf.config) + return ( - ))} + ) + })}