diff --git a/src/areas/workflows/WorkflowsPage.tsx b/src/areas/workflows/WorkflowsPage.tsx index 2f5c849..3f33de5 100644 --- a/src/areas/workflows/WorkflowsPage.tsx +++ b/src/areas/workflows/WorkflowsPage.tsx @@ -29,6 +29,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 WorkflowEdge from './nodes/WorkflowEdge' @@ -36,7 +37,7 @@ import WorkflowEdge from './nodes/WorkflowEdge' const DRAG_KEY = 'modly/extension-id' const DRAG_NODE_KEY = 'modly/node-type' -const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, waitNode: WaitNode } +const NODE_TYPES = { extensionNode: ExtensionNode, imageNode: ImageNode, textNode: TextNode, outputNode: AddToSceneNode, meshNode: Load3DMeshNode, previewNode: PreviewImageNode, imagePreviewNode: ImagePreviewNode, waitNode: WaitNode } const EDGE_TYPES = { workflowEdge: WorkflowEdge } const DEFAULT_EDGE_OPTS = { type: 'workflowEdge' } @@ -158,6 +159,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: <> }, ] @@ -402,6 +404,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' }, ] @@ -747,6 +750,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 } @@ -758,6 +762,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 0000000..158191f --- /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 ? ( + preview + ) : ( +

+ Connect an image and run to preview. +

+ )} +
+
+ ) +} diff --git a/src/areas/workflows/preflight.ts b/src/areas/workflows/preflight.ts index 2fac49a..bd25b60 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 === 'extensionNode') { return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.name ?? 'Extension' } @@ -39,6 +40,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 === 'extensionNode') { return getWorkflowExtension(node.data.extensionId ?? '', allExtensions)?.output }