import { useState, useCallback, useRef, 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, type WorkflowDefinition, type WorkflowConfig, type WorkflowParams, } from '../api/workflows' import { FileUp, RefreshCw, Camera, Film, Layers, Download, Plus, Save, Trash2, GitBranch, X, } from 'lucide-react' import { toast } from 'sonner' // ─── Custom Node Components ────────────────────────────────────────────────── interface BaseNodeProps { label: string icon: React.ReactNode color: string description?: string selected?: boolean hasSource?: boolean hasTarget?: boolean } function BaseNode({ label, icon, color, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) { return (
{hasTarget && ( )}
{icon} {label}
{description &&

{description}

} {hasSource && ( )}
) } function InputNode({ selected }: { selected?: boolean }) { return ( } color="green" description="STEP-Datei Eingang" selected={selected} hasTarget={false} /> ) } function ConvertNode({ selected }: { selected?: boolean }) { return ( } color="blue" description="STEP → STL (cadquery)" selected={selected} /> ) } function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) { const params = data.params ?? {} return ( } color="orange" description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined} selected={selected} /> ) } function RenderFramesNode({ data, selected }: { data: { params?: WorkflowParams }; selected?: boolean }) { const params = data.params ?? {} return ( } color="orange" description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined} selected={selected} /> ) } function FFmpegNode({ selected }: { selected?: boolean }) { return ( } color="purple" description="Frames → MP4" selected={selected} /> ) } function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) { return ( } color="gray" description="Ergebnis-Datei" selected={selected} hasSource={false} /> ) } const nodeTypes: NodeTypes = { inputNode: InputNode as any, convertNode: ConvertNode as any, renderNode: RenderNode as any, renderFramesNode: RenderFramesNode as any, ffmpegNode: FFmpegNode as any, outputNode: OutputNode as any, } // ─── Workflow → Graph conversion ───────────────────────────────────────────── 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 Konvertierung' } }, { 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 } } 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 ───────────────────────────────────────────────────────── function ConfigSidepanel({ params, onChange, }: { params: WorkflowParams onChange: (p: WorkflowParams) => void }) { return (

Node-Konfiguration

{/* Render Engine */}
{(['cycles', 'eevee'] as const).map(eng => ( ))}
{/* Samples */}
onChange({ ...params, samples: Number(e.target.value) })} className="w-full accent-accent" />
1 4096
{/* Resolution */}
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => ( ))}
{/* FPS (only relevant for animation nodes) */}
{[12, 24, 30, 60].map(fps => ( ))}
{/* Duration */}
onChange({ ...params, duration_s: Number(e.target.value) })} className="w-full accent-accent" />
) } // ─── Node Palette ────────────────────────────────────────────────────────────── const NODE_PALETTE = [ { type: 'convertNode', label: 'STEP→STL', icon: }, { type: 'renderNode', label: 'Still Render', icon: }, { type: 'renderFramesNode', label: 'Frame Render', icon: }, { type: 'ffmpegNode', label: 'FFmpeg', icon: }, { type: 'outputNode', label: 'Output', icon: }, ] // ─── New Workflow Modal ─────────────────────────────────────────────────────── interface NewWorkflowModalProps { onClose: () => void onCreate: (name: string, type: WorkflowConfig['type']) => void isLoading: boolean } function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) { const [name, setName] = useState('') const [type, setType] = useState('still') return (

Neuer Workflow

setName(e.target.value)} autoFocus />
{([ { value: 'still', label: 'Still', desc: 'Einzelbild PNG' }, { value: 'turntable', label: 'Turntable', desc: 'Animations-MP4' }, { value: 'multi_angle', label: 'Multi-Angle', desc: 'Mehrere Winkel' }, { value: 'custom', label: 'Custom', desc: 'Freier Editor' }, ] as { value: WorkflowConfig['type']; 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 { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config) const [nodes, setNodes, onNodesChange] = useNodesState(initNodes) const [edges, setEdges, onEdgesChange] = useEdgesState(initEdges) const [selectedNodeId, setSelectedNodeId] = useState(null) const [params, setParams] = useState(workflow.config.params) const reactFlowWrapper = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useState(null) const onConnect = useCallback( (connection: Connection) => setEdges(eds => addEdge(connection, eds)), [setEdges], ) const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { if (node.type === 'renderNode' || node.type === 'renderFramesNode') { setSelectedNodeId(node.id) const nodeParams = (node.data as any).params as WorkflowParams | undefined if (nodeParams) setParams(nodeParams) } else { setSelectedNodeId(null) } }, []) const onPaneClick = useCallback(() => { setSelectedNodeId(null) }, []) 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 }), ) }, [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 type = event.dataTransfer.getData('application/reactflow') if (!type || !reactFlowInstance) return const position = reactFlowInstance.screenToFlowPosition({ x: event.clientX, y: event.clientY, }) const newNode: Node = { id: `${type}_${Date.now()}`, type, position, data: { label: type }, } setNodes(nds => [...nds, newNode]) }, [reactFlowInstance, setNodes], ) const handleSave = () => { const updatedConfig: WorkflowConfig = { ...workflow.config, params, nodes: nodes as any, } onSave(updatedConfig) } const selectedNode = nodes.find(n => n.id === selectedNodeId) const { mode } = useThemeStore() const isDark = resolveTheme(mode) === 'dark' return (
{/* Canvas Toolbar */}
Nodes: {NODE_PALETTE.map(item => (
{ e.dataTransfer.setData('application/reactflow', item.type) 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" > {item.icon} {item.label}
))}
{/* Canvas + Sidepanel */}
{selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && ( )}
) } // ─── 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 erstellt') }, onError: () => toast.error('Fehler beim Erstellen'), }) const updateMutation = useMutation({ mutationFn: ({ id, config }: { id: string; config: WorkflowConfig }) => updateWorkflow(id, { config }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) toast.success('Workflow gespeichert') }, onError: () => toast.error('Fehler beim Speichern'), }) const deleteMutation = useMutation({ mutationFn: deleteWorkflow, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['workflows'] }) setSelectedId(null) toast.success('Workflow gelöscht') }, onError: () => toast.error('Fehler beim Löschen'), }) const handleCreate = (name: string, type: WorkflowConfig['type']) => { 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: { type, params: defaultParams }, is_active: true, }) } const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null const typeLabel: Record = { still: 'Still', turntable: 'Turntable', multi_angle: 'Multi-Angle', 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', 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} /> ) : (

Kein Workflow ausgewählt

Wähle einen Workflow aus der Liste oder erstelle einen neuen.

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