|
|
|
@@ -0,0 +1,789 @@
|
|
|
|
|
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 { 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 (
|
|
|
|
|
<div
|
|
|
|
|
className={`rounded-lg border-2 p-3 min-w-[140px] bg-white shadow-sm transition-colors ${
|
|
|
|
|
selected ? 'border-blue-500' : 'border-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{hasTarget && (
|
|
|
|
|
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-gray-400 border-2 border-white" />
|
|
|
|
|
)}
|
|
|
|
|
<div className={`flex items-center gap-2 mb-1 text-${color}-600`}>
|
|
|
|
|
{icon}
|
|
|
|
|
<span className="font-medium text-sm">{label}</span>
|
|
|
|
|
</div>
|
|
|
|
|
{description && <p className="text-xs text-gray-500">{description}</p>}
|
|
|
|
|
{hasSource && (
|
|
|
|
|
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-gray-400 border-2 border-white" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function InputNode({ selected }: { selected?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label="STEP Input"
|
|
|
|
|
icon={<FileUp size={14} />}
|
|
|
|
|
color="green"
|
|
|
|
|
description="STEP-Datei Eingang"
|
|
|
|
|
selected={selected}
|
|
|
|
|
hasTarget={false}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ConvertNode({ selected }: { selected?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label="STL Konvertierung"
|
|
|
|
|
icon={<RefreshCw size={14} />}
|
|
|
|
|
color="blue"
|
|
|
|
|
description="STEP → STL (cadquery)"
|
|
|
|
|
selected={selected}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) {
|
|
|
|
|
const params = data.params ?? {}
|
|
|
|
|
return (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label={data.label ?? 'Still Render'}
|
|
|
|
|
icon={<Camera size={14} />}
|
|
|
|
|
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 (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label="Frames Render"
|
|
|
|
|
icon={<Film size={14} />}
|
|
|
|
|
color="orange"
|
|
|
|
|
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : undefined}
|
|
|
|
|
selected={selected}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FFmpegNode({ selected }: { selected?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label="FFmpeg Composite"
|
|
|
|
|
icon={<Layers size={14} />}
|
|
|
|
|
color="purple"
|
|
|
|
|
description="Frames → MP4"
|
|
|
|
|
selected={selected}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function OutputNode({ data, selected }: { data: { label?: string }; selected?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<BaseNode
|
|
|
|
|
label={data.label ?? 'Output'}
|
|
|
|
|
icon={<Download size={14} />}
|
|
|
|
|
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 (
|
|
|
|
|
<div className="w-72 border-l border-gray-200 bg-white p-4 space-y-5 overflow-y-auto">
|
|
|
|
|
<h3 className="font-semibold text-gray-800">Node-Konfiguration</h3>
|
|
|
|
|
|
|
|
|
|
{/* Render Engine */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="text-sm text-gray-600 mb-2 block">Render Engine</label>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{(['cycles', 'eevee'] as const).map(eng => (
|
|
|
|
|
<button
|
|
|
|
|
key={eng}
|
|
|
|
|
onClick={() => onChange({ ...params, render_engine: eng })}
|
|
|
|
|
className={`px-3 py-1.5 rounded text-sm font-medium transition-colors ${
|
|
|
|
|
(params.render_engine ?? 'cycles') === eng
|
|
|
|
|
? 'bg-blue-600 text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{eng === 'cycles' ? 'Cycles' : 'EEVEE'}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Samples */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="text-sm text-gray-600 mb-2 block">
|
|
|
|
|
Samples: <span className="font-semibold text-gray-800">{params.samples ?? 256}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={1}
|
|
|
|
|
max={4096}
|
|
|
|
|
step={1}
|
|
|
|
|
value={params.samples ?? 256}
|
|
|
|
|
onChange={e => onChange({ ...params, samples: Number(e.target.value) })}
|
|
|
|
|
className="w-full accent-blue-600"
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex justify-between text-xs text-gray-400 mt-1">
|
|
|
|
|
<span>1</span>
|
|
|
|
|
<span>4096</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Resolution */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="text-sm text-gray-600 mb-2 block">Auflösung</label>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w, h]) => (
|
|
|
|
|
<button
|
|
|
|
|
key={w}
|
|
|
|
|
onClick={() => onChange({ ...params, resolution: [w, h] })}
|
|
|
|
|
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
|
|
|
(params.resolution?.[0] ?? 2048) === w
|
|
|
|
|
? 'bg-blue-600 text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{w}px
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* FPS (only relevant for animation nodes) */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="text-sm text-gray-600 mb-2 block">
|
|
|
|
|
FPS: <span className="font-semibold text-gray-800">{params.fps ?? 24}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
{[12, 24, 30, 60].map(fps => (
|
|
|
|
|
<button
|
|
|
|
|
key={fps}
|
|
|
|
|
onClick={() => onChange({ ...params, fps })}
|
|
|
|
|
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
|
|
|
|
(params.fps ?? 24) === fps
|
|
|
|
|
? 'bg-blue-600 text-white'
|
|
|
|
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{fps}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Duration */}
|
|
|
|
|
<div>
|
|
|
|
|
<label className="text-sm text-gray-600 mb-2 block">
|
|
|
|
|
Dauer (s): <span className="font-semibold text-gray-800">{params.duration_s ?? 5}</span>
|
|
|
|
|
</label>
|
|
|
|
|
<input
|
|
|
|
|
type="range"
|
|
|
|
|
min={1}
|
|
|
|
|
max={30}
|
|
|
|
|
step={1}
|
|
|
|
|
value={params.duration_s ?? 5}
|
|
|
|
|
onChange={e => onChange({ ...params, duration_s: Number(e.target.value) })}
|
|
|
|
|
className="w-full accent-blue-600"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Node Palette ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
const NODE_PALETTE = [
|
|
|
|
|
{ type: 'convertNode', label: 'STEP→STL', icon: <RefreshCw size={14} /> },
|
|
|
|
|
{ type: 'renderNode', label: 'Still Render', icon: <Camera size={14} /> },
|
|
|
|
|
{ type: 'renderFramesNode', label: 'Frame Render', icon: <Film size={14} /> },
|
|
|
|
|
{ type: 'ffmpegNode', label: 'FFmpeg', icon: <Layers size={14} /> },
|
|
|
|
|
{ type: 'outputNode', label: 'Output', icon: <Download size={14} /> },
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
// ─── 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<WorkflowConfig['type']>('still')
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
|
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
|
|
|
|
|
<div className="flex items-center justify-between mb-4">
|
|
|
|
|
<h2 className="text-lg font-semibold">Neuer Workflow</h2>
|
|
|
|
|
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
|
|
|
|
<X size={20} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Name</label>
|
|
|
|
|
<input
|
|
|
|
|
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
|
|
|
placeholder="z.B. Still Render Standard"
|
|
|
|
|
value={name}
|
|
|
|
|
onChange={e => setName(e.target.value)}
|
|
|
|
|
autoFocus
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label className="block text-sm text-gray-600 mb-1">Typ</label>
|
|
|
|
|
<div className="grid grid-cols-2 gap-2">
|
|
|
|
|
{([
|
|
|
|
|
{ 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 => (
|
|
|
|
|
<button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
onClick={() => setType(opt.value)}
|
|
|
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
|
|
|
type === opt.value
|
|
|
|
|
? 'border-blue-500 bg-blue-50'
|
|
|
|
|
: 'border-gray-200 hover:border-gray-300'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<p className="text-sm font-medium">{opt.label}</p>
|
|
|
|
|
<p className="text-xs text-gray-500 mt-0.5">{opt.desc}</p>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex justify-end gap-2 mt-6">
|
|
|
|
|
<button
|
|
|
|
|
onClick={onClose}
|
|
|
|
|
className="px-4 py-2 text-sm rounded-lg border border-gray-300 hover:bg-gray-50"
|
|
|
|
|
>
|
|
|
|
|
Abbrechen
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
disabled={!name.trim() || isLoading}
|
|
|
|
|
onClick={() => onCreate(name.trim(), type)}
|
|
|
|
|
className="px-4 py-2 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
|
|
|
>
|
|
|
|
|
{isLoading ? 'Erstelle…' : 'Erstellen'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── 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<string | null>(null)
|
|
|
|
|
const [params, setParams] = useState<WorkflowParams>(workflow.config.params)
|
|
|
|
|
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
|
|
|
|
const [reactFlowInstance, setReactFlowInstance] = useState<any>(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<HTMLDivElement>) => {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.dataTransfer.dropEffect = 'move'
|
|
|
|
|
}, [])
|
|
|
|
|
|
|
|
|
|
const onDrop = useCallback(
|
|
|
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col flex-1 min-h-0">
|
|
|
|
|
{/* Canvas Toolbar */}
|
|
|
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-gray-200 bg-white">
|
|
|
|
|
<span className="text-sm font-medium text-gray-600 mr-2">Nodes:</span>
|
|
|
|
|
{NODE_PALETTE.map(item => (
|
|
|
|
|
<div
|
|
|
|
|
key={item.type}
|
|
|
|
|
draggable
|
|
|
|
|
onDragStart={e => {
|
|
|
|
|
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-gray-200 bg-gray-50 text-xs text-gray-700 cursor-grab hover:bg-gray-100 select-none"
|
|
|
|
|
>
|
|
|
|
|
{item.icon}
|
|
|
|
|
{item.label}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
<div className="ml-auto">
|
|
|
|
|
<button
|
|
|
|
|
onClick={handleSave}
|
|
|
|
|
disabled={isSaving}
|
|
|
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
|
|
|
|
|
>
|
|
|
|
|
<Save size={14} />
|
|
|
|
|
{isSaving ? 'Speichere…' : 'Speichern'}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Canvas + Sidepanel */}
|
|
|
|
|
<div className="flex flex-1 min-h-0">
|
|
|
|
|
<div ref={reactFlowWrapper} className="flex-1" onDrop={onDrop} onDragOver={onDragOver}>
|
|
|
|
|
<ReactFlow
|
|
|
|
|
nodes={nodes}
|
|
|
|
|
edges={edges}
|
|
|
|
|
onNodesChange={onNodesChange}
|
|
|
|
|
onEdgesChange={onEdgesChange}
|
|
|
|
|
onConnect={onConnect}
|
|
|
|
|
onNodeClick={onNodeClick}
|
|
|
|
|
onPaneClick={onPaneClick}
|
|
|
|
|
onInit={setReactFlowInstance}
|
|
|
|
|
nodeTypes={nodeTypes}
|
|
|
|
|
fitView
|
|
|
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
|
|
|
>
|
|
|
|
|
<Background gap={16} color="#e5e7eb" />
|
|
|
|
|
<Controls />
|
|
|
|
|
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
|
|
|
|
</ReactFlow>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{selectedNode && (selectedNode.type === 'renderNode' || selectedNode.type === 'renderFramesNode') && (
|
|
|
|
|
<ConfigSidepanel params={params} onChange={handleParamsChange} />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Main Page ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
export default function WorkflowEditor() {
|
|
|
|
|
const queryClient = useQueryClient()
|
|
|
|
|
const [selectedId, setSelectedId] = useState<string | null>(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<WorkflowConfig['type'], string> = {
|
|
|
|
|
still: 'Still',
|
|
|
|
|
turntable: 'Turntable',
|
|
|
|
|
multi_angle: 'Multi-Angle',
|
|
|
|
|
custom: 'Custom',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const typeBadgeColor: Record<WorkflowConfig['type'], string> = {
|
|
|
|
|
still: 'bg-orange-100 text-orange-700',
|
|
|
|
|
turntable: 'bg-purple-100 text-purple-700',
|
|
|
|
|
multi_angle: 'bg-blue-100 text-blue-700',
|
|
|
|
|
custom: 'bg-gray-100 text-gray-600',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex h-full">
|
|
|
|
|
{/* Workflow List Sidebar */}
|
|
|
|
|
<aside className="w-56 flex-shrink-0 border-r border-gray-200 bg-white flex flex-col">
|
|
|
|
|
<div className="p-3 border-b border-gray-200 flex items-center justify-between">
|
|
|
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-gray-700">
|
|
|
|
|
<GitBranch size={16} />
|
|
|
|
|
Workflows
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowNewModal(true)}
|
|
|
|
|
className="p-1 rounded hover:bg-gray-100 text-gray-500 hover:text-gray-700"
|
|
|
|
|
title="Neuer Workflow"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={16} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
|
|
|
|
{isLoading && (
|
|
|
|
|
<p className="text-xs text-gray-400 px-2 py-4 text-center">Lade…</p>
|
|
|
|
|
)}
|
|
|
|
|
{!isLoading && workflows.length === 0 && (
|
|
|
|
|
<p className="text-xs text-gray-400 px-2 py-4 text-center">
|
|
|
|
|
Noch keine Workflows.
|
|
|
|
|
<br />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowNewModal(true)}
|
|
|
|
|
className="mt-1 text-blue-500 hover:underline"
|
|
|
|
|
>
|
|
|
|
|
+ Neu erstellen
|
|
|
|
|
</button>
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
{workflows.map(wf => (
|
|
|
|
|
<button
|
|
|
|
|
key={wf.id}
|
|
|
|
|
onClick={() => setSelectedId(wf.id)}
|
|
|
|
|
className={`w-full text-left px-3 py-2.5 rounded-lg transition-colors group ${
|
|
|
|
|
selectedId === wf.id
|
|
|
|
|
? 'bg-blue-50 border border-blue-200'
|
|
|
|
|
: 'hover:bg-gray-50 border border-transparent'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-start justify-between gap-1">
|
|
|
|
|
<p className="text-sm font-medium text-gray-800 truncate">{wf.name}</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={e => {
|
|
|
|
|
e.stopPropagation()
|
|
|
|
|
if (confirm(`Workflow "${wf.name}" löschen?`)) {
|
|
|
|
|
deleteMutation.mutate(wf.id)
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 hover:text-red-600 text-gray-400 flex-shrink-0"
|
|
|
|
|
title="Löschen"
|
|
|
|
|
>
|
|
|
|
|
<Trash2 size={12} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
<span
|
|
|
|
|
className={`inline-block mt-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
|
|
|
typeBadgeColor[wf.config.type]
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{typeLabel[wf.config.type]}
|
|
|
|
|
</span>
|
|
|
|
|
{!wf.is_active && (
|
|
|
|
|
<span className="ml-1 text-xs text-gray-400">(inaktiv)</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</aside>
|
|
|
|
|
|
|
|
|
|
{/* Main Canvas Area */}
|
|
|
|
|
<div className="flex-1 flex flex-col min-w-0">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<div className="px-6 py-4 border-b border-gray-200 bg-white flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 className="text-xl font-semibold text-gray-900">Workflow-Editor</h1>
|
|
|
|
|
{selectedWorkflow && (
|
|
|
|
|
<p className="text-sm text-gray-500 mt-0.5">{selectedWorkflow.name}</p>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowNewModal(true)}
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={16} />
|
|
|
|
|
Neuer Workflow
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Canvas or Empty State */}
|
|
|
|
|
{selectedWorkflow ? (
|
|
|
|
|
<FlowCanvas
|
|
|
|
|
key={selectedWorkflow.id}
|
|
|
|
|
workflow={selectedWorkflow}
|
|
|
|
|
onSave={config => updateMutation.mutate({ id: selectedWorkflow.id, config })}
|
|
|
|
|
isSaving={updateMutation.isPending}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex-1 flex items-center justify-center text-center">
|
|
|
|
|
<div>
|
|
|
|
|
<GitBranch size={48} className="mx-auto text-gray-300 mb-4" />
|
|
|
|
|
<p className="text-gray-500 font-medium">Kein Workflow ausgewählt</p>
|
|
|
|
|
<p className="text-sm text-gray-400 mt-1">
|
|
|
|
|
Wähle einen Workflow aus der Liste oder erstelle einen neuen.
|
|
|
|
|
</p>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowNewModal(true)}
|
|
|
|
|
className="mt-4 flex items-center gap-2 px-4 py-2 mx-auto rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={16} />
|
|
|
|
|
Workflow erstellen
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* New Workflow Modal */}
|
|
|
|
|
{showNewModal && (
|
|
|
|
|
<NewWorkflowModal
|
|
|
|
|
onClose={() => setShowNewModal(false)}
|
|
|
|
|
onCreate={handleCreate}
|
|
|
|
|
isLoading={createMutation.isPending}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|