Files
graphfx/graphfx-ui/src/lib/graphfxToVueFlow.ts

295 lines
9.2 KiB
TypeScript

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<string, GraphFxIoValue>
out?: Record<string, GraphFxIoValue>
[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<void>
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<string, string>
imagePreviews?: Record<string, string>
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<GraphFxNodeData>[]
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<GraphFxIoType, string> = {
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<string, unknown>
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<string, GraphFxIoValue> | 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<string, { nodeId: string; outputName: string }>()
const nodeIds = new Set<string>()
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<GraphFxNodeData>[] = 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
}