diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index cf5ee4bb..b31c5ea9 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -28,6 +28,7 @@ import TextNode from './nodes/TextNode' import AddToSceneNode from './nodes/AddToSceneNode' import Load3DMeshNode from './nodes/Load3DMeshNode' import PreviewImageNode from './nodes/PreviewImageNode' +import ImagePreviewNode from './nodes/ImagePreviewNode' import WaitNode from './nodes/WaitNode' import WhileNode from './nodes/WhileNode' import ForEachNode from './nodes/ForEachNode' @@ -100,6 +101,7 @@ const PANEL_BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <>> }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <>> }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <>> }, + { type: 'imagePreviewNode', label: 'Preview Image', color: '#38bdf8', icon: <>> }, { type: 'waitNode', label: 'Wait', color: '#71717a', icon: <>> }, { type: 'whileNode', label: 'While', color: '#f59e0b', icon: <>> }, { type: 'forEachNode', label: 'For Each', color: '#38bdf8', icon: <>> }, @@ -346,6 +348,7 @@ const BUILTIN_NODES = [ { type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', description: 'Load a 3D mesh file or use current model' }, { type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', description: 'Output node — adds the mesh to the 3D scene' }, { type: 'previewNode', label: 'Preview Views', color: '#38bdf8', description: 'Displays multi-view image outputs in a 2×3 grid' }, + { type: 'imagePreviewNode', label: 'Preview Image', color: '#38bdf8', description: 'Displays a single image output in the workflow' }, { type: 'waitNode', label: 'Wait', color: '#71717a', description: 'Pauses the workflow until you click Continue' }, { type: 'whileNode', label: 'While', color: '#f59e0b', description: 'Container: wrap nodes to loop them N times or with Continue/Retry' }, { type: 'forEachNode', label: 'For Each', color: '#38bdf8', description: 'Iterates a folder (image / text / mesh) alphabetically, one item per run of the downstream nodes' }, @@ -701,6 +704,7 @@ function getNodeOutputType(node: Node | undefined, allExts: WorkflowExtension[]) if (node.type === 'imageNode') return 'image' if (node.type === 'meshNode') return 'mesh' if (node.type === 'textNode') return 'text' + if (node.type === 'imagePreviewNode') return 'image' return allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId)?.output } @@ -712,6 +716,7 @@ function getNodeInputType( if (!node) return undefined if (node.type === 'outputNode') return 'mesh' if (node.type === 'previewNode') return 'image' + if (node.type === 'imagePreviewNode') return 'image' const ext = allExts.find((e) => e.id === (node.data as WFNodeData)?.extensionId) if (ext?.inputs && ext.inputs.length > 1 && targetHandle) { const idx = parseInt(targetHandle.replace('input-', ''), 10) diff --git a/src/areas/workflows/nodes/ImagePreviewNode.tsx b/src/areas/workflows/nodes/ImagePreviewNode.tsx new file mode 100644 index 00000000..158191fc --- /dev/null +++ b/src/areas/workflows/nodes/ImagePreviewNode.tsx @@ -0,0 +1,109 @@ +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Handle, Position, useEdges, useNodes } from '@xyflow/react' +import { useWorkflowRunStore } from '../workflowRunStore' +import BaseNode from './BaseNode' + +const IO_COLOR = '#38bdf8' + +function mimeFromPath(p: string): string { + const ext = p.split('.').pop()?.toLowerCase() ?? '' + if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg' + if (ext === 'webp') return 'image/webp' + return 'image/png' +} + +export default function ImagePreviewNode({ id, selected }: { id: string; selected?: boolean }) { + const nodeImageOutputs = useWorkflowRunStore((s) => s.nodeImageOutputs) + const edges = useEdges() + const nodes = useNodes() + const ioRowRef = useRef(null) + const [handleTop, setHandleTop] = useState('50%') + const [dataUrl, setDataUrl] = useState(undefined) + + useLayoutEffect(() => { + if (ioRowRef.current) { + const center = ioRowRef.current.offsetTop + ioRowRef.current.offsetHeight / 2 + setHandleTop(`${center}px`) + } + }, []) + + const incomingEdge = edges.find((e) => e.target === id) + const sourceNode = incomingEdge ? nodes.find((n) => n.id === incomingEdge.source) : undefined + const workspaceUrl = incomingEdge ? nodeImageOutputs[incomingEdge.source] : undefined + const imageFilePath = sourceNode?.type === 'imageNode' + ? (sourceNode.data as { params?: { filePath?: string } })?.params?.filePath + : undefined + + useEffect(() => { + let cancelled = false + if (!workspaceUrl && !imageFilePath) { + setDataUrl(undefined) + return + } + ;(async () => { + try { + let absPath: string + if (workspaceUrl) { + const settings = await window.electron.settings.get() + const wsDir = settings.workspaceDir.replace(/\\/g, '/').replace(/\/+$/, '') + const rel = workspaceUrl.replace(/^\/workspace\//, '') + absPath = `${wsDir}/${rel}` + } else { + absPath = imageFilePath! + } + const base64 = await window.electron.fs.readFileBase64(absPath) + if (!cancelled) setDataUrl(`data:${mimeFromPath(absPath)};base64,${base64}`) + } catch { + if (!cancelled) setDataUrl(undefined) + } + })() + return () => { cancelled = true } + }, [workspaceUrl, imageFilePath]) + + return ( + + + + + + } + subheader={ + + image + → + image + + } + handles={ + <> + + + > + } + > + + {dataUrl ? ( + + ) : ( + + Connect an image and run to preview. + + )} + + + ) +} diff --git a/src/areas/workflows/preflight.ts b/src/areas/workflows/preflight.ts index b33c1851..3985855c 100644 --- a/src/areas/workflows/preflight.ts +++ b/src/areas/workflows/preflight.ts @@ -16,6 +16,7 @@ function nodeLabel(node: WFNode, allExtensions: WorkflowExtension[]): string { if (node.type === 'meshNode') return 'Load 3D Mesh' if (node.type === 'outputNode') return 'Add to Scene' if (node.type === 'previewNode') return 'Preview Views' + if (node.type === 'imagePreviewNode') return 'Preview Image' if (node.type === 'forEachNode') { const mode = (node.data.params?.mode as string) ?? 'image' return mode === 'text' ? 'For Each Text' : mode === 'mesh' ? 'For Each Mesh' : 'For Each Image' @@ -44,6 +45,7 @@ function getNodeOutputType(node: WFNode, allExtensions: WorkflowExtension[]): Da if (node.type === 'textNode') return 'text' if (node.type === 'meshNode' || node.type === 'outputNode') return 'mesh' if (node.type === 'previewNode') return 'image' + if (node.type === 'imagePreviewNode') return 'image' if (node.type === 'forEachNode') { const mode = (node.data.params?.mode as DataType | undefined) ?? 'image' return mode === 'text' || mode === 'mesh' ? mode : 'image'
+ Connect an image and run to preview. +