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 */}
{/* 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}
/>
)}
)
}