Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/areas/workflows/WorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ 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'

// ─── Constants ────────────────────────────────────────────────────────────────

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' }
Expand Down Expand Up @@ -158,6 +159,7 @@ const PANEL_BUILTIN_NODES = [
{ type: 'meshNode', label: 'Load 3D Mesh', color: '#a78bfa', icon: <><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></> },
{ type: 'outputNode', label: 'Add to Scene', color: '#a78bfa', icon: <><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></> },
{ type: 'previewNode', label: 'Preview Views', color: '#38bdf8', icon: <><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></> },
{ type: 'imagePreviewNode', label: 'Preview Image', color: '#38bdf8', icon: <><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></> },
{ type: 'waitNode', label: 'Wait', color: '#71717a', icon: <><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></> },
]

Expand Down Expand Up @@ -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' },
]

Expand Down Expand Up @@ -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
}

Expand All @@ -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)
Expand Down
109 changes: 109 additions & 0 deletions src/areas/workflows/nodes/ImagePreviewNode.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null)
const [handleTop, setHandleTop] = useState('50%')
const [dataUrl, setDataUrl] = useState<string | undefined>(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 (
<BaseNode
id={id}
selected={selected}
title="Preview Image"
minWidth={180}
icon={
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={IO_COLOR} strokeWidth="2">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21 15 16 10 5 21"/>
</svg>
}
subheader={
<div ref={ioRowRef} className="flex items-center justify-between px-3 py-2">
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium border border-sky-500/30 bg-sky-500/10 text-sky-400">image</span>
<span className="text-[9px] text-zinc-600">&rarr;</span>
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[9px] font-medium border border-sky-500/30 bg-sky-500/10 text-sky-400">image</span>
</div>
}
handles={
<>
<Handle
type="target"
position={Position.Left}
style={{ background: IO_COLOR, width: 14, height: 14, border: '2.5px solid #18181b', top: handleTop }}
/>
<Handle
type="source"
position={Position.Right}
style={{ background: IO_COLOR, width: 14, height: 14, border: '2.5px solid #18181b', top: handleTop }}
/>
</>
}
>
<div className="px-2 pb-2 pt-1 flex-1 min-h-0">
{dataUrl ? (
<img src={dataUrl} alt="preview" className="nodrag w-full h-full object-contain rounded" />
) : (
<p className="py-3 text-center text-[10px] text-zinc-600 italic">
Connect an image and run to preview.
</p>
)}
</div>
</BaseNode>
)
}
2 changes: 2 additions & 0 deletions src/areas/workflows/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand All @@ -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
}
Expand Down