import { MarkerType, type Edge, type Node } from '@vue-flow/core' import { ioOptionsFor, ioTypeFor, type GraphFxIoType } from './nodeDefinitions' export type GraphFxIoValue = { label?: string | null value?: unknown output?: string | null type?: GraphFxIoType definition?: { type?: GraphFxIoType; enum?: string[]; options?: string[] } } export type GraphFxSerializedNode = { node: { id: string name: string options?: { in?: Record out?: Record [key: string]: unknown } [key: string]: unknown } x?: number y?: number [key: string]: unknown } export type GraphFxSerializedGraph = GraphFxSerializedNode[] export type GraphFxPort = { name: string label: string type: GraphFxIoType output?: string | null connectedDisplay?: string | null value?: unknown options?: string[] previewUrl?: string | null } export type GraphFxImageOption = { id: string name: string } export type GraphFxImageInspection = { nodeId: string nodeName: string direction: 'in' | 'out' ioName: string label: string url: string } export type GraphFxNodeActions = { renameNodeId: (nodeId: string, nextId: string) => void updateIoLabel: (nodeId: string, direction: 'in' | 'out', ioName: string, label: string) => void updateInputValue: (nodeId: string, ioName: string, value: string | boolean) => void disconnectInput: (nodeId: string, ioName: string) => void deleteNode: (nodeId: string) => void duplicateNode: (nodeId: string) => void selectInputImage: (nodeId: string, ioName: string, imageId: string) => void | Promise inspectImage: (inspection: GraphFxImageInspection) => void } export type GraphFxNodeData = { id: string name: string inputs: GraphFxPort[] outputs: GraphFxPort[] hasSavedPosition: boolean raw: GraphFxSerializedNode actions?: GraphFxNodeActions imageOptions?: GraphFxImageOption[] selectedImages?: Record imagePreviews?: Record runtimeReady?: boolean } export type GraphFxFlowWarning = { type: 'missing-output' | 'duplicate-output' | 'invalid-node' message: string nodeId?: string ioName?: string outputId?: string } export type GraphFxFlowModel = { nodes: Node[] edges: Edge[] warnings: GraphFxFlowWarning[] } export function getInputHandleId(nodeId: string, inputName: string) { return `${nodeId}:in:${inputName}` } export function getOutputHandleId(nodeId: string, outputName: string) { return `${nodeId}:out:${outputName}` } function serializedOutputIds(nodeId: string, outputName: string) { return [ // GraphFX Output.id is `${outputsSet.id}-${name}` and Outputs.id is `${node.id}-out`. `${nodeId}-out-${outputName}`, // Keep accepting early graphfx-ui/sample shorthand. `${nodeId}-${outputName}`, ] } const ioTypeColors: Record = { Image: '#4dd7ff', Number: '#f6c85f', String: '#9ee66f', Boolean: '#c084fc', Color: '#ff78b7', Font: '#fb923c', Unknown: '#94a3b8', } function labelFor(name: string, io?: GraphFxIoValue) { return io?.label || name } function imagePreviewUrl(value: unknown, depth = 0): string | null { if (!value || depth > 4) return null if (typeof value === 'string') { const trimmed = value.trim() if (trimmed.startsWith('data:image/')) return trimmed const base64Match = trimmed.match(/(?:base64,)?([A-Za-z0-9+/=\r\n]{40,})$/) if (base64Match && /^[A-Za-z0-9+/=\r\n]+$/.test(base64Match[1])) return `data:image/png;base64,${base64Match[1].replace(/\s+/g, '')}` return null } if (typeof value === 'object') { const record = value as Record for (const key of ['src', 'dataUrl', 'dataURL', 'url', 'href', 'base64', 'data', 'image', 'value']) { const found = imagePreviewUrl(record[key], depth + 1) if (found) return found } for (const nested of Object.values(record)) { const found = imagePreviewUrl(nested, depth + 1) if (found) return found } } return null } function normalizedOptions(nodeName: string, direction: 'in' | 'out', name: string, io?: GraphFxIoValue) { const options = ioOptionsFor(nodeName, direction, name, io?.definition) if (!options.length) return [] const value = typeof io?.value === 'string' ? io.value : null return value && !options.includes(value) ? [value, ...options] : options } function toPorts(nodeName: string, record: Record | undefined, direction: 'in' | 'out') { return Object.entries(record || {}).map(([name, io]) => { const type = ioTypeFor(nodeName, direction, name, io?.type || io?.definition?.type) return { name, label: labelFor(name, io), type, output: direction === 'in' ? io?.output || null : null, value: direction === 'in' ? io?.value : undefined, options: normalizedOptions(nodeName, direction, name, io), previewUrl: imagePreviewUrl(io?.value), } }) } function savedPosition(item: GraphFxSerializedNode, index: number) { const hasX = typeof item.x === 'number' && Number.isFinite(item.x) const hasY = typeof item.y === 'number' && Number.isFinite(item.y) return { hasSavedPosition: hasX && hasY, position: hasX && hasY ? { x: item.x as number, y: item.y as number } : { x: index * 430, y: 0 }, } } export function graphFxToVueFlow(graph: GraphFxSerializedGraph): GraphFxFlowModel { const warnings: GraphFxFlowWarning[] = [] const outputBySerializedId = new Map() const nodeIds = new Set() for (const [index, item] of graph.entries()) { if (!item?.node?.id || !item.node.name) { warnings.push({ type: 'invalid-node', message: `Graph entry #${index + 1} does not look like a serialized GraphFX node.`, }) continue } nodeIds.add(item.node.id) for (const outputName of Object.keys(item.node.options?.out || {})) { const [serializedOutputId, ...aliases] = serializedOutputIds(item.node.id, outputName) if (outputBySerializedId.has(serializedOutputId)) { warnings.push({ type: 'duplicate-output', message: `Output id ${serializedOutputId} appears more than once.`, nodeId: item.node.id, ioName: outputName, outputId: serializedOutputId, }) } outputBySerializedId.set(serializedOutputId, { nodeId: item.node.id, outputName }) for (const alias of aliases) { if (!outputBySerializedId.has(alias)) { outputBySerializedId.set(alias, { nodeId: item.node.id, outputName }) } } } } const nodes: Node[] = graph .filter((item) => item?.node?.id && item.node.name) .map((item, index) => { const { hasSavedPosition, position } = savedPosition(item, index) const inputs = toPorts(item.node.name, item.node.options?.in, 'in') const outputs = toPorts(item.node.name, item.node.options?.out, 'out') return { id: item.node.id, type: 'graphfx', position, data: { id: item.node.id, name: item.node.name, inputs, outputs, hasSavedPosition, raw: item, }, } }) const edges: Edge[] = [] for (const item of graph) { if (!item?.node?.id) continue for (const [inputName, input] of Object.entries(item.node.options?.in || {})) { if (!input.output) continue const source = outputBySerializedId.get(input.output) if (!source) { warnings.push({ type: 'missing-output', message: `${item.node.name}.${inputName} references missing output ${input.output}.`, nodeId: item.node.id, ioName: inputName, outputId: input.output, }) continue } const sourceNode = nodes.find((node) => node.id === source.nodeId) const sourcePort = sourceNode?.data?.outputs.find((output) => output.name === source.outputName) const targetNode = nodes.find((node) => node.id === item.node.id) const targetPort = targetNode?.data?.inputs.find((port) => port.name === inputName) if (targetPort) { targetPort.connectedDisplay = `${sourceNode?.data?.name || source.nodeId}.${sourcePort?.label || source.outputName}` } const edgeColor = ioTypeColors[sourcePort?.type || 'Unknown'] edges.push({ id: `${source.nodeId}:${source.outputName}->${item.node.id}:${inputName}`, source: source.nodeId, target: item.node.id, sourceHandle: getOutputHandleId(source.nodeId, source.outputName), targetHandle: getInputHandleId(item.node.id, inputName), type: 'smoothstep', animated: false, style: { stroke: edgeColor, strokeWidth: 2.8 }, markerEnd: { type: MarkerType.ArrowClosed, color: edgeColor }, }) } } return { nodes, edges, warnings } } export function parseGraphFxJson(json: string): GraphFxSerializedGraph { const parsed = JSON.parse(json) if (!Array.isArray(parsed)) { throw new Error('GraphFX serialized graphs are arrays returned by graph.serialize().') } return parsed as GraphFxSerializedGraph } export function countSavedPositions(graph: GraphFxSerializedGraph) { return graph.filter((item) => typeof item.x === 'number' && typeof item.y === 'number').length }