295 lines
9.2 KiB
TypeScript
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
|
|
}
|