feat: stabilize workflow phase 1 foundation
This commit is contained in:
@@ -23,8 +23,12 @@ import {
|
||||
updateWorkflow,
|
||||
deleteWorkflow,
|
||||
getPipelineSteps,
|
||||
createPresetWorkflowConfig,
|
||||
getWorkflowPresetType,
|
||||
type WorkflowDefinition,
|
||||
type WorkflowConfig,
|
||||
type WorkflowEdge,
|
||||
type WorkflowPresetType,
|
||||
type WorkflowParams,
|
||||
type PipelineStep,
|
||||
type StepCategory,
|
||||
@@ -44,6 +48,28 @@ import {
|
||||
} 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
|
||||
}
|
||||
|
||||
function getResolutionSelection(params: WorkflowParams): number {
|
||||
const resolution = Array.isArray(params.resolution) ? params.resolution : undefined
|
||||
if (resolution && typeof resolution[0] === 'number') {
|
||||
return Number(resolution[0])
|
||||
}
|
||||
if (typeof params.width === 'number' && typeof params.height === 'number' && params.width === params.height) {
|
||||
return params.width
|
||||
}
|
||||
return 2048
|
||||
}
|
||||
|
||||
// ─── Custom Node Components ──────────────────────────────────────────────────
|
||||
|
||||
interface BaseNodeProps {
|
||||
@@ -163,71 +189,47 @@ const nodeTypes: NodeTypes = {
|
||||
outputNode: OutputNode as any,
|
||||
}
|
||||
|
||||
// ─── Workflow → Graph conversion ─────────────────────────────────────────────
|
||||
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'
|
||||
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 === 'renderFramesNode') return 'blender_turntable'
|
||||
if (type === 'outputNode') return 'output_save'
|
||||
return 'blender_still'
|
||||
}
|
||||
|
||||
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 Conversion' } },
|
||||
{ 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 }
|
||||
return {
|
||||
nodes: config.nodes.map(node => ({
|
||||
id: node.id,
|
||||
type: node.ui?.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,
|
||||
},
|
||||
})),
|
||||
edges: config.edges.map((edge, index) => ({
|
||||
id: `e_${edge.from}_${edge.to}_${index}`,
|
||||
source: edge.from,
|
||||
target: edge.to,
|
||||
})),
|
||||
}
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
@@ -320,9 +322,9 @@ function ConfigSidepanel({
|
||||
{([[1024, 1024], [2048, 2048], [4096, 4096]] as [number, number][]).map(([w]) => (
|
||||
<button
|
||||
key={w}
|
||||
onClick={() => onChange({ ...params, resolution: [w, w] })}
|
||||
onClick={() => onChange(normalizeWorkflowParams({ ...params, resolution: [w, w] }))}
|
||||
className={`px-2 py-1.5 rounded text-xs font-medium transition-colors ${
|
||||
(params.resolution?.[0] ?? 2048) === w
|
||||
getResolutionSelection(params) === w
|
||||
? 'bg-accent text-white'
|
||||
: 'bg-surface-hover text-content-secondary hover:bg-surface-muted'
|
||||
}`}
|
||||
@@ -455,13 +457,13 @@ const NODE_PALETTE = [
|
||||
|
||||
interface NewWorkflowModalProps {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, type: WorkflowConfig['type']) => void
|
||||
onCreate: (name: string, type: WorkflowPresetType) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [type, setType] = useState<WorkflowConfig['type']>('still')
|
||||
const [type, setType] = useState<WorkflowPresetType>('still')
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50">
|
||||
@@ -494,7 +496,7 @@ function NewWorkflowModal({ onClose, onCreate, isLoading }: NewWorkflowModalProp
|
||||
{ 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: WorkflowConfig['type']; label: string; desc: string }[]).map(opt => (
|
||||
] as { value: WorkflowPresetType; label: string; desc: string }[]).map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setType(opt.value)}
|
||||
@@ -545,7 +547,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
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)
|
||||
|
||||
@@ -563,8 +564,6 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNodeId(node.id)
|
||||
const nodeParams = (node.data as any).params as WorkflowParams | undefined
|
||||
if (nodeParams) setParams(nodeParams)
|
||||
}, [])
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
@@ -573,11 +572,10 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
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, data: { ...n.data, params: normalizeWorkflowParams(newParams) } }
|
||||
}
|
||||
return n
|
||||
}),
|
||||
@@ -591,7 +589,14 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
setNodes(nds =>
|
||||
nds.map(n => {
|
||||
if (n.id === selectedNodeId) {
|
||||
return { ...n, data: { ...n.data, pipeline_step: stepName || undefined } }
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
step: stepName || inferStepFromNodeType(n.type),
|
||||
label: (n.data as any).label ?? inferNodeLabel(stepName),
|
||||
},
|
||||
}
|
||||
}
|
||||
return n
|
||||
}),
|
||||
@@ -621,7 +626,11 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
id: `${type}_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: { label: type },
|
||||
data: {
|
||||
label: type,
|
||||
params: {},
|
||||
step: inferStepFromNodeType(type),
|
||||
},
|
||||
}
|
||||
setNodes(nds => [...nds, newNode])
|
||||
},
|
||||
@@ -630,9 +639,22 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedConfig: WorkflowConfig = {
|
||||
...workflow.config,
|
||||
params,
|
||||
nodes: nodes as any,
|
||||
version: workflow.config.version ?? 1,
|
||||
ui: workflow.config.ui,
|
||||
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)
|
||||
}
|
||||
@@ -698,9 +720,9 @@ function FlowCanvas({ workflow, onSave, isSaving }: FlowCanvasProps) {
|
||||
|
||||
{selectedNode && (
|
||||
<ConfigSidepanel
|
||||
params={params}
|
||||
params={((selectedNode.data as any).params as WorkflowParams | undefined) ?? {}}
|
||||
onChange={handleParamsChange}
|
||||
pipelineStep={(selectedNode.data as any).pipeline_step as string | undefined}
|
||||
pipelineStep={(selectedNode.data as any).step as string | undefined}
|
||||
onPipelineStepChange={handlePipelineStepChange}
|
||||
pipelineSteps={pipelineSteps}
|
||||
/>
|
||||
@@ -758,7 +780,7 @@ export default function WorkflowEditor() {
|
||||
onError: () => toast.error('Failed to delete workflow'),
|
||||
})
|
||||
|
||||
const handleCreate = (name: string, type: WorkflowConfig['type']) => {
|
||||
const handleCreate = (name: string, type: WorkflowPresetType) => {
|
||||
const defaultParams: WorkflowParams =
|
||||
type === 'turntable'
|
||||
? { render_engine: 'cycles', samples: 64, fps: 24, duration_s: 5 }
|
||||
@@ -768,14 +790,14 @@ export default function WorkflowEditor() {
|
||||
|
||||
createMutation.mutate({
|
||||
name,
|
||||
config: { type, params: defaultParams },
|
||||
config: createPresetWorkflowConfig(type, defaultParams),
|
||||
is_active: true,
|
||||
})
|
||||
}
|
||||
|
||||
const selectedWorkflow = workflows.find(w => w.id === selectedId) ?? null
|
||||
|
||||
const typeLabel: Record<WorkflowConfig['type'], string> = {
|
||||
const typeLabel: Record<WorkflowPresetType, string> = {
|
||||
still: 'Still',
|
||||
turntable: 'Turntable',
|
||||
multi_angle: 'Multi-Angle',
|
||||
@@ -783,7 +805,7 @@ export default function WorkflowEditor() {
|
||||
custom: 'Custom',
|
||||
}
|
||||
|
||||
const typeBadgeColor: Record<WorkflowConfig['type'], string> = {
|
||||
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',
|
||||
@@ -827,7 +849,9 @@ export default function WorkflowEditor() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{workflows.map(wf => (
|
||||
{workflows.map(wf => {
|
||||
const presetType = getWorkflowPresetType(wf.config)
|
||||
return (
|
||||
<button
|
||||
key={wf.id}
|
||||
onClick={() => setSelectedId(wf.id)}
|
||||
@@ -862,16 +886,17 @@ export default function WorkflowEditor() {
|
||||
</div>
|
||||
<span
|
||||
className={`inline-block mt-1 text-xs px-1.5 py-0.5 rounded-full font-medium ${
|
||||
typeBadgeColor[wf.config.type]
|
||||
typeBadgeColor[presetType]
|
||||
}`}
|
||||
>
|
||||
{typeLabel[wf.config.type]}
|
||||
{typeLabel[presetType]}
|
||||
</span>
|
||||
{!wf.is_active && (
|
||||
<span className="ml-1 text-xs text-content-muted">(inactive)</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user