import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react' import { ReactFlow, Background, Controls, MiniMap, addEdge, useNodesState, useEdgesState, Handle, Position, type Node, type Edge, type Connection, type NodeTypes, } from '@xyflow/react' import '@xyflow/react/dist/style.css' import { useThemeStore, resolveTheme } from '../store/theme' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { getWorkflows, createWorkflow, updateWorkflow, deleteWorkflow, getNodeDefinitions, createPresetWorkflowConfig, getWorkflowPresetType, type WorkflowDefinition, type WorkflowConfig, type WorkflowEdge, type WorkflowPresetType, type WorkflowParams, type StepCategory, type WorkflowNodeDefinition, type WorkflowNodeFieldDefinition, } from '../api/workflows' import { FileUp, RefreshCw, Camera, Film, Layers, Download, Bell, Plus, Save, Trash2, GitBranch, X, } 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 } type WorkflowCanvasNodeData = { label: string params: WorkflowParams step: string description?: string icon?: string category?: StepCategory } function renderWorkflowIcon(iconName?: string, size = 14) { switch (iconName) { case 'file-up': return case 'film': return case 'layers': return case 'download': return case 'bell': return case 'camera': return case 'refresh-cw': default: return } } function buildNodeData( step: string, params: WorkflowParams = {}, definition?: WorkflowNodeDefinition, overrides?: Partial, ): WorkflowCanvasNodeData { return { label: overrides?.label ?? definition?.label ?? inferNodeLabel(step), params: normalizeWorkflowParams(params), step, description: overrides?.description ?? definition?.description, icon: overrides?.icon ?? definition?.icon, category: overrides?.category ?? definition?.category, } } // ─── Custom Node Components ────────────────────────────────────────────────── interface BaseNodeProps { label: string icon: React.ReactNode accentClass: string description?: string selected?: boolean hasSource?: boolean hasTarget?: boolean } function BaseNode({ label, icon, accentClass, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) { return (
{hasTarget && ( )}
{icon} {label}
{description &&

{description}

} {hasSource && ( )}
) } function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( ) } function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( ) } function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( ) } function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { const params = data.params ?? {} return ( ) } function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { const params = data.params ?? {} return ( ) } function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) { return ( ) } const nodeTypes: NodeTypes = { inputNode: InputNode as any, convertNode: ConvertNode as any, processNode: ProcessNode as any, renderNode: RenderNode as any, renderFramesNode: RenderFramesNode as any, outputNode: OutputNode as any, } 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' || step === 'notify' || step === 'thumbnail_save') return 'outputNode' if (step.startsWith('blender_') || step === 'threejs_render') return 'renderNode' if (step.startsWith('occ_') || step === 'glb_bbox' || step === 'material_map_resolve' || step === 'auto_populate_materials') { return 'processNode' } return 'renderNode' } 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 === 'processNode') return 'order_line_setup' if (type === 'renderFramesNode') return 'blender_turntable' if (type === 'outputNode') return 'output_save' return 'blender_still' } function workflowToGraph( config: WorkflowConfig, nodeDefinitionsByStep: Record, ): { nodes: Node[]; edges: Edge[] } { return { nodes: config.nodes.map(node => ({ id: node.id, type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step), position: node.ui?.position ?? { x: 0, y: 0 }, data: buildNodeData(node.step, node.params ?? {}, nodeDefinitionsByStep[node.step], { label: node.ui?.label ?? undefined, }), })), edges: config.edges.map((edge, index) => ({ id: `e_${edge.from}_${edge.to}_${index}`, source: edge.from, target: edge.to, })), } } // ─── Config Sidepanel ───────────────────────────────────────────────────────── function groupFieldsBySection(fields: WorkflowNodeFieldDefinition[]) { return fields.reduce>((sections, field) => { const section = field.section || 'General' sections[section] = [...(sections[section] ?? []), field] return sections }, {}) } function ConfigSidepanel({ params, onChange, nodeDefinition, step, onStepChange, nodeDefinitions, }: { params: WorkflowParams onChange: (p: WorkflowParams) => void nodeDefinition?: WorkflowNodeDefinition step?: string onStepChange?: (step: string) => void nodeDefinitions: WorkflowNodeDefinition[] }) { const updateField = (field: WorkflowNodeFieldDefinition, value: unknown) => { onChange( normalizeWorkflowParams({ ...params, [field.key]: value, }), ) } const handleNumberChange = (field: WorkflowNodeFieldDefinition, event: ChangeEvent) => { const rawValue = event.target.value if (rawValue === '') { const nextParams = { ...params } delete nextParams[field.key] onChange(nextParams) return } updateField(field, Number(rawValue)) } const fieldsBySection = groupFieldsBySection(nodeDefinition?.fields ?? []) return (

Node Configuration

{nodeDefinitions.length > 0 && onStepChange && (
{nodeDefinition && (

{nodeDefinition.description}

{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
)}
)} {Object.keys(fieldsBySection).length === 0 && (

This node currently has no configurable settings in the editor.

)} {Object.entries(fieldsBySection).map(([section, fields]) => (

{section}

{fields.map(field => { const rawValue = params[field.key] const value = rawValue ?? field.default return (
{field.type === 'select' && ( )} {field.type === 'number' && ( handleNumberChange(field, event)} className="w-full border border-border-default rounded-lg px-3 py-2 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent" /> )} {field.type === 'boolean' && ( )} {field.description && (

{field.description}

)}
) })}
))}
) } // ─── Node Definitions Panel ─────────────────────────────────────────────────── const CATEGORY_LABELS: Record = { input: 'Input', processing: 'Processing', rendering: 'Rendering', output: 'Output', } const CATEGORY_COLORS: Record = { input: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', processing: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', rendering: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300', output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300', } function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) { const [expanded, setExpanded] = useState(null) const grouped = definitions.reduce>( (acc, definition) => { acc[definition.category] = [...(acc[definition.category] ?? []), definition] return acc }, { input: [], processing: [], rendering: [], output: [] }, ) const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output'] return (

Available Nodes

{categories.map(cat => (
{expanded === cat && (
{grouped[cat].map(definition => (

{definition.label}

{definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'}

{definition.step}

{definition.description}

))}
)}
))}
) } // ─── New Workflow Modal ─────────────────────────────────────────────────────── interface NewWorkflowModalProps { onClose: () => void onCreate: (name: string, type: WorkflowPresetType) => void isLoading: boolean } function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) { const [name, setName] = useState('') const [type, setType] = useState('still') return (

New Workflow

setName(e.target.value)} autoFocus />
{([ { value: 'still', label: 'Still', desc: 'Single PNG image' }, { value: 'turntable', label: 'Turntable', desc: 'Animation MP4' }, { 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: WorkflowPresetType; label: string; desc: string }[]).map(opt => ( ))}
) } // ─── Flow Canvas ────────────────────────────────────────────────────────────── interface FlowCanvasProps { workflow: WorkflowDefinition onSave: (config: WorkflowConfig) => void isSaving: boolean } function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) { const { data: nodeDefinitionsData } = useQuery({ queryKey: ['workflow-node-definitions'], queryFn: getNodeDefinitions, staleTime: 5 * 60 * 1000, }) const nodeDefinitions = nodeDefinitionsData?.definitions ?? [] const nodeDefinitionsByStep = Object.fromEntries(nodeDefinitions.map(definition => [definition.step, definition])) const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config, nodeDefinitionsByStep) const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [selectedNodeId, setSelectedNodeId] = useState(null) const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState(null) useEffect(() => { const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep) setNodes(graph.nodes) setEdges(graph.edges) setSelectedNodeId(null) }, [nodeDefinitionsData, setEdges, setNodes, workflow.config]) const onConnect = useCallback( (connection: Connection) => setEdges(eds => addEdge(connection, eds)), [setEdges], ) const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { setSelectedNodeId(node.id) }, []) const onPaneClick = useCallback(() => { setSelectedNodeId(null) }, []) const handleParamsChange = useCallback( (newParams: WorkflowParams) => { setNodes(nds => nds.map(n => { if (n.id === selectedNodeId) { return { ...n, data: { ...n.data, params: normalizeWorkflowParams(newParams) } } } return n }), ) }, [selectedNodeId, setNodes], ) const handlePipelineStepChange = useCallback( (stepName: string) => { const definition = nodeDefinitionsByStep[stepName] setNodes(nds => nds.map(n => { if (n.id === selectedNodeId) { const currentData = (n.data as WorkflowCanvasNodeData | undefined) ?? buildNodeData(stepName) return { ...n, type: definition?.node_type ?? inferNodeType(stepName), data: { ...buildNodeData( stepName || inferStepFromNodeType(n.type), { ...(definition?.defaults ?? {}), ...currentData.params, }, definition, ), step: stepName || inferStepFromNodeType(n.type), }, } } return n }), ) }, [nodeDefinitionsByStep, selectedNodeId, setNodes], ) // Drag-drop new nodes from palette const onDragOver = useCallback((event: DragEvent) => { event.preventDefault() event.dataTransfer.dropEffect = 'move' }, []) const onDrop = useCallback( (event: DragEvent) => { event.preventDefault() const step = event.dataTransfer.getData('application/workflow-step') if (!step || !reactFlowInstance) return const definition = nodeDefinitionsByStep[step] const type = definition?.node_type ?? inferNodeType(step) const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY, }) const newNode: Node = { id: `${step}_${Date.now()}`, type, position, data: buildNodeData(step, definition?.defaults ?? {}, definition), } setNodes(nds => [...nds, newNode]) }, [nodeDefinitionsByStep, reactFlowInstance, setNodes], ) const handleSave = () => { const updatedConfig: WorkflowConfig = { 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) } const selectedNode = nodes.find(n => n.id === selectedNodeId) const { mode } = useThemeStore() const isDark = resolveTheme(mode) === 'dark' return (
{/* Canvas Toolbar */}
Nodes {nodeDefinitions.map(definition => (
{ e.dataTransfer.setData('application/workflow-step', definition.step) e.dataTransfer.effectAllowed = 'move' }} className="flex items-center gap-1.5 px-2.5 py-1.5 rounded border border-border-default bg-surface-hover text-xs text-content-secondary cursor-grab hover:bg-surface-muted select-none whitespace-nowrap" title={definition.description} > {renderWorkflowIcon(definition.icon)} {definition.label}
))}
{/* Canvas + Sidepanel */}
{selectedNode && ( )} {!selectedNode && nodeDefinitions.length > 0 && (
)}
) } // ─── Main Page ──────────────────────────────────────────────────────────────── export default function WorkflowEditor() { const queryClient = useQueryClient() const [selectedId, setSelectedId] = useState(null) const [showNewModal, setShowNewModal] = useState(false) const { data: workflows = [], isLoading } = useQuery({ queryKey: ['workflows'], queryFn: getWorkflows, }) const createMutation = useMutation({ mutationFn: createWorkflow, onSuccess: wf => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) setSelectedId(wf.id) setShowNewModal(false) toast.success('Workflow created') }, onError: () => toast.error('Failed to create workflow'), }) const updateMutation = useMutation({ mutationFn: ({ id, config }: { id: string; config: WorkflowConfig }) => updateWorkflow(id, { config }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) toast.success('Workflow saved') }, onError: () => toast.error('Failed to save workflow'), }) const deleteMutation = useMutation({ mutationFn: deleteWorkflow, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) setSelectedId(null) toast.success('Workflow deleted') }, onError: () => toast.error('Failed to delete workflow'), }) const handleCreate = (name: string, type: WorkflowPresetType) => { const defaultParams: WorkflowParams = type === 'turntable' ? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 } : type === 'multi_angle' ? { render_engine: 'cycles', samples: 128, resolution: [2048, 2048], angles: [0, 45, 90] } : { render_engine: 'cycles', samples: 256, resolution: [2048, 2048] } createMutation.mutate({ name, config: createPresetWorkflowConfig(type, defaultParams), is_active: true, }) } const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null const typeLabel: Record = { still: 'Still', turntable: 'Turntable', multi_angle: 'Multi-Angle', still_with_exports: 'Still + GLB', custom: 'Custom', } const typeBadgeColor: Record = { 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', still_with_exports: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', custom: 'bg-surface-hover text-content-muted', } return (
{/* Workflow List Sidebar */}
{/* Main Canvas Area */}
{/* Header */}

Workflow Editor

{selectedWorkflow && (

{selectedWorkflow.name}

)}
{/* Canvas or Empty State */} {selectedWorkflow ? ( updateMutation.mutate({ id: selectedWorkflow.id, config })} isSaving={updateMutation.isPending} /> ) : (
{workflows.length === 0 ? ( <>

No workflows configured.

Workflows define the sequence of pipeline steps for rendering orders. Click "New Workflow" to create one.

) : ( <>

No workflow selected

Select a workflow from the list or create a new one.

)}
)}
{/* New Workflow Modal */} {showNewModal && ( setShowNewModal(false)} onCreate={handleCreate} isLoading={createMutation.isPending} /> )} ) }