1103 lines
40 KiB
TypeScript
1103 lines
40 KiB
TypeScript
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 WorkflowExecutionMode,
|
|
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 <FileUp size={size} />
|
|
case 'film':
|
|
return <Film size={size} />
|
|
case 'layers':
|
|
return <Layers size={size} />
|
|
case 'download':
|
|
return <Download size={size} />
|
|
case 'bell':
|
|
return <Bell size={size} />
|
|
case 'camera':
|
|
return <Camera size={size} />
|
|
case 'refresh-cw':
|
|
default:
|
|
return <RefreshCw size={size} />
|
|
}
|
|
}
|
|
|
|
function buildNodeData(
|
|
step: string,
|
|
params: WorkflowParams = {},
|
|
definition?: WorkflowNodeDefinition,
|
|
overrides?: Partial<WorkflowCanvasNodeData>,
|
|
): 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 (
|
|
<div
|
|
className={`rounded-lg border-2 p-3 min-w-[140px] bg-surface shadow-sm transition-colors ${
|
|
selected ? 'border-accent' : 'border-border-default'
|
|
}`}
|
|
>
|
|
{hasTarget && (
|
|
<Handle type="target" position={Position.Left} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
|
)}
|
|
<div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
|
|
{icon}
|
|
<span className="font-medium text-sm">{label}</span>
|
|
</div>
|
|
{description && <p className="text-xs text-content-muted">{description}</p>}
|
|
{hasSource && (
|
|
<Handle type="source" position={Position.Right} className="w-3 h-3 bg-content-muted border-2 border-surface" />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-green-600"
|
|
description={data.description}
|
|
selected={selected}
|
|
hasTarget={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-blue-600"
|
|
description={data.description}
|
|
selected={selected}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function ProcessNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-sky-600"
|
|
description={data.description}
|
|
selected={selected}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RenderNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
const params = data.params ?? {}
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-orange-600"
|
|
description={
|
|
params.render_engine
|
|
? `${params.render_engine} · ${params.samples ?? 256} samples`
|
|
: data.description
|
|
}
|
|
selected={selected}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
const params = data.params ?? {}
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-orange-600"
|
|
description={params.fps ? `${params.fps} fps · ${params.duration_s ?? '?'}s` : data.description}
|
|
selected={selected}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
|
return (
|
|
<BaseNode
|
|
label={data.label}
|
|
icon={renderWorkflowIcon(data.icon)}
|
|
accentClass="text-slate-600"
|
|
description={data.description}
|
|
selected={selected}
|
|
hasSource={false}
|
|
/>
|
|
)
|
|
}
|
|
|
|
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'
|
|
}
|
|
|
|
const EXECUTION_MODE_LABELS: Record<WorkflowExecutionMode, string> = {
|
|
legacy: 'Legacy',
|
|
graph: 'Graph',
|
|
shadow: 'Shadow',
|
|
}
|
|
|
|
const EXECUTION_MODE_BADGE_STYLES: Record<WorkflowExecutionMode, string> = {
|
|
legacy: 'bg-slate-100 text-slate-700 dark:bg-slate-900/40 dark:text-slate-300',
|
|
graph: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
|
shadow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
|
}
|
|
|
|
const EXECUTION_MODE_HINTS: Record<WorkflowExecutionMode, string> = {
|
|
legacy: 'Preset dispatcher remains authoritative for production runs.',
|
|
graph: 'Production dispatch uses graph runtime with hard fallback to legacy on failure.',
|
|
shadow: 'Currently stored and exposed, but production dispatch still falls back to legacy until shadow parity lands.',
|
|
}
|
|
|
|
function workflowToGraph(
|
|
config: WorkflowConfig,
|
|
nodeDefinitionsByStep: Record<string, WorkflowNodeDefinition>,
|
|
): { 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<Record<string, WorkflowNodeFieldDefinition[]>>((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<HTMLInputElement>) => {
|
|
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 (
|
|
<div className="w-72 border-l border-border-default bg-surface p-4 space-y-5 overflow-y-auto">
|
|
<h3 className="font-semibold text-content">Node Configuration</h3>
|
|
|
|
{nodeDefinitions.length > 0 && onStepChange && (
|
|
<div>
|
|
<label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
|
|
<select
|
|
value={step ?? ''}
|
|
onChange={event => onStepChange(event.target.value)}
|
|
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"
|
|
>
|
|
{nodeDefinitions.map(definition => (
|
|
<option key={definition.step} value={definition.step}>
|
|
{definition.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
{nodeDefinition && (
|
|
<div className="mt-2 space-y-1">
|
|
<p className="text-xs text-content-muted">{nodeDefinition.description}</p>
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium ${
|
|
nodeDefinition.execution_kind === 'bridge'
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
}`}
|
|
>
|
|
{nodeDefinition.execution_kind === 'bridge' ? 'Legacy Bridge' : 'Native Node'}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{Object.keys(fieldsBySection).length === 0 && (
|
|
<p className="text-sm text-content-muted">
|
|
This node currently has no configurable settings in the editor.
|
|
</p>
|
|
)}
|
|
|
|
{Object.entries(fieldsBySection).map(([section, fields]) => (
|
|
<div key={section} className="space-y-3">
|
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-content-secondary">
|
|
{section}
|
|
</h4>
|
|
{fields.map(field => {
|
|
const rawValue = params[field.key]
|
|
const value = rawValue ?? field.default
|
|
|
|
return (
|
|
<div key={field.key}>
|
|
<label className="text-sm text-content-secondary mb-1 block">
|
|
{field.label}
|
|
{field.unit ? ` (${field.unit})` : ''}
|
|
</label>
|
|
{field.type === 'select' && (
|
|
<select
|
|
value={String(value ?? '')}
|
|
onChange={event => updateField(field, event.target.value)}
|
|
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.options.map(option => (
|
|
<option key={String(option.value)} value={String(option.value)}>
|
|
{option.label}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
{field.type === 'number' && (
|
|
<input
|
|
type="number"
|
|
min={field.min ?? undefined}
|
|
max={field.max ?? undefined}
|
|
step={field.step ?? undefined}
|
|
value={typeof value === 'number' ? value : value == null ? '' : Number(value)}
|
|
onChange={event => 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' && (
|
|
<label className="flex items-center gap-2 rounded-lg border border-border-default px-3 py-2 text-sm text-content">
|
|
<input
|
|
type="checkbox"
|
|
checked={Boolean(value)}
|
|
onChange={event => updateField(field, event.target.checked)}
|
|
className="accent-accent"
|
|
/>
|
|
<span>{Boolean(value) ? 'Enabled' : 'Disabled'}</span>
|
|
</label>
|
|
)}
|
|
{field.description && (
|
|
<p className="mt-1 text-xs text-content-muted">{field.description}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── Node Definitions Panel ───────────────────────────────────────────────────
|
|
|
|
const CATEGORY_LABELS: Record<StepCategory, string> = {
|
|
input: 'Input',
|
|
processing: 'Processing',
|
|
rendering: 'Rendering',
|
|
output: 'Output',
|
|
}
|
|
|
|
const CATEGORY_COLORS: Record<StepCategory, string> = {
|
|
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<StepCategory | null>(null)
|
|
|
|
const grouped = definitions.reduce<Record<StepCategory, WorkflowNodeDefinition[]>>(
|
|
(acc, definition) => {
|
|
acc[definition.category] = [...(acc[definition.category] ?? []), definition]
|
|
return acc
|
|
},
|
|
{ input: [], processing: [], rendering: [], output: [] },
|
|
)
|
|
|
|
const categories: StepCategory[] = ['input', 'processing', 'rendering', 'output']
|
|
|
|
return (
|
|
<div className="border-t border-border-default pt-3 mt-3">
|
|
<p className="text-xs font-semibold text-content-secondary uppercase tracking-wide mb-2">
|
|
Available Nodes
|
|
</p>
|
|
<div className="space-y-1">
|
|
{categories.map(cat => (
|
|
<div key={cat}>
|
|
<button
|
|
onClick={() => setExpanded(expanded === cat ? null : cat)}
|
|
className="w-full flex items-center justify-between text-xs text-content-secondary hover:text-content py-1"
|
|
>
|
|
<span className={`px-1.5 py-0.5 rounded-full font-medium ${CATEGORY_COLORS[cat]}`}>
|
|
{CATEGORY_LABELS[cat]}
|
|
</span>
|
|
<span className="text-content-muted">{grouped[cat].length}</span>
|
|
</button>
|
|
{expanded === cat && (
|
|
<div className="ml-2 mt-1 space-y-1">
|
|
{grouped[cat].map(definition => (
|
|
<div
|
|
key={definition.step}
|
|
className="text-xs bg-surface-hover rounded px-2 py-1.5"
|
|
title={definition.description}
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<p className="font-medium text-content-secondary truncate">{definition.label}</p>
|
|
<span
|
|
className={`rounded-full px-1.5 py-0.5 text-[10px] font-medium ${
|
|
definition.execution_kind === 'bridge'
|
|
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300'
|
|
: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300'
|
|
}`}
|
|
>
|
|
{definition.execution_kind === 'bridge' ? 'Bridge' : 'Native'}
|
|
</span>
|
|
</div>
|
|
<p className="font-mono text-content-muted truncate mt-0.5">{definition.step}</p>
|
|
<p className="text-content-muted mt-0.5 line-clamp-2">{definition.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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<WorkflowPresetType>('still')
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
|
<div className="bg-surface 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 text-content">New Workflow</h2>
|
|
<button onClick={onClose} className="text-content-muted hover:text-content">
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm text-content-secondary mb-1">Name</label>
|
|
<input
|
|
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"
|
|
placeholder="e.g. Still Render Standard"
|
|
value={name}
|
|
onChange={e => setName(e.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm text-content-secondary mb-1">Type</label>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{([
|
|
{ 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 => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setType(opt.value)}
|
|
className={`p-3 rounded-lg border-2 text-left transition-colors ${
|
|
type === opt.value
|
|
? 'border-accent bg-accent-light'
|
|
: 'border-border-default hover:border-border-light'
|
|
}`}
|
|
>
|
|
<p className="text-sm font-medium text-content">{opt.label}</p>
|
|
<p className="text-xs text-content-muted 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-border-default text-content-secondary hover:bg-surface-hover"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
disabled={!name.trim() || isLoading}
|
|
onClick={() => onCreate(name.trim(), type)}
|
|
className="px-4 py-2 text-sm rounded-lg bg-accent text-white hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
{isLoading ? 'Creating…' : 'Create'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ─── 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<string | null>(null)
|
|
const [executionMode, setExecutionMode] = useState<WorkflowExecutionMode>(workflow.config.ui?.execution_mode ?? 'legacy')
|
|
const reactFlowWrapper = useRef<HTMLDivElement>(null)
|
|
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
|
|
|
useEffect(() => {
|
|
const graph = workflowToGraph(workflow.config, nodeDefinitionsByStep)
|
|
setNodes(graph.nodes)
|
|
setEdges(graph.edges)
|
|
setSelectedNodeId(null)
|
|
setExecutionMode(workflow.config.ui?.execution_mode ?? 'legacy')
|
|
}, [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<HTMLDivElement>) => {
|
|
event.preventDefault()
|
|
event.dataTransfer.dropEffect = 'move'
|
|
}, [])
|
|
|
|
const onDrop = useCallback(
|
|
(event: DragEvent<HTMLDivElement>) => {
|
|
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 ?? {}),
|
|
execution_mode: executionMode,
|
|
},
|
|
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 (
|
|
<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-border-default bg-surface overflow-x-auto">
|
|
<span className="text-sm font-medium text-content-secondary mr-2 whitespace-nowrap">Nodes</span>
|
|
{nodeDefinitions.map(definition => (
|
|
<div
|
|
key={definition.step}
|
|
draggable
|
|
onDragStart={e => {
|
|
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}
|
|
</div>
|
|
))}
|
|
<div className="ml-auto flex items-center gap-3">
|
|
<label className="flex items-center gap-2 text-xs text-content-secondary whitespace-nowrap">
|
|
<span>Execution Mode</span>
|
|
<select
|
|
value={executionMode}
|
|
onChange={event => setExecutionMode(event.target.value as WorkflowExecutionMode)}
|
|
className="border border-border-default rounded-lg px-2.5 py-1.5 text-sm bg-surface text-content focus:outline-none focus:ring-2 focus:ring-accent"
|
|
>
|
|
{(['legacy', 'graph', 'shadow'] as WorkflowExecutionMode[]).map(mode => (
|
|
<option key={mode} value={mode}>
|
|
{EXECUTION_MODE_LABELS[mode]}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<button
|
|
onClick={handleSave}
|
|
disabled={isSaving}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg bg-accent text-white hover:bg-accent-hover disabled:opacity-50"
|
|
>
|
|
<Save size={14} />
|
|
{isSaving ? 'Saving…' : 'Save'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="px-4 py-2 border-b border-border-default bg-surface-hover/40">
|
|
<p className="text-xs text-content-muted">{EXECUTION_MODE_HINTS[executionMode]}</p>
|
|
</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}
|
|
colorMode={isDark ? 'dark' : 'light'}
|
|
fitView
|
|
fitViewOptions={{ padding: 0.2 }}
|
|
>
|
|
<Background gap={16} />
|
|
<Controls />
|
|
<MiniMap nodeStrokeWidth={3} zoomable pannable />
|
|
</ReactFlow>
|
|
</div>
|
|
|
|
{selectedNode && (
|
|
<ConfigSidepanel
|
|
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
|
onChange={handleParamsChange}
|
|
step={(selectedNode.data as any).step as string | undefined}
|
|
onStepChange={handlePipelineStepChange}
|
|
nodeDefinition={nodeDefinitionsByStep[((selectedNode.data as any).step as string | undefined) ?? '']}
|
|
nodeDefinitions={nodeDefinitions}
|
|
/>
|
|
)}
|
|
{!selectedNode && nodeDefinitions.length > 0 && (
|
|
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
|
|
<NodeDefinitionsPanel definitions={nodeDefinitions} />
|
|
</div>
|
|
)}
|
|
</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 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<WorkflowPresetType, string> = {
|
|
still: 'Still',
|
|
turntable: 'Turntable',
|
|
multi_angle: 'Multi-Angle',
|
|
still_with_exports: 'Still + GLB',
|
|
custom: 'Custom',
|
|
}
|
|
|
|
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',
|
|
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 (
|
|
<div className="flex h-full">
|
|
{/* Workflow List Sidebar */}
|
|
<aside className="w-56 flex-shrink-0 border-r border-border-default bg-surface flex flex-col">
|
|
<div className="p-3 border-b border-border-default flex items-center justify-between">
|
|
<div className="flex items-center gap-2 text-sm font-semibold text-content-secondary">
|
|
<GitBranch size={16} />
|
|
Workflows
|
|
</div>
|
|
<button
|
|
onClick={() => setShowNewModal(true)}
|
|
className="p-1 rounded hover:bg-surface-hover text-content-muted hover:text-content"
|
|
title="New Workflow"
|
|
>
|
|
<Plus size={16} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
|
{isLoading && (
|
|
<p className="text-xs text-content-muted px-2 py-4 text-center">Loading…</p>
|
|
)}
|
|
{!isLoading && workflows.length === 0 && (
|
|
<div className="px-2 py-4 text-center">
|
|
<p className="text-xs text-content-secondary font-medium">No workflows configured.</p>
|
|
<p className="text-xs text-content-muted mt-1">
|
|
Workflows define the sequence of pipeline steps for rendering orders.
|
|
</p>
|
|
<button
|
|
onClick={() => setShowNewModal(true)}
|
|
className="mt-2 text-xs text-accent hover:underline"
|
|
>
|
|
+ New Workflow
|
|
</button>
|
|
</div>
|
|
)}
|
|
{workflows.map(wf => {
|
|
const presetType = getWorkflowPresetType(wf.config)
|
|
const executionMode = wf.config.ui?.execution_mode ?? 'legacy'
|
|
return (
|
|
<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-accent-light border border-accent/30'
|
|
: 'hover:bg-surface-hover border border-transparent'
|
|
}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-1">
|
|
<div className="flex items-center gap-1.5 min-w-0">
|
|
{wf.is_active && (
|
|
<span
|
|
className="flex-shrink-0 w-2 h-2 rounded-full bg-green-500"
|
|
title="Active"
|
|
/>
|
|
)}
|
|
<p className="text-sm font-medium text-content truncate">{wf.name}</p>
|
|
</div>
|
|
<button
|
|
onClick={e => {
|
|
e.stopPropagation()
|
|
if (confirm(`Delete workflow "${wf.name}"?`)) {
|
|
deleteMutation.mutate(wf.id)
|
|
}
|
|
}}
|
|
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-red-100 hover:text-red-600 text-content-muted flex-shrink-0"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
<span
|
|
className={`inline-block mt-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
typeBadgeColor[presetType]
|
|
}`}
|
|
>
|
|
{typeLabel[presetType]}
|
|
</span>
|
|
<span
|
|
className={`inline-block mt-1 ml-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
EXECUTION_MODE_BADGE_STYLES[executionMode]
|
|
}`}
|
|
>
|
|
{EXECUTION_MODE_LABELS[executionMode]}
|
|
</span>
|
|
{!wf.is_active && (
|
|
<span className="ml-1 text-xs text-content-muted">(inactive)</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-border-default bg-surface flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-xl font-semibold text-content">Workflow Editor</h1>
|
|
{selectedWorkflow && (
|
|
<div className="mt-0.5 flex items-center gap-2">
|
|
<p className="text-sm text-content-muted">{selectedWorkflow.name}</p>
|
|
<span
|
|
className={`inline-block text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
|
EXECUTION_MODE_BADGE_STYLES[selectedWorkflow.config.ui?.execution_mode ?? 'legacy']
|
|
}`}
|
|
>
|
|
{EXECUTION_MODE_LABELS[selectedWorkflow.config.ui?.execution_mode ?? 'legacy']}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => setShowNewModal(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent-hover transition-colors"
|
|
>
|
|
<Plus size={16} />
|
|
New 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-content-muted mb-4" />
|
|
{workflows.length === 0 ? (
|
|
<>
|
|
<p className="text-content-secondary font-medium">No workflows configured.</p>
|
|
<p className="text-sm text-content-muted mt-1 max-w-xs mx-auto">
|
|
Workflows define the sequence of pipeline steps for rendering orders.
|
|
Click "New Workflow" to create one.
|
|
</p>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p className="text-content-secondary font-medium">No workflow selected</p>
|
|
<p className="text-sm text-content-muted mt-1">
|
|
Select a workflow from the list or create a new one.
|
|
</p>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={() => setShowNewModal(true)}
|
|
className="mt-4 flex items-center gap-2 px-4 py-2 mx-auto rounded-lg bg-accent text-white text-sm font-medium hover:bg-accent-hover"
|
|
>
|
|
<Plus size={16} />
|
|
New Workflow
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* New Workflow Modal */}
|
|
{showNewModal && (
|
|
<NewWorkflowModal
|
|
onClose={() => setShowNewModal(false)}
|
|
onCreate={handleCreate}
|
|
isLoading={createMutation.isPending}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|