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
+112 -87
View File
@@ -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>