feat: add workflow node registry phase 2
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef, DragEvent } from 'react'
|
||||
import { useState, useCallback, useRef, useEffect, type ChangeEvent, type DragEvent } from 'react'
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
getPipelineSteps,
|
||||
getNodeDefinitions,
|
||||
createPresetWorkflowConfig,
|
||||
getWorkflowPresetType,
|
||||
type WorkflowDefinition,
|
||||
@@ -30,8 +30,9 @@ import {
|
||||
type WorkflowEdge,
|
||||
type WorkflowPresetType,
|
||||
type WorkflowParams,
|
||||
type PipelineStep,
|
||||
type StepCategory,
|
||||
type WorkflowNodeDefinition,
|
||||
type WorkflowNodeFieldDefinition,
|
||||
} from '../api/workflows'
|
||||
import {
|
||||
FileUp,
|
||||
@@ -40,6 +41,7 @@ import {
|
||||
Film,
|
||||
Layers,
|
||||
Download,
|
||||
Bell,
|
||||
Plus,
|
||||
Save,
|
||||
Trash2,
|
||||
@@ -59,15 +61,49 @@ function normalizeWorkflowParams(params: WorkflowParams): WorkflowParams {
|
||||
return normalized
|
||||
}
|
||||
|
||||
function getResolutionSelection(params: WorkflowParams): number {
|
||||
const resolution = Array.isArray(params.resolution) ? params.resolution : undefined
|
||||
if (resolution && typeof resolution[0] === 'number') {
|
||||
return Number(resolution[0])
|
||||
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} />
|
||||
}
|
||||
if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) {
|
||||
return params.width
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
return 2048
|
||||
}
|
||||
|
||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||
@@ -75,14 +111,14 @@ function getResolutionSelection(params: WorkflowParams): number {
|
||||
interface BaseNodeProps {
|
||||
label: string
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
accentClass: string
|
||||
description?: string
|
||||
selected?: boolean
|
||||
hasSource?: boolean
|
||||
hasTarget?: boolean
|
||||
}
|
||||
|
||||
function BaseNode({ label, icon, color, description, selected, hasSource = true, hasTarget = true }: BaseNodeProps) {
|
||||
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 ${
|
||||
@@ -92,7 +128,7 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
|
||||
{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 text-${color}-600`}>
|
||||
<div className={`flex items-center gap-2 mb-1 ${accentClass}`}>
|
||||
{icon}
|
||||
<span className="font-medium text-sm">{label}</span>
|
||||
</div>
|
||||
@@ -104,76 +140,80 @@ function BaseNode({ label, icon, color, description, selected, hasSource = true,
|
||||
)
|
||||
}
|
||||
|
||||
function InputNode({ selected }: { selected?: boolean }) {
|
||||
function InputNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label="STEP Input"
|
||||
icon={<FileUp size={14} />}
|
||||
color="green"
|
||||
description="STEP file input"
|
||||
label={data.label}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-green-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
hasTarget={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ConvertNode({ selected }: { selected?: boolean }) {
|
||||
function ConvertNode({ data, selected }: { data: WorkflowCanvasNodeData; selected?: boolean }) {
|
||||
return (
|
||||
<BaseNode
|
||||
label="STL Conversion"
|
||||
icon={<RefreshCw size={14} />}
|
||||
color="blue"
|
||||
description="STEP → STL (cadquery)"
|
||||
label={data.label}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-blue-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RenderNode({ data, selected }: { data: { label?: string; params?: WorkflowParams }; selected?: boolean }) {
|
||||
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 ?? 'Still Render'}
|
||||
icon={<Camera size={14} />}
|
||||
color="orange"
|
||||
description={params.render_engine ? `${params.render_engine} · ${params.samples ?? 256} samples` : undefined}
|
||||
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: { params?: WorkflowParams }; selected?: boolean }) {
|
||||
function RenderFramesNode({ data, selected }: { data: WorkflowCanvasNodeData; 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}
|
||||
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 FFmpegNode({ selected }: { selected?: boolean }) {
|
||||
function OutputNode({ data, selected }: { data: WorkflowCanvasNodeData; 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="Output file"
|
||||
label={data.label}
|
||||
icon={renderWorkflowIcon(data.icon)}
|
||||
accentClass="text-slate-600"
|
||||
description={data.description}
|
||||
selected={selected}
|
||||
hasSource={false}
|
||||
/>
|
||||
@@ -183,9 +223,9 @@ function OutputNode({ data, selected }: { data: { label?: string }; selected?: b
|
||||
const nodeTypes: NodeTypes = {
|
||||
inputNode: InputNode as any,
|
||||
convertNode: ConvertNode as any,
|
||||
processNode: ProcessNode as any,
|
||||
renderNode: RenderNode as any,
|
||||
renderFramesNode: RenderFramesNode as any,
|
||||
ffmpegNode: FFmpegNode as any,
|
||||
outputNode: OutputNode as any,
|
||||
}
|
||||
|
||||
@@ -193,7 +233,11 @@ 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') return 'outputNode'
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -207,22 +251,24 @@ function inferNodeLabel(step: string): string {
|
||||
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): { nodes: Node[]; edges: Edge[] } {
|
||||
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 ?? inferNodeType(node.step),
|
||||
type: node.ui?.type ?? nodeDefinitionsByStep[node.step]?.node_type ?? inferNodeType(node.step),
|
||||
position: node.ui?.position ?? { x: 0, y: 0 },
|
||||
data: {
|
||||
label: node.ui?.label ?? inferNodeLabel(node.step),
|
||||
params: node.params ?? {},
|
||||
step: node.step,
|
||||
},
|
||||
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}`,
|
||||
@@ -234,149 +280,155 @@ function workflowToGraph(config: WorkflowConfig): { nodes: Node[]; edges: Edge[]
|
||||
|
||||
// ─── 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,
|
||||
pipelineStep,
|
||||
onPipelineStepChange,
|
||||
pipelineSteps,
|
||||
nodeDefinition,
|
||||
step,
|
||||
onStepChange,
|
||||
nodeDefinitions,
|
||||
}: {
|
||||
params: WorkflowParams
|
||||
onChange: (p: WorkflowParams) => void
|
||||
pipelineStep?: string
|
||||
onPipelineStepChange?: (step: string) => void
|
||||
pipelineSteps: PipelineStep[]
|
||||
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>
|
||||
|
||||
{/* Pipeline Step binding */}
|
||||
{pipelineSteps.length > 0 && onPipelineStepChange && (
|
||||
{nodeDefinitions.length > 0 && onStepChange && (
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">Pipeline Step</label>
|
||||
<label className="text-sm text-content-secondary mb-2 block">Workflow Node</label>
|
||||
<select
|
||||
value={pipelineStep ?? ''}
|
||||
onChange={e => onPipelineStepChange(e.target.value)}
|
||||
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"
|
||||
>
|
||||
<option value="">(not bound)</option>
|
||||
{pipelineSteps.map(s => (
|
||||
<option key={s.name} value={s.name}>
|
||||
{s.label}
|
||||
{nodeDefinitions.map(definition => (
|
||||
<option key={definition.step} value={definition.step}>
|
||||
{definition.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{pipelineStep && (
|
||||
<p className="text-xs text-content-muted mt-1">
|
||||
{pipelineSteps.find(s => s.name === pipelineStep)?.description ?? ''}
|
||||
</p>
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* Render Engine */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary 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-accent text-white'
|
||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
||||
}`}
|
||||
>
|
||||
{eng === 'cycles' ? 'Cycles' : 'EEVEE'}
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Samples */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">
|
||||
Samples: <span className="font-semibold text-content">{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-accent"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-content-muted mt-1">
|
||||
<span>1</span>
|
||||
<span>4096</span>
|
||||
</div>
|
||||
</div>
|
||||
{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
|
||||
|
||||
{/* Resolution */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">Resolution</label>
|
||||
<div className="flex gap-2">
|
||||
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => onChange(normalizeWorkflowParams({ ...params, resolution: [w, w] }))}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
getResolutionSelection(params) === w
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
||||
}`}
|
||||
>
|
||||
{w}px
|
||||
</button>
|
||||
))}
|
||||
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>
|
||||
|
||||
{/* FPS (only relevant for animation nodes) */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">
|
||||
FPS: <span className="font-semibold text-content">{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-accent text-white'
|
||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
||||
}`}
|
||||
>
|
||||
{fps}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Duration */}
|
||||
<div>
|
||||
<label className="text-sm text-content-secondary mb-2 block">
|
||||
Duration (s): <span className="font-semibold text-content">{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-accent"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Pipeline Steps Panel ─────────────────────────────────────────────────────
|
||||
// ─── Node Definitions Panel ───────────────────────────────────────────────────
|
||||
|
||||
const CATEGORY_LABELS: Record<StepCategory, string> = {
|
||||
input: 'Input',
|
||||
@@ -392,12 +444,12 @@ const CATEGORY_COLORS: Record<StepCategory, string> = {
|
||||
output: 'bg-purple-100 text-purple-700 dark:bg-purple-900/40 dark:text-purple-300',
|
||||
}
|
||||
|
||||
function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
||||
function NodeDefinitionsPanel({ definitions }: { definitions: WorkflowNodeDefinition[] }) {
|
||||
const [expanded, setExpanded] = useState<StepCategory | null>(null)
|
||||
|
||||
const grouped = steps.reduce<Record<StepCategory, PipelineStep[]>>(
|
||||
(acc, step) => {
|
||||
acc[step.category] = [...(acc[step.category] ?? []), step]
|
||||
const grouped = definitions.reduce<Record<StepCategory, WorkflowNodeDefinition[]>>(
|
||||
(acc, definition) => {
|
||||
acc[definition.category] = [...(acc[definition.category] ?? []), definition]
|
||||
return acc
|
||||
},
|
||||
{ input: [], processing: [], rendering: [], output: [] },
|
||||
@@ -408,7 +460,7 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
||||
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">
|
||||
Pipeline Steps
|
||||
Available Nodes
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{categories.map(cat => (
|
||||
@@ -424,14 +476,26 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
||||
</button>
|
||||
{expanded === cat && (
|
||||
<div className="ml-2 mt-1 space-y-1">
|
||||
{grouped[cat].map(step => (
|
||||
{grouped[cat].map(definition => (
|
||||
<div
|
||||
key={step.name}
|
||||
key={definition.step}
|
||||
className="text-xs bg-surface-hover rounded px-2 py-1.5"
|
||||
title={step.description}
|
||||
title={definition.description}
|
||||
>
|
||||
<p className="font-mono text-content-secondary truncate">{step.name}</p>
|
||||
<p className="text-content-muted mt-0.5 line-clamp-2">{step.description}</p>
|
||||
<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>
|
||||
@@ -443,16 +507,6 @@ function PipelineStepsPanel({ steps }: { steps: PipelineStep[] }) {
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 {
|
||||
@@ -543,19 +597,26 @@ interface FlowCanvasProps {
|
||||
}
|
||||
|
||||
function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const { nodes: initNodes, edges: initEdges } = workflowToGraph(workflow.config)
|
||||
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 reactFlowWrapper = useRef<HTMLDivElement>(null)
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null)
|
||||
|
||||
const { data: pipelineStepsData } = useQuery({
|
||||
queryKey: ['pipeline-steps'],
|
||||
queryFn: getPipelineSteps,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
const pipelineSteps = pipelineStepsData?.steps ?? []
|
||||
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)),
|
||||
@@ -586,15 +647,24 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
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: {
|
||||
...n.data,
|
||||
...buildNodeData(
|
||||
stepName || inferStepFromNodeType(n.type),
|
||||
{
|
||||
...(definition?.defaults ?? {}),
|
||||
...currentData.params,
|
||||
},
|
||||
definition,
|
||||
),
|
||||
step: stepName || inferStepFromNodeType(n.type),
|
||||
label: (n.data as any).label ?? inferNodeLabel(stepName),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -602,7 +672,7 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
[selectedNodeId, setNodes],
|
||||
[nodeDefinitionsByStep, selectedNodeId, setNodes],
|
||||
)
|
||||
|
||||
// Drag-drop new nodes from palette
|
||||
@@ -614,8 +684,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
const onDrop = useCallback(
|
||||
(event: DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
const type = event.dataTransfer.getData('application/reactflow')
|
||||
if (!type || !reactFlowInstance) return
|
||||
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,
|
||||
@@ -623,18 +696,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
})
|
||||
|
||||
const newNode: Node = {
|
||||
id: `${type}_${Date.now()}`,
|
||||
id: `${step}_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
label: type,
|
||||
params: {},
|
||||
step: inferStepFromNodeType(type),
|
||||
},
|
||||
data: buildNodeData(step, definition?.defaults ?? {}, definition),
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
},
|
||||
[reactFlowInstance, setNodes],
|
||||
[nodeDefinitionsByStep, reactFlowInstance, setNodes],
|
||||
)
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -667,20 +736,21 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
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">
|
||||
<span className="text-sm font-medium text-content-secondary mr-2">Nodes</span>
|
||||
{NODE_PALETTE.map(item => (
|
||||
<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={item.type}
|
||||
key={definition.step}
|
||||
draggable
|
||||
onDragStart={e => {
|
||||
e.dataTransfer.setData('application/reactflow', item.type)
|
||||
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"
|
||||
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}
|
||||
>
|
||||
{item.icon}
|
||||
{item.label}
|
||||
{renderWorkflowIcon(definition.icon)}
|
||||
{definition.label}
|
||||
</div>
|
||||
))}
|
||||
<div className="ml-auto">
|
||||
@@ -722,14 +792,15 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
<ConfigSidepanel
|
||||
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
||||
onChange={handleParamsChange}
|
||||
pipelineStep={(selectedNode.data as any).step as string | undefined}
|
||||
onPipelineStepChange={handlePipelineStepChange}
|
||||
pipelineSteps={pipelineSteps}
|
||||
step={(selectedNode.data as any).step as string | undefined}
|
||||
onStepChange={handlePipelineStepChange}
|
||||
nodeDefinition={nodeDefinitionsByStep[((selectedNode.data as any).step as string | undefined) ?? '']}
|
||||
nodeDefinitions={nodeDefinitions}
|
||||
/>
|
||||
)}
|
||||
{!selectedNode && pipelineSteps.length > 0 && (
|
||||
{!selectedNode && nodeDefinitions.length > 0 && (
|
||||
<div className="w-64 border-l border-border-default bg-surface p-4 overflow-y-auto">
|
||||
<PipelineStepsPanel steps={pipelineSteps} />
|
||||
<NodeDefinitionsPanel definitions={nodeDefinitions} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user