feat: stabilize workflow phase 1 foundation

This commit is contained in:
2026-04-07 08:48:48 +02:00
parent bc9ab5f864
commit 63e35ce807
8 changed files with 742 additions and 128 deletions
+17 -17
View File
@@ -680,35 +680,36 @@ async def seed_workflows(
): ):
"""Create the standard workflow definitions if they do not already exist.""" """Create the standard workflow definitions if they do not already exist."""
from app.domains.rendering.models import WorkflowDefinition from app.domains.rendering.models import WorkflowDefinition
from app.domains.rendering.workflow_config_utils import build_preset_workflow_config
STANDARD_WORKFLOWS = [ STANDARD_WORKFLOWS = [
{ {
"name": "Still Image — Cycles", "name": "Still Image — Cycles",
"config": { "config": build_preset_workflow_config(
"type": "still", "still",
"params": {"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]}, {"render_engine": "cycles", "samples": 256, "resolution": [1920, 1080]},
}, ),
}, },
{ {
"name": "Still Image — EEVEE", "name": "Still Image — EEVEE",
"config": { "config": build_preset_workflow_config(
"type": "still", "still",
"params": {"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]}, {"render_engine": "eevee", "samples": 64, "resolution": [1920, 1080]},
}, ),
}, },
{ {
"name": "Turntable Animation", "name": "Turntable Animation",
"config": { "config": build_preset_workflow_config(
"type": "turntable", "turntable",
"params": {"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5}, {"render_engine": "cycles", "samples": 64, "fps": 24, "duration_s": 5},
}, ),
}, },
{ {
"name": "Multi-Angle (0° / 45° / 90°)", "name": "Multi-Angle (0° / 45° / 90°)",
"config": { "config": build_preset_workflow_config(
"type": "multi_angle", "multi_angle",
"params": {"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]}, {"render_engine": "cycles", "samples": 128, "angles": [0, 45, 90]},
}, ),
}, },
] ]
@@ -1093,4 +1094,3 @@ async def get_dashboard_stats(
product_stats=product_stats, product_stats=product_stats,
order_status=order_status, order_status=order_status,
) )
@@ -77,8 +77,17 @@ def dispatch_render_with_workflow(order_line_id: str) -> dict:
) )
return _legacy_dispatch(order_line_id) return _legacy_dispatch(order_line_id)
workflow_type = wf_def.config.get("type") from app.domains.rendering.workflow_config_utils import extract_runtime_workflow
params = wf_def.config.get("params", {})
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( logger.info(
"order_line %s: dispatching via WorkflowDefinition %s (type=%s)", "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, WorkflowDefinitionOut,
WorkflowRunOut, WorkflowRunOut,
) )
from app.domains.rendering.workflow_config_utils import canonicalize_workflow_config
from app.domains.rendering.workflow_schema import WorkflowConfig from app.domains.rendering.workflow_schema import WorkflowConfig
from app.core.process_steps import StepName from app.core.process_steps import StepName
@@ -90,6 +91,17 @@ class PipelineStepsResponse(BaseModel):
router = APIRouter(prefix="/api/workflows", tags=["workflows"]) 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) @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),
@@ -115,7 +127,7 @@ async def list_workflows(
result = await db.execute( result = await db.execute(
select(WorkflowDefinition).order_by(WorkflowDefinition.created_at) 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) @router.get("/{workflow_id}", response_model=WorkflowDefinitionOut)
@@ -130,7 +142,7 @@ async def get_workflow(
wf = result.scalar_one_or_none() wf = result.scalar_one_or_none()
if not wf: if not wf:
raise HTTPException(status_code=404, detail="Workflow definition not found") 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) @router.post("", response_model=WorkflowDefinitionOut, status_code=201)
@@ -139,21 +151,23 @@ async def create_workflow(
_user: User = Depends(require_global_admin), _user: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
normalized_config = canonicalize_workflow_config(body.config)
if body.config: if body.config:
try: try:
WorkflowConfig.model_validate(body.config) WorkflowConfig.model_validate(normalized_config)
except ValidationError as exc: except (ValidationError, ValueError) as exc:
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}") detail = exc.errors() if isinstance(exc, ValidationError) else str(exc)
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {detail}")
wf = WorkflowDefinition( wf = WorkflowDefinition(
name=body.name, name=body.name,
output_type_id=body.output_type_id, output_type_id=body.output_type_id,
config=body.config, config=normalized_config,
is_active=body.is_active, is_active=body.is_active,
) )
db.add(wf) db.add(wf)
await db.commit() await db.commit()
await db.refresh(wf) await db.refresh(wf)
return wf return _workflow_to_out(wf)
@router.put("/{workflow_id}", response_model=WorkflowDefinitionOut) @router.put("/{workflow_id}", response_model=WorkflowDefinitionOut)
@@ -174,16 +188,18 @@ async def update_workflow(
wf.name = body.name wf.name = body.name
if body.config is not None: if body.config is not None:
try: try:
WorkflowConfig.model_validate(body.config) normalized_config = canonicalize_workflow_config(body.config)
except ValidationError as exc: WorkflowConfig.model_validate(normalized_config)
raise HTTPException(status_code=422, detail=f"Invalid workflow config: {exc.errors()}") except (ValidationError, ValueError) as exc:
wf.config = body.config 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: if body.is_active is not None:
wf.is_active = body.is_active wf.is_active = body.is_active
await db.commit() await db.commit()
await db.refresh(wf) await db.refresh(wf)
return wf return _workflow_to_out(wf)
@router.delete("/{workflow_id}", status_code=204) @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 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): class WorkflowNode(BaseModel):
id: str id: str
step: StepName # validated against the StepName StrEnum step: StepName # validated against the StepName StrEnum
params: dict = {} params: dict = {}
ui: WorkflowNodeUI | None = None
class WorkflowEdge(BaseModel): class WorkflowEdge(BaseModel):
@@ -35,10 +47,15 @@ class WorkflowEdge(BaseModel):
model_config = {"populate_by_name": True} model_config = {"populate_by_name": True}
class WorkflowUI(BaseModel):
preset: str | None = None
class WorkflowConfig(BaseModel): class WorkflowConfig(BaseModel):
version: int = 1 version: int = 1
nodes: list[WorkflowNode] nodes: list[WorkflowNode]
edges: list[WorkflowEdge] = [] edges: list[WorkflowEdge] = []
ui: WorkflowUI | None = None
@field_validator("nodes") @field_validator("nodes")
@classmethod @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
View File
@@ -1,5 +1,7 @@
import api from './client' import api from './client'
export type WorkflowPresetType = 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom'
export interface WorkflowDefinition { export interface WorkflowDefinition {
id: string id: string
name: string name: string
@@ -10,25 +12,45 @@ export interface WorkflowDefinition {
} }
export interface WorkflowConfig { export interface WorkflowConfig {
type: 'still' | 'turntable' | 'multi_angle' | 'still_with_exports' | 'custom' version: number
params: WorkflowParams nodes: WorkflowNode[]
nodes?: WorkflowNode[] edges: WorkflowEdge[]
ui?: WorkflowUi
} }
export interface WorkflowParams { export interface WorkflowParams {
[key: string]: unknown
render_engine?: 'cycles' | 'eevee' render_engine?: 'cycles' | 'eevee'
samples?: number samples?: number
resolution?: [number, number] resolution?: [number, number]
fps?: number fps?: number
duration_s?: number duration_s?: number
angles?: number[] angles?: number[]
rotation_z?: number
width?: number
height?: number
} }
export interface WorkflowNode { export interface WorkflowNode {
id: string id: string
type: string step: string
position: { x: number; y: number } params: WorkflowParams
data: Record<string, unknown> 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 { export interface WorkflowCreate {
@@ -62,16 +84,16 @@ export interface WorkflowNodeResult {
} }
export const getWorkflows = (): Promise<WorkflowDefinition[]> => 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> => 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> => 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> => 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> => export const deleteWorkflow = (id: string): Promise<void> =>
api.delete(`/workflows/${id}`).then(() => undefined) api.delete(`/workflows/${id}`).then(() => undefined)
@@ -96,3 +118,149 @@ export interface PipelineStepsResponse {
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)
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'
}
+112 -87
View File
@@ -23,8 +23,12 @@ import {
updateWorkflow, updateWorkflow,
deleteWorkflow, deleteWorkflow,
getPipelineSteps, getPipelineSteps,
createPresetWorkflowConfig,
getWorkflowPresetType,
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowConfig, type WorkflowConfig,
type WorkflowEdge,
type WorkflowPresetType,
type WorkflowParams, type WorkflowParams,
type PipelineStep, type PipelineStep,
type StepCategory, type StepCategory,
@@ -44,6 +48,28 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { toast } from 'sonner' 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 ────────────────────────────────────────────────── // ─── Custom Node Components ──────────────────────────────────────────────────
interface BaseNodeProps { interface BaseNodeProps {
@@ -163,71 +189,47 @@ const nodeTypes: NodeTypes = {
outputNode: OutputNode as any, 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[] } { function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[] } {
const Y = 100 return {
nodes: config.nodes.map(node => ({
if (config.type === 'still') { id: node.id,
const nodes: Node[] = [ type: node.ui?.type ?? inferNodeType(node.step),
{ id: 'input', type: 'inputNode', position: { x: 0, y: Y }, data: { label: 'STEP Input' } }, position: node.ui?.position ?? { x: 0, y: 0 },
{ id: 'convert', type: 'convertNode', position: { x: 220, y: Y }, data: { label: 'STL Conversion' } }, data: {
{ id: 'render', type: 'renderNode', position: { x: 440, y: Y }, data: { label: 'Still Render', params: config.params } }, label: node.ui?.label ?? inferNodeLabel(node.step),
{ id: 'output', type: 'outputNode', position: { x: 660, y: Y }, data: { label: 'PNG Output' } }, params: node.params ?? {},
] step: node.step,
const edges: Edge[] = [ },
{ id: 'e1', source: 'input', target: 'convert' }, })),
{ id: 'e2', source: 'convert', target: 'render' }, edges: config.edges.map((edge, index) => ({
{ id: 'e3', source: 'render', target: 'output' }, id: `e_${edge.from}_${edge.to}_${index}`,
] source: edge.from,
return { nodes, edges } 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 ───────────────────────────────────────────────────────── // ─── Config Sidepanel ─────────────────────────────────────────────────────────
@@ -320,9 +322,9 @@ function ConfigSidepanel({
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => ( {([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
<button <button
key={w} 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 ${ 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-accent text-white'
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted' : 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
}`} }`}
@@ -455,13 +457,13 @@ const NODE_PALETTE = [
interface NewWorkflowModalProps { interface NewWorkflowModalProps {
onClose: () => void onClose: () => void
onCreate: (name: string, type: WorkflowConfig['type']) => void onCreate: (name: string, type: WorkflowPresetType) => void
isLoading: boolean isLoading: boolean
} }
function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) { function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) {
const [name, setName] = useState('') const [name, setName] = useState('')
const [type, setType] = useState<WorkflowConfig['type']>('still') const [type, setType] = useState<WorkflowPresetType>('still')
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50"> <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: 'multi_angle', label: 'Multi-Angle', desc: 'Multiple angles' },
{ value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' }, { value: 'still_with_exports', label: 'Still + GLB', desc: 'PNG + GLB exports' },
{ value: 'custom', label: 'Custom', desc: 'Free canvas' }, { 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 <button
key={opt.value} key={opt.value}
onClick={() => setType(opt.value)} onClick={() => setType(opt.value)}
@@ -545,7 +547,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
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 [params, setParams] = useState<WorkflowParams>(workflow.config.params)
const reactFlowWrapper = useRef<HTMLDivElement>(null) const reactFlowWrapper = useRef<HTMLDivElement>(null)
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null) const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
@@ -563,8 +564,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNodeId(node.id) setSelectedNodeId(node.id)
const nodeParams = (node.data as any).params as WorkflowParams | undefined
if (nodeParams) setParams(nodeParams)
}, []) }, [])
const onPaneClick = useCallback(() => { const onPaneClick = useCallback(() => {
@@ -573,11 +572,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const handleParamsChange = useCallback( const handleParamsChange = useCallback(
(newParams: WorkflowParams) => { (newParams: WorkflowParams) => {
setParams(newParams)
setNodes(nds => setNodes(nds =>
nds.map(n => { nds.map(n => {
if (n.id === selectedNodeId) { if (n.id === selectedNodeId) {
return { ...n, data: { ...n.data, params: newParams } } return { ...n, data: { ...n.data, params: normalizeWorkflowParams(newParams) } }
} }
return n return n
}), }),
@@ -591,7 +589,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
setNodes(nds => setNodes(nds =>
nds.map(n => { nds.map(n => {
if (n.id === selectedNodeId) { 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 return n
}), }),
@@ -621,7 +626,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
id: `${type}_${Date.now()}`, id: `${type}_${Date.now()}`,
type, type,
position, position,
data: { label: type }, data: {
label: type,
params: {},
step: inferStepFromNodeType(type),
},
} }
setNodes(nds => [...nds, newNode]) setNodes(nds => [...nds, newNode])
}, },
@@ -630,9 +639,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
const handleSave = () => { const handleSave = () => {
const updatedConfig: WorkflowConfig = { const updatedConfig: WorkflowConfig = {
...workflow.config, version: workflow.config.version ?? 1,
params, ui: workflow.config.ui,
nodes: nodes as any, 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) onSave(updatedConfig)
} }
@@ -698,9 +720,9 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
{selectedNode && ( {selectedNode && (
<ConfigSidepanel <ConfigSidepanel
params={params} params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
onChange={handleParamsChange} onChange={handleParamsChange}
pipelineStep={(selectedNode.data as any).pipeline_step as string | undefined} pipelineStep={(selectedNode.data as any).step as string | undefined}
onPipelineStepChange={handlePipelineStepChange} onPipelineStepChange={handlePipelineStepChange}
pipelineSteps={pipelineSteps} pipelineSteps={pipelineSteps}
/> />
@@ -758,7 +780,7 @@ export default function WorkflowEditor() {
onError: () => toast.error('Failed to delete workflow'), onError: () => toast.error('Failed to delete workflow'),
}) })
const handleCreate = (name: string, type: WorkflowConfig['type']) => { const handleCreate = (name: string, type: WorkflowPresetType) => {
const defaultParams: WorkflowParams = const defaultParams: WorkflowParams =
type === 'turntable' type === 'turntable'
? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 } ? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 }
@@ -768,14 +790,14 @@ export default function WorkflowEditor() {
createMutation.mutate({ createMutation.mutate({
name, name,
config: { type, params: defaultParams }, config: createPresetWorkflowConfig(type, defaultParams),
is_active: true, is_active: true,
}) })
} }
const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null
const typeLabel: Record<WorkflowConfig['type'], string> = { const typeLabel: Record<WorkflowPresetType, string> = {
still: 'Still', still: 'Still',
turntable: 'Turntable', turntable: 'Turntable',
multi_angle: 'Multi-Angle', multi_angle: 'Multi-Angle',
@@ -783,7 +805,7 @@ export default function WorkflowEditor() {
custom: 'Custom', 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', 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', 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', 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> </button>
</div> </div>
)} )}
{workflows.map(wf => ( {workflows.map(wf => {
const presetType = getWorkflowPresetType(wf.config)
return (
<button <button
key={wf.id} key={wf.id}
onClick={() => setSelectedId(wf.id)} onClick={() => setSelectedId(wf.id)}
@@ -862,16 +886,17 @@ export default function WorkflowEditor() {
</div> </div>
<span <span
className={`inline-block mt-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${ 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> </span>
{!wf.is_active && ( {!wf.is_active && (
<span className="ml-1 text-xs text-content-muted">(inactive)</span> <span className="ml-1 text-xs text-content-muted">(inactive)</span>
)} )}
</button> </button>
))} )
})}
</div> </div>
</aside> </aside>