From 8c58aaae2bcd4be2143d254da826d400d4c7174d Mon Sep 17 00:00:00 2001 From: Ficik Date: Fri, 19 Jun 2026 10:22:04 +0200 Subject: [PATCH] Improve GraphFX UI editing and image previews --- .dockerignore | 8 +- graphfx-ui/.dockerignore | 8 + graphfx-ui/src/App.vue | 723 +++++++++++++--------- graphfx-ui/src/components/GraphFxNode.vue | 186 +++++- graphfx-ui/src/lib/editorHelpers.ts | 23 +- graphfx-ui/src/lib/graphRuntime.ts | 8 +- graphfx-ui/src/lib/graphfxToVueFlow.ts | 69 ++- graphfx-ui/src/lib/imageLibrary.ts | 53 ++ graphfx-ui/src/lib/layout.ts | 16 +- graphfx-ui/src/lib/nodeDefinitions.ts | 29 +- graphfx-ui/src/lib/sampleGraph.ts | 16 +- graphfx-ui/src/lib/testGraph.ts | 57 ++ graphfx-ui/src/style.css | 614 +++++++++--------- 13 files changed, 1151 insertions(+), 659 deletions(-) create mode 100644 graphfx-ui/.dockerignore create mode 100644 graphfx-ui/src/lib/imageLibrary.ts create mode 100644 graphfx-ui/src/lib/testGraph.ts diff --git a/.dockerignore b/.dockerignore index c9ee9c6..c6cbb9a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,8 +1,6 @@ -node_modules -graphfx-ui/node_modules -graphfx-ui/dist -graphfx-ui/dist-ssr +**/node_modules +**/dist .git .gitignore npm-debug.log* -.DS_Store +**/.vite diff --git a/graphfx-ui/.dockerignore b/graphfx-ui/.dockerignore new file mode 100644 index 0000000..c20931d --- /dev/null +++ b/graphfx-ui/.dockerignore @@ -0,0 +1,8 @@ +node_modules +dist +.git +.gitignore +Dockerfile +README.md +npm-debug.log* +.vite diff --git a/graphfx-ui/src/App.vue b/graphfx-ui/src/App.vue index 5177ebe..4f3b295 100644 --- a/graphfx-ui/src/App.vue +++ b/graphfx-ui/src/App.vue @@ -9,33 +9,37 @@ import '@vue-flow/controls/dist/style.css' import GraphFxNode from './components/GraphFxNode.vue' import { countSavedPositions, graphFxToVueFlow, parseGraphFxJson, type GraphFxFlowWarning, type GraphFxSerializedGraph } from './lib/graphfxToVueFlow' import { layoutGraph } from './lib/layout' -import { coerceIoValue, createSerializedNode, displayIoValue, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers' +import { coerceIoValue, createSerializedNode, nodeCatalog, removeNodeAndConnections } from './lib/editorHelpers' +import { ioTypeFor } from './lib/nodeDefinitions' import { sampleGraph } from './lib/sampleGraph' +import { imageTestGraph } from './lib/testGraph' +import { filesToStoredImages, loadHtmlImage, loadImageLibrary, saveImageLibrary, type StoredImage } from './lib/imageLibrary' import apiExampleJson from '../examples/api.json?raw' import { createRuntimeGraph, destroyRuntimeGraph, setImageInput, subscribeImageOutput, - type LabeledImageInput, type LabeledImageOutput, type RuntimeState, } from './lib/graphRuntime' -const editorText = ref(JSON.stringify(sampleGraph, null, 2)) +const IMAGE_SELECTION_KEY = 'GRAPHFX_UI_IMAGE_SELECTIONS' + +const editorText = ref(JSON.stringify(imageTestGraph, null, 2)) const parseError = ref(null) -const graph = ref(sampleGraph) +const graph = ref(imageTestGraph) const nodes = ref([]) const edges = ref([]) const warnings = ref([]) -const useSavedPositions = ref(true) -const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'My first graph') +const selectedSaveSlot = ref(localStorage.getItem('GRAPHFX_UI_SELECTED_SAVE_SLOT') || 'Image test graph') const fileInput = ref(null) +const imageFileInput = ref(null) const sidebarCollapsed = ref(false) const activeTab = ref<'control' | 'data'>('control') -const selectedNodeId = ref(null) const nodeSearch = ref('') -const { fitView } = useVueFlow() +const addMenu = ref<{ x: number; y: number; flowX: number; flowY: number } | null>(null) +const { fitView, screenToFlowCoordinate } = useVueFlow() const runtimeState = ref(null) const runtimeStatus = ref('Graph not running') @@ -43,22 +47,19 @@ const runtimeError = ref(null) const selectedOutputKey = ref('') const outputPreviewUrl = ref(null) let unsubscribeOutput: (() => void) | null = null +let unsubscribeRuntimeOutputPreviews: Array<() => void> = [] -const webcamVideo = ref(null) -const webcamStream = ref(null) -const webcamError = ref(null) -const liveInputKeys = ref(new Set()) -const liveFrames = new Map() +const runtimeImagePreviews = ref>({}) +const imageLibrary = ref(loadImageLibrary()) +const selectedImageByInput = ref>(loadImageSelections()) const labeledImageInputs = computed(() => runtimeState.value?.inputs || []) const labeledImageOutputs = computed(() => runtimeState.value?.outputs || []) -const selectedGraphNode = computed(() => graph.value.find((item) => item.node.id === selectedNodeId.value) || null) -const selectedFlowNode = computed(() => nodes.value.find((node) => node.id === selectedNodeId.value) || null) +const imageOptions = computed(() => imageLibrary.value.map(({ id, name }) => ({ id, name }))) const filteredNodeCatalog = computed(() => { const query = nodeSearch.value.trim().toLowerCase() return nodeCatalog.filter((item) => !query || item.name.toLowerCase().includes(query)).slice(0, 80) }) -const firstCatalogMatch = computed(() => filteredNodeCatalog.value[0] || null) const stats = computed(() => ({ nodes: nodes.value.length, @@ -67,20 +68,114 @@ const stats = computed(() => ({ warnings: warnings.value.length, })) -function refreshFlow() { +const nodeActions = { + renameNodeId, + updateIoLabel, + updateInputValue, + disconnectInput, + deleteNode, + duplicateNode, + selectInputImage, +} + +function loadImageSelections() { + try { + const raw = localStorage.getItem(IMAGE_SELECTION_KEY) + const parsed = raw ? JSON.parse(raw) : {} + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : {} + } catch { + return {} + } +} + +function saveImageSelections() { + localStorage.setItem(IMAGE_SELECTION_KEY, JSON.stringify(selectedImageByInput.value)) +} + +function selectedImagesForNode(nodeId: string) { + const result: Record = {} + const prefix = `${nodeId}:` + for (const [key, imageId] of Object.entries(selectedImageByInput.value)) { + if (key.startsWith(prefix)) result[key.slice(prefix.length)] = imageId + } + return result +} + +function selectedImageDataUrl(imageId: string | undefined) { + return imageLibrary.value.find((image) => image.id === imageId)?.dataUrl || '' +} + +function previewKey(direction: 'in' | 'out', nodeId: string, ioName: string) { + return `${direction}:${nodeId}:${ioName}` +} + +function outputPreviewForSerializedId(outputId?: string | null) { + if (!outputId) return '' + for (const entry of graph.value) { + for (const outputName of Object.keys(entry.node.options?.out || {})) { + if (outputId === `${entry.node.id}-out-${outputName}` || outputId === `${entry.node.id}-${outputName}`) { + return runtimeImagePreviews.value[previewKey('out', entry.node.id, outputName)] || '' + } + } + } + return '' +} + +function imagePreviewsForNode(node: Node) { + const result: Record = {} + for (const input of node.data?.inputs || []) { + const selectedUrl = selectedImageDataUrl(selectedImageByInput.value[`${node.id}:${input.name}`]) + const connectedUrl = outputPreviewForSerializedId(input.output) + const runtimeUrl = runtimeImagePreviews.value[previewKey('in', node.id, input.name)] + const url = connectedUrl || selectedUrl || runtimeUrl || input.previewUrl || '' + if (url) result[`in:${input.name}`] = url + } + for (const output of node.data?.outputs || []) { + const url = runtimeImagePreviews.value[previewKey('out', node.id, output.name)] || output.previewUrl || '' + if (url) result[`out:${output.name}`] = url + } + return result +} + +function decorateNodes(nodeList: Node[]) { + return nodeList.map((node) => ({ + ...node, + data: { + ...node.data, + actions: nodeActions, + imageOptions: imageOptions.value, + selectedImages: selectedImagesForNode(node.id), + imagePreviews: imagePreviewsForNode(node), + runtimeReady: Boolean(runtimeState.value?.graph), + }, + })) +} + +function refreshFlow(options: { fit?: boolean; layout?: boolean } = {}) { try { const parsed = parseGraphFxJson(editorText.value) const model = graphFxToVueFlow(parsed) graph.value = parsed - if (selectedNodeId.value && !parsed.some((item) => item.node.id === selectedNodeId.value)) selectedNodeId.value = null - nodes.value = layoutGraph(model.nodes, model.edges, { - direction: 'LR', - useSavedPositions: useSavedPositions.value, - }) + const flowNodes = options.layout + ? layoutGraph(model.nodes, model.edges, { direction: 'LR', useSavedPositions: false }) + : model.nodes + + if (options.layout) { + for (const flowNode of flowNodes) { + const entry = graph.value.find((item) => item.node.id === flowNode.id) + if (entry) { + entry.x = Math.round(flowNode.position.x) + entry.y = Math.round(flowNode.position.y) + } + } + syncEditorFromGraph() + } + + nodes.value = decorateNodes(flowNodes) edges.value = model.edges warnings.value = model.warnings parseError.value = null - nextTick(() => fitView({ padding: 0.18, duration: 250 })) + if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 })) } catch (error) { parseError.value = error instanceof Error ? error.message : String(error) } @@ -90,106 +185,116 @@ function syncEditorFromGraph() { editorText.value = JSON.stringify(graph.value, null, 2) } -function refreshFromGraph(options: { fit?: boolean } = {}) { +function refreshFromGraph(options: { fit?: boolean; layout?: boolean } = {}) { syncEditorFromGraph() - refreshFlow() - if (options.fit) nextTick(() => fitView({ padding: 0.18, duration: 250 })) + refreshFlow(options) + if (options.fit) nextTick(() => fitView({ padding: 0.2, duration: 250 })) +} + +function applyAutoLayout() { + refreshFromGraph({ layout: true, fit: true }) +} + +function fitGraph() { + nextTick(() => fitView({ padding: 0.22, duration: 260 })) +} + +function findGraphNode(nodeId: string) { + return graph.value.find((item) => item.node.id === nodeId) || null } function defaultNewNodePosition() { const maxX = Math.max(0, ...graph.value.map((item) => typeof item.x === 'number' ? item.x : 0)) const maxY = Math.max(0, ...graph.value.map((item) => typeof item.y === 'number' ? item.y : 0)) - return { x: maxX + 340, y: Math.max(40, maxY + 40) } + return { x: maxX + 420, y: Math.max(40, maxY + 40) } } -function addNode(name: string) { +function addNode(name: string, position = defaultNewNodePosition()) { try { - const node = createSerializedNode(name, defaultNewNodePosition()) + const node = createSerializedNode(name, position) graph.value = [...graph.value, node] - selectedNodeId.value = node.node.id refreshFromGraph({ fit: true }) - activeTab.value = 'control' + closeAddMenu() } catch (error) { parseError.value = error instanceof Error ? error.message : String(error) } } -function addFirstCatalogMatch() { - if (firstCatalogMatch.value) addNode(firstCatalogMatch.value.name) -} - -function deleteSelectedNode() { - if (!selectedNodeId.value) return - graph.value = removeNodeAndConnections(graph.value, selectedNodeId.value) - selectedNodeId.value = null +function deleteNode(nodeId: string) { + graph.value = removeNodeAndConnections(graph.value, nodeId) + for (const key of Object.keys(selectedImageByInput.value)) { + if (key.startsWith(`${nodeId}:`)) delete selectedImageByInput.value[key] + } + saveImageSelections() refreshFromGraph() } -function duplicateSelectedNode() { - const source = selectedGraphNode.value +function duplicateNode(nodeId: string) { + const source = findGraphNode(nodeId) if (!source) return const clone = JSON.parse(JSON.stringify(source)) as typeof source clone.node.id = crypto.randomUUID() - clone.x = (typeof source.x === 'number' ? source.x : 0) + 60 - clone.y = (typeof source.y === 'number' ? source.y : 0) + 60 + clone.x = (typeof source.x === 'number' ? source.x : 0) + 80 + clone.y = (typeof source.y === 'number' ? source.y : 0) + 80 for (const input of Object.values(clone.node.options?.in || {})) input.output = null graph.value = [...graph.value, clone] - selectedNodeId.value = clone.node.id refreshFromGraph({ fit: true }) } -function renameSelectedNodeId(nextId: string) { - const item = selectedGraphNode.value - const oldId = item?.node.id +function renameNodeId(nodeId: string, nextId: string) { + const item = findGraphNode(nodeId) const clean = nextId.trim() - if (!item || !oldId || !clean || clean === oldId) return + if (!item || !clean || clean === nodeId) return if (graph.value.some((entry) => entry.node.id === clean)) { parseError.value = `A node with id "${clean}" already exists.` return } + item.node.id = clean - const oldPrefix = `${oldId}-out-` + const oldCanonical = `${nodeId}-out-` + const newCanonical = `${clean}-out-` + const oldCompat = `${nodeId}-` + const newCompat = `${clean}-` for (const entry of graph.value) { for (const input of Object.values(entry.node.options?.in || {})) { - if (input.output?.startsWith(oldPrefix)) input.output = input.output.replace(oldPrefix, `${clean}-out-`) + if (input.output?.startsWith(oldCanonical)) input.output = input.output.replace(oldCanonical, newCanonical) + else if (input.output?.startsWith(oldCompat)) input.output = input.output.replace(oldCompat, newCompat) } } - selectedNodeId.value = clean + + const nextSelections: Record = {} + for (const [key, value] of Object.entries(selectedImageByInput.value)) { + nextSelections[key.startsWith(`${nodeId}:`) ? `${clean}:${key.slice(nodeId.length + 1)}` : key] = value + } + selectedImageByInput.value = nextSelections + saveImageSelections() refreshFromGraph() } -function updateIoLabel(direction: 'in' | 'out', ioName: string, label: string) { - const io = selectedGraphNode.value?.node.options?.[direction]?.[ioName] +function updateIoLabel(nodeId: string, direction: 'in' | 'out', ioName: string, label: string) { + const io = findGraphNode(nodeId)?.node.options?.[direction]?.[ioName] if (!io) return io.label = label.trim() || null refreshFromGraph() } -function updateInputValue(ioName: string, rawValue: string) { - const input = selectedGraphNode.value?.node.options?.in?.[ioName] - const port = selectedFlowNode.value?.data?.inputs?.find((item) => item.name === ioName) - if (!input || !port) return - input.value = coerceIoValue(rawValue, port.type) +function updateInputValue(nodeId: string, ioName: string, rawValue: string | boolean) { + const item = findGraphNode(nodeId) + const input = item?.node.options?.in?.[ioName] + if (!item || !input) return + const type = ioTypeFor(item.node.name, 'in', ioName, input.type || input.definition?.type) + input.value = coerceIoValue(String(rawValue), type) input.output = null refreshFromGraph() } -function disconnectInput(ioName: string) { - const input = selectedGraphNode.value?.node.options?.in?.[ioName] +function disconnectInput(nodeId: string, ioName: string) { + const input = findGraphNode(nodeId)?.node.options?.in?.[ioName] if (!input) return input.output = null refreshFromGraph() } -function clearSelection() { - selectedNodeId.value = null -} - -function onNodeClick(payload: unknown, maybeNode?: Node) { - const node = (maybeNode || (payload as { node?: Node })?.node || payload) as Node | undefined - if (node?.id) selectedNodeId.value = node.id -} - function parseOutputHandle(handle?: string | null) { const match = handle?.match(/^(.*):out:(.*)$/) return match ? { nodeId: match[1], outputName: match[2] } : null @@ -234,12 +339,39 @@ function onEdgeDoubleClick(payload: unknown, maybeEdge?: Edge) { updateInputConnection(target.nodeId, target.inputName, null) } +function openAddMenu(event: MouseEvent) { + const target = event.target as HTMLElement | null + if (target?.closest('.graphfx-node') || target?.closest('.context-menu')) return + const point = screenToFlowCoordinate({ x: event.clientX, y: event.clientY }) + const menuWidth = 350 + const menuHeight = Math.min(540, window.innerHeight - 24) + const x = Math.max(12, Math.min(event.clientX, window.innerWidth - menuWidth - 12)) + const y = Math.max(12, Math.min(event.clientY, window.innerHeight - menuHeight - 12)) + addMenu.value = { x, y, flowX: point.x, flowY: point.y } + nodeSearch.value = '' +} + +function closeAddMenu() { + addMenu.value = null +} + +function addNodeFromMenu(name: string) { + const pos = addMenu.value ? { x: addMenu.value.flowX, y: addMenu.value.flowY } : defaultNewNodePosition() + addNode(name, pos) +} + function loadSample() { editorText.value = JSON.stringify(sampleGraph, null, 2) selectedSaveSlot.value = 'sample' refreshFlow() } +function loadImageTestGraph() { + editorText.value = JSON.stringify(imageTestGraph, null, 2) + selectedSaveSlot.value = 'Image test graph' + refreshFlow() +} + function loadApiExample() { editorText.value = JSON.stringify(parseGraphFxJson(apiExampleJson), null, 2) selectedSaveSlot.value = 'api' @@ -295,102 +427,10 @@ function loadJsonFromEditor() { if (!parseError.value) activeTab.value = 'control' } -function stopAllLiveInputs() { - for (const frame of liveFrames.values()) cancelAnimationFrame(frame) - liveFrames.clear() - liveInputKeys.value = new Set() -} - -function stopLiveInput(key: string) { - const frame = liveFrames.get(key) - if (frame) cancelAnimationFrame(frame) - liveFrames.delete(key) - const next = new Set(liveInputKeys.value) - next.delete(key) - liveInputKeys.value = next -} - -async function runGraph() { - refreshFlow() - if (parseError.value) return - - stopAllLiveInputs() - unsubscribeOutput?.() - unsubscribeOutput = null - outputPreviewUrl.value = null - destroyRuntimeGraph(runtimeState.value) - runtimeState.value = null - runtimeError.value = null - runtimeStatus.value = 'Starting graph…' - - try { - runtimeState.value = await createRuntimeGraph(graph.value) - runtimeStatus.value = `Running: ${runtimeState.value.inputs.length} labeled image inputs, ${runtimeState.value.outputs.length} labeled image outputs` - selectedOutputKey.value = runtimeState.value.outputs[0]?.key || '' - subscribeSelectedOutput() - } catch (error) { - runtimeError.value = error instanceof Error ? error.message : String(error) - runtimeStatus.value = 'Graph failed to start' - } -} - -async function openWebcam() { - if (webcamStream.value) return - webcamError.value = null - try { - const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false }) - webcamStream.value = stream - if (webcamVideo.value) { - webcamVideo.value.srcObject = stream - await webcamVideo.value.play() - } - } catch (error) { - webcamError.value = error instanceof Error ? error.message : String(error) - } -} - -function closeWebcam() { - stopAllLiveInputs() - webcamStream.value?.getTracks().forEach((track) => track.stop()) - webcamStream.value = null - if (webcamVideo.value) webcamVideo.value.srcObject = null -} - function outputByKey(key: string): LabeledImageOutput | undefined { return labeledImageOutputs.value.find((output) => output.key === key) } -function pushWebcamFrame(input: LabeledImageInput) { - if (!runtimeState.value?.graph || !webcamVideo.value) return - if (!webcamVideo.value.videoWidth || !webcamVideo.value.videoHeight) return - setImageInput(runtimeState.value.graph, input, webcamVideo.value) -} - -async function snapshotInput(input: LabeledImageInput) { - await openWebcam() - stopLiveInput(input.key) - pushWebcamFrame(input) -} - -async function toggleLiveInput(input: LabeledImageInput) { - if (liveInputKeys.value.has(input.key)) { - stopLiveInput(input.key) - return - } - - await openWebcam() - const next = new Set(liveInputKeys.value) - next.add(input.key) - liveInputKeys.value = next - - const tick = () => { - if (!liveInputKeys.value.has(input.key)) return - pushWebcamFrame(input) - liveFrames.set(input.key, requestAnimationFrame(tick)) - } - tick() -} - function subscribeSelectedOutput() { unsubscribeOutput?.() unsubscribeOutput = null @@ -403,13 +443,146 @@ function subscribeSelectedOutput() { }) } -watch(useSavedPositions, refreshFlow) +function unsubscribeRuntimePreviewOutputs() { + for (const unsubscribe of unsubscribeRuntimeOutputPreviews) unsubscribe() + unsubscribeRuntimeOutputPreviews = [] +} + +function subscribeRuntimePreviewOutputs() { + unsubscribeRuntimePreviewOutputs() + if (!runtimeState.value?.graph) return + unsubscribeRuntimeOutputPreviews = runtimeState.value.outputs.map((output) => subscribeImageOutput(runtimeState.value!.graph!, output, (url) => { + const key = previewKey('out', output.nodeId, output.ioName) + const next = { ...runtimeImagePreviews.value } + if (url) next[key] = url + else delete next[key] + runtimeImagePreviews.value = next + refreshFlow() + })) +} + +async function runGraph() { + refreshFlow() + if (parseError.value) return + + unsubscribeOutput?.() + unsubscribeOutput = null + unsubscribeRuntimePreviewOutputs() + outputPreviewUrl.value = null + runtimeImagePreviews.value = {} + destroyRuntimeGraph(runtimeState.value) + runtimeState.value = null + runtimeError.value = null + runtimeStatus.value = 'Starting graph…' + + try { + runtimeState.value = await createRuntimeGraph(graph.value) + runtimeStatus.value = `Preview ready: ${runtimeState.value.inputs.length} image inputs, ${runtimeState.value.outputs.length} image outputs` + selectedOutputKey.value = runtimeState.value.outputs[0]?.key || '' + subscribeRuntimePreviewOutputs() + refreshFlow() + subscribeSelectedOutput() + await applySelectedImagesToRuntime() + } catch (error) { + runtimeError.value = error instanceof Error ? error.message : String(error) + runtimeStatus.value = 'Graph failed to start' + refreshFlow() + } +} + +async function applySelectedImagesToRuntime() { + if (!runtimeState.value?.graph) return + const selected = Object.entries(selectedImageByInput.value) + let applied = 0 + for (const [inputKey, imageId] of selected) { + const input = runtimeState.value.inputs.find((item) => `${item.nodeId}:${item.ioName}` === inputKey) + const image = imageLibrary.value.find((item) => item.id === imageId) + if (!input || !image) continue + const htmlImage = await loadHtmlImage(image.dataUrl) + setImageInput(runtimeState.value.graph, input, htmlImage) + runtimeImagePreviews.value = { + ...runtimeImagePreviews.value, + [previewKey('in', input.nodeId, input.ioName)]: image.dataUrl, + } + applied += 1 + } + if (applied) { + runtimeStatus.value = `Preview updated: applied ${applied} stored image input${applied === 1 ? '' : 's'}` + refreshFlow() + } +} + +async function selectInputImage(nodeId: string, ioName: string, imageId: string) { + selectedImageByInput.value = { ...selectedImageByInput.value, [`${nodeId}:${ioName}`]: imageId } + saveImageSelections() + refreshFlow() + if (!runtimeState.value?.graph) await runGraph() + else await applySelectedImagesToRuntime() +} + +async function importImages(event: Event) { + const input = event.target as HTMLInputElement + if (!input.files?.length) return + const imported = await filesToStoredImages(input.files) + imageLibrary.value = [...imported, ...imageLibrary.value] + saveImageLibrary(imageLibrary.value) + input.value = '' + refreshFlow() +} + +function removeImage(imageId: string) { + imageLibrary.value = imageLibrary.value.filter((image) => image.id !== imageId) + for (const [key, selectedId] of Object.entries(selectedImageByInput.value)) { + if (selectedId === imageId) delete selectedImageByInput.value[key] + } + saveImageLibrary(imageLibrary.value) + saveImageSelections() + refreshFlow() +} + +function makeTestImage(name: string, background: string, accent: string) { + const canvas = document.createElement('canvas') + canvas.width = 900 + canvas.height = 600 + const ctx = canvas.getContext('2d')! + const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height) + gradient.addColorStop(0, background) + gradient.addColorStop(1, '#060914') + ctx.fillStyle = gradient + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.fillStyle = accent + ctx.beginPath() + ctx.arc(680, 180, 130, 0, Math.PI * 2) + ctx.fill() + ctx.strokeStyle = 'rgba(255,255,255,.65)' + ctx.lineWidth = 18 + ctx.strokeRect(70, 70, 760, 460) + ctx.fillStyle = '#ffffff' + ctx.font = '900 72px system-ui, sans-serif' + ctx.fillText(name, 115, 300) + ctx.font = '700 34px system-ui, sans-serif' + ctx.fillText('GraphFX local image input', 118, 355) + return canvas.toDataURL('image/png') +} + +function addGeneratedTestImages() { + const existingNames = new Set(imageLibrary.value.map((image) => image.name)) + const generated: StoredImage[] = [ + { id: crypto.randomUUID(), name: 'test-neon-blue.png', dataUrl: makeTestImage('BLUE', '#1246ff', '#37d5ff'), createdAt: Date.now() }, + { id: crypto.randomUUID(), name: 'test-warm-magenta.png', dataUrl: makeTestImage('MAGENTA', '#af1b6d', '#ff78b7'), createdAt: Date.now() }, + { id: crypto.randomUUID(), name: 'test-green-room.png', dataUrl: makeTestImage('GREEN', '#067a58', '#33e1ba'), createdAt: Date.now() }, + ].filter((image) => !existingNames.has(image.name)) + if (!generated.length) return + imageLibrary.value = [...generated, ...imageLibrary.value] + saveImageLibrary(imageLibrary.value) + refreshFlow() +} + watch(selectedOutputKey, subscribeSelectedOutput) onBeforeUnmount(() => { - stopAllLiveInputs() - closeWebcam() unsubscribeOutput?.() + unsubscribeRuntimePreviewOutputs() destroyRuntimeGraph(runtimeState.value) }) @@ -417,8 +590,8 @@ refreshFlow()