From 95900fd1a39de3ba8ea0ce28eaa61527d78a4d35 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 13 Aug 2026 17:33:01 -0500 Subject: [PATCH 1/7] feat(frontend): export workflow as PNG --- .../features/Workflows/editor-interface.mdx | 3 + invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 8 + invokeai/frontend/web/public/locales/en.json | 2 + .../flow/nodes/Connector/ConnectorNode.tsx | 8 +- .../BottomLeftPanel/ViewportControls.tsx | 31 ++- .../nodes/util/workflowImageExport.test.ts | 28 +++ .../nodes/util/workflowImageExport.ts | 205 ++++++++++++++++++ 8 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx index bce33485ad6..69351a378cf 100644 --- a/docs/src/content/docs/features/Workflows/editor-interface.mdx +++ b/docs/src/content/docs/features/Workflows/editor-interface.mdx @@ -47,6 +47,9 @@ If you're not familiar with Diffusion, take a look at our [Diffusion Overview](. Nodes have a **"Use Cache"** option in their footer. This allows for performance improvements by reusing previously cached values during workflow processing. + + Click the camera button in the workflow editor controls to download a 2x PNG of the complete workflow. The image includes connectors, the background grid, and the selected Invoke color scheme, with nodes shown deselected and the MiniMap omitted. + ### Managing Nodes diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 58050826024..8b3b63ae8e5 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -62,6 +62,7 @@ "filesize": "^10.1.6", "fracturedjsonjs": "^4.1.1", "framer-motion": "^11.18.2", + "html-to-image": "^1.11.13", "i18next": "^25.7.3", "i18next-http-backend": "^3.0.2", "idb-keyval": "6.2.1", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index 6488e7dd0ec..968212d94a9 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: framer-motion: specifier: ^11.18.2 version: 11.18.2(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + html-to-image: + specifier: ^1.11.13 + version: 1.11.13 i18next: specifier: ^25.7.3 version: 25.7.3(typescript@5.9.3) @@ -3137,6 +3140,9 @@ packages: html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -8096,6 +8102,8 @@ snapshots: dependencies: void-elements: 3.1.0 + html-to-image@1.11.13: {} + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index c2a4853cc20..f9a78270ece 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1550,6 +1550,8 @@ "currentImageDescription": "Displays the current image in the Node Editor", "downloadWorkflow": "Download Workflow JSON", "downloadWorkflowError": "Error downloading workflow", + "downloadWorkflowImage": "Download Workflow Image", + "downloadWorkflowImageError": "Error downloading workflow image", "edge": "Edge", "edit": "Edit", "editMode": "Edit in Workflow Editor", diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx index bc6867a9b9c..8f5a515b3e5 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Connector/ConnectorNode.tsx @@ -120,9 +120,15 @@ const ConnectorNode = ({ id, selected }: NodeProps>) => justifyContent="center" borderRadius="full" bg={selected ? 'base.650' : 'base.700'} + data-connector-node-body="true" > - + { const { t } = useTranslation(); - const { zoomIn, zoomOut, fitView } = useReactFlow(); + const { zoomIn, zoomOut, fitView, getNodes, getNodesBounds } = useReactFlow(); const dispatch = useAppDispatch(); const shouldShowMinimapPanel = useAppSelector(selectShouldShowMinimapPanel); + const workflowName = useAppSelector(selectWorkflowName); const handleClickedZoomIn = useCallback(() => { zoomIn({ duration: 300 }); @@ -38,6 +43,24 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, dispatch]); + const handleWorkflowImageExportError = useCallback(() => { + toast({ id: 'DOWNLOAD_WORKFLOW_IMAGE_ERROR', status: 'error', description: t('nodes.downloadWorkflowImageError') }); + }, [t]); + + const handleClickedExportWorkflow = useCallback(() => { + const flowElement = document.querySelector('#workflow-editor'); + if (!flowElement) { + handleWorkflowImageExportError(); + return; + } + + void exportWorkflowAsPng({ + flowElement, + bounds: getNodesBounds(getNodes()), + workflowName, + }).catch(handleWorkflowImageExportError); + }, [getNodes, getNodesBounds, handleWorkflowImageExportError, workflowName]); + return ( { icon={} /> + } + /> { + it('uses padded logical bounds and export scale for output dimensions', () => { + expect(getWorkflowImageDimensions({ x: -100, y: 50, width: 1600, height: 900 })).toEqual({ + width: 1600 + EXPORT_PADDING * 2, + height: 900 + EXPORT_PADDING * 2, + canvasWidth: (1600 + EXPORT_PADDING * 2) * EXPORT_SCALE, + canvasHeight: (900 + EXPORT_PADDING * 2) * EXPORT_SCALE, + }); + }); + + it('keeps ordinary workflow names unchanged', () => { + expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage')).toBe('Hi-Res Two Stage'); + }); + + it('replaces filesystem-invalid characters and falls back for blank names', () => { + expect(sanitizeWorkflowImageFilename('Workflow: 01 / test?')).toBe('Workflow- 01 - test-'); + expect(sanitizeWorkflowImageFilename(' ... ')).toBe('My Workflow'); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts new file mode 100644 index 00000000000..1d7a4a0bb79 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -0,0 +1,205 @@ +import type { Rect } from '@xyflow/react'; +import { toPng } from 'html-to-image'; + +export const EXPORT_PADDING = 100; +export const EXPORT_SCALE = 2; +const GRID_GAP = 25; +const DEFAULT_WORKFLOW_IMAGE_FILENAME = 'My Workflow'; + +type WorkflowImageDimensions = { + width: number; + height: number; + canvasWidth: number; + canvasHeight: number; +}; + +const getPaddedWorkflowBounds = (bounds: Rect): Rect => ({ + x: bounds.x - EXPORT_PADDING, + y: bounds.y - EXPORT_PADDING, + width: bounds.width + EXPORT_PADDING * 2, + height: bounds.height + EXPORT_PADDING * 2, +}); + +const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: Rect): Rect => { + let minX = nodeBounds.x; + let minY = nodeBounds.y; + let maxX = nodeBounds.x + nodeBounds.width; + let maxY = nodeBounds.y + nodeBounds.height; + + flowElement.querySelectorAll('.react-flow__edge-path').forEach((path) => { + let pathBounds: DOMRect; + try { + pathBounds = path.getBBox(); + } catch { + return; + } + if ( + !Number.isFinite(pathBounds.x) || + !Number.isFinite(pathBounds.y) || + !Number.isFinite(pathBounds.width) || + !Number.isFinite(pathBounds.height) + ) { + return; + } + + minX = Math.min(minX, pathBounds.x); + minY = Math.min(minY, pathBounds.y); + maxX = Math.max(maxX, pathBounds.x + pathBounds.width); + maxY = Math.max(maxY, pathBounds.y + pathBounds.height); + }); + + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; +}; + +export const getWorkflowImageDimensions = (bounds: Rect): WorkflowImageDimensions => { + const paddedBounds = getPaddedWorkflowBounds(bounds); + + const width = Math.max(1, Math.ceil(paddedBounds.width)); + const height = Math.max(1, Math.ceil(paddedBounds.height)); + + return { width, height, canvasWidth: width * EXPORT_SCALE, canvasHeight: height * EXPORT_SCALE }; +}; + +export const sanitizeWorkflowImageFilename = (workflowName: string): string => { + const sanitizedName = workflowName + .replace(/[<>:"/\\|?*]/g, '-') + .split('') + .map((character) => (character.charCodeAt(0) < 32 ? '-' : character)) + .join('') + .trim() + .replace(/[. ]+$/g, ''); + + return sanitizedName || DEFAULT_WORKFLOW_IMAGE_FILENAME; +}; + +const setExportElementStyle = (element: HTMLElement, property: string, value: string) => { + element.style.setProperty(property, value, 'important'); +}; + +const setBackgroundGridForExport = (root: HTMLElement, translation: { x: number; y: number }) => { + const background = root.querySelector('.react-flow__background'); + const pattern = background?.querySelector('pattern'); + if (!background || !pattern) { + return; + } + + const patternId = `${pattern.id}-export`; + pattern.id = patternId; + pattern.setAttribute('width', `${GRID_GAP}`); + pattern.setAttribute('height', `${GRID_GAP}`); + pattern.setAttribute('x', `${((translation.x % GRID_GAP) + GRID_GAP) % GRID_GAP}`); + pattern.setAttribute('y', `${((translation.y % GRID_GAP) + GRID_GAP) % GRID_GAP}`); + pattern.setAttribute('patternTransform', `translate(-${GRID_GAP},-${GRID_GAP})`); + + const patternReference = `url(#${patternId})`; + background.querySelector('rect')?.setAttribute('fill', patternReference); + + const dot = pattern.querySelector('circle'); + dot?.setAttribute('cx', '0.5'); + dot?.setAttribute('cy', '0.5'); + dot?.setAttribute('r', '0.5'); +}; + +const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: WorkflowImageDimensions) => { + const root = clone.matches('.react-flow') ? clone : clone.querySelector('.react-flow'); + const viewport = clone.querySelector('.react-flow__viewport'); + if (!root || !viewport) { + throw new Error('Workflow editor DOM is missing React Flow viewport'); + } + + const translation = { + x: EXPORT_PADDING - bounds.x, + y: EXPORT_PADDING - bounds.y, + }; + + clone.style.width = `${dimensions.width}px`; + clone.style.height = `${dimensions.height}px`; + clone.style.position = 'absolute'; + clone.style.left = '-100000px'; + clone.style.top = '0'; + clone.style.pointerEvents = 'none'; + + root.style.width = `${dimensions.width}px`; + root.style.height = `${dimensions.height}px`; + viewport.style.transform = `translate(${translation.x}px, ${translation.y}px) scale(1)`; + setBackgroundGridForExport(root, translation); + + clone + .querySelectorAll('[data-is-selected], [data-selected], [data-are-connected-nodes-selected]') + .forEach((element) => { + if (element.hasAttribute('data-is-selected')) { + element.setAttribute('data-is-selected', 'false'); + } + if (element.hasAttribute('data-selected')) { + element.setAttribute('data-selected', 'false'); + } + if (element.hasAttribute('data-are-connected-nodes-selected')) { + element.setAttribute('data-are-connected-nodes-selected', 'false'); + } + }); + clone.querySelectorAll('.react-flow__node.selected, .react-flow__edge.selected').forEach((element) => { + element.classList.remove('selected'); + }); + clone.querySelectorAll('[data-connector-node-body="true"]').forEach((element) => { + setExportElementStyle(element, 'background-color', 'var(--invoke-colors-base-700)'); + }); + clone.querySelectorAll('[data-connector-node-icon="true"]').forEach((element) => { + setExportElementStyle(element, 'color', 'var(--invoke-colors-base-100)'); + }); + + clone + .querySelectorAll('.react-flow__edges, .react-flow__edges > svg, .react-flow__edge') + .forEach((element) => { + setExportElementStyle(element, 'z-index', '0'); + }); + clone + .querySelectorAll('.react-flow__edgelabel-renderer, .react-flow__edgelabel-renderer *') + .forEach((element) => { + setExportElementStyle(element, 'z-index', '0'); + }); + clone.querySelectorAll('.react-flow__nodes, .react-flow__node').forEach((element) => { + setExportElementStyle(element, 'z-index', '1'); + }); + clone.querySelectorAll('.react-flow__selection, .react-flow__nodesselection').forEach((element) => { + setExportElementStyle(element, 'display', 'none'); + }); +}; + +const downloadPng = (dataUrl: string, workflowName: string) => { + const anchor = document.createElement('a'); + anchor.download = `${sanitizeWorkflowImageFilename(workflowName)}.png`; + anchor.href = dataUrl; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); +}; + +export const exportWorkflowAsPng = async ({ + flowElement, + bounds, + workflowName, +}: { + flowElement: HTMLElement; + bounds: Rect; + workflowName: string; +}): Promise => { + const contentBounds = getWorkflowContentBounds(flowElement, bounds); + const dimensions = getWorkflowImageDimensions(contentBounds); + const clone = flowElement.cloneNode(true) as HTMLElement; + + prepareExportClone(clone, contentBounds, dimensions); + (flowElement.parentElement ?? document.body).appendChild(clone); + + try { + const dataUrl = await toPng(clone, { + width: dimensions.width, + height: dimensions.height, + canvasWidth: dimensions.canvasWidth, + canvasHeight: dimensions.canvasHeight, + pixelRatio: 1, + }); + downloadPng(dataUrl, workflowName); + } finally { + clone.remove(); + } +}; From 66bba8620f1960253a45c8e99eb0da536891b6df Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 13 Aug 2026 19:34:58 -0500 Subject: [PATCH 2/7] fix(frontend): make workflow image export opaque and show edges --- .../BottomLeftPanel/ViewportControls.tsx | 29 ++- .../nodes/util/workflowImageExport.test.ts | 72 ++++++- .../nodes/util/workflowImageExport.ts | 198 ++++++++++++++++-- 3 files changed, 272 insertions(+), 27 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 45087751ff6..87978dfb117 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -8,7 +8,7 @@ import { } from 'features/nodes/store/workflowSettingsSlice'; import { exportWorkflowAsPng } from 'features/nodes/util/workflowImageExport'; import { toast } from 'features/toast/toast'; -import { memo, useCallback } from 'react'; +import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCameraBold, @@ -26,6 +26,7 @@ const ViewportControls = () => { const dispatch = useAppDispatch(); const shouldShowMinimapPanel = useAppSelector(selectShouldShowMinimapPanel); const workflowName = useAppSelector(selectWorkflowName); + const [isExportingWorkflow, setIsExportingWorkflow] = useState(false); const handleClickedZoomIn = useCallback(() => { zoomIn({ duration: 300 }); @@ -48,18 +49,30 @@ const ViewportControls = () => { }, [t]); const handleClickedExportWorkflow = useCallback(() => { + if (isExportingWorkflow) { + return; + } + const flowElement = document.querySelector('#workflow-editor'); if (!flowElement) { handleWorkflowImageExportError(); return; } - void exportWorkflowAsPng({ - flowElement, - bounds: getNodesBounds(getNodes()), - workflowName, - }).catch(handleWorkflowImageExportError); - }, [getNodes, getNodesBounds, handleWorkflowImageExportError, workflowName]); + setIsExportingWorkflow(true); + void new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }) + .then(() => + exportWorkflowAsPng({ + flowElement, + bounds: getNodesBounds(getNodes()), + workflowName, + }) + ) + .catch(handleWorkflowImageExportError) + .finally(() => setIsExportingWorkflow(false)); + }, [getNodes, getNodesBounds, handleWorkflowImageExportError, isExportingWorkflow, workflowName]); return ( @@ -85,6 +98,8 @@ const ViewportControls = () => { } /> diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index 44462cb660d..43e2c297545 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -1,10 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { EXPORT_PADDING, EXPORT_SCALE, + EXPORT_STYLE_PROPERTIES, + getWorkflowExportCloneStyle, + getWorkflowExportOptions, + getWorkflowExportStagingStyle, getWorkflowImageDimensions, + getWorkflowSvgExportStyles, sanitizeWorkflowImageFilename, + setWorkflowExportNodeOpacity, + SVG_EXPORT_STYLE_PROPERTIES, } from './workflowImageExport'; describe('workflow image export', () => { @@ -17,6 +24,69 @@ describe('workflow image export', () => { }); }); + it('keeps capture clone local to an offscreen staging wrapper', () => { + const dimensions = getWorkflowImageDimensions({ x: 0, y: 0, width: 400, height: 300 }); + + expect(getWorkflowExportStagingStyle(dimensions)).toEqual({ + position: 'fixed', + left: '-100000px', + top: '0', + width: '600px', + height: '500px', + pointerEvents: 'none', + }); + expect(getWorkflowExportCloneStyle(dimensions)).toEqual({ + width: '600px', + height: '500px', + position: 'relative', + left: '0', + top: '0', + pointerEvents: 'none', + }); + }); + + it('uses Blob-friendly image export dimensions', () => { + const dimensions = getWorkflowImageDimensions({ x: 0, y: 0, width: 400, height: 300 }); + + expect(getWorkflowExportOptions(dimensions, 'rgb(1, 2, 3)')).toEqual({ + width: 600, + height: 500, + canvasWidth: 1200, + canvasHeight: 1000, + backgroundColor: 'rgb(1, 2, 3)', + pixelRatio: 1, + includeStyleProperties: [...EXPORT_STYLE_PROPERTIES], + skipFonts: true, + }); + }); + + it('extracts computed SVG edge styles for inline capture', () => { + const computedStyle = { + getPropertyValue: (property: string) => + ({ stroke: 'rgb(1, 2, 3)', 'stroke-width': '3px', fill: 'none' })[property] ?? '', + }; + + expect(getWorkflowSvgExportStyles(computedStyle)).toEqual({ + stroke: 'rgb(1, 2, 3)', + 'stroke-width': '3px', + fill: 'none', + }); + expect(SVG_EXPORT_STYLE_PROPERTIES).toContain('stroke'); + expect(SVG_EXPORT_STYLE_PROPERTIES).toContain('marker-end'); + }); + + it('makes node wrappers opaque regardless of the node opacity slider', () => { + const setProperty = vi.fn(); + const nodeWrapper = { style: { setProperty } } as unknown as HTMLElement; + const root = { + querySelectorAll: (selector: string) => (selector === '.react-flow__node > [data-is-selected]' ? [nodeWrapper] : []), + } as unknown as HTMLElement; + + setWorkflowExportNodeOpacity(root); + + expect(setProperty).toHaveBeenCalledWith('opacity', '1', 'important'); + }); + it('keeps ordinary workflow names unchanged', () => { expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage')).toBe('Hi-Res Two Stage'); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index 1d7a4a0bb79..4a503029872 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -1,8 +1,111 @@ import type { Rect } from '@xyflow/react'; -import { toPng } from 'html-to-image'; +import { toBlob } from 'html-to-image'; export const EXPORT_PADDING = 100; export const EXPORT_SCALE = 2; +export const EXPORT_STYLE_PROPERTIES = [ + 'box-sizing', + 'display', + 'position', + 'inset', + 'top', + 'right', + 'bottom', + 'left', + 'width', + 'height', + 'min-width', + 'min-height', + 'max-width', + 'max-height', + 'padding', + 'padding-top', + 'padding-right', + 'padding-bottom', + 'padding-left', + 'margin', + 'margin-top', + 'margin-right', + 'margin-bottom', + 'margin-left', + 'flex', + 'flex-direction', + 'flex-grow', + 'flex-shrink', + 'flex-basis', + 'align-items', + 'align-content', + 'align-self', + 'justify-content', + 'gap', + 'row-gap', + 'column-gap', + 'grid-template-columns', + 'grid-template-rows', + 'grid-column', + 'grid-row', + 'overflow', + 'overflow-x', + 'overflow-y', + 'visibility', + 'opacity', + 'transform', + 'transform-origin', + 'color', + 'background', + 'background-color', + 'background-image', + 'background-size', + 'background-position', + 'background-repeat', + 'background-clip', + 'background-origin', + 'border', + 'border-width', + 'border-style', + 'border-color', + 'border-radius', + 'box-shadow', + 'font-family', + 'font-size', + 'font-weight', + 'font-style', + 'line-height', + 'letter-spacing', + 'text-align', + 'text-decoration', + 'text-transform', + 'text-shadow', + 'white-space', + 'word-break', + 'overflow-wrap', + 'filter', + 'object-fit', + 'object-position', + 'fill', + 'stroke', + 'stroke-width', + 'stroke-linecap', + 'stroke-linejoin', + 'stroke-dasharray', + 'stroke-dashoffset', + 'z-index', + 'pointer-events', + 'vertical-align', +] as const; +export const SVG_EXPORT_STYLE_PROPERTIES = [ + ...EXPORT_STYLE_PROPERTIES, + 'fill-opacity', + 'stroke-opacity', + 'marker-start', + 'marker-mid', + 'marker-end', + 'paint-order', + 'shape-rendering', + 'vector-effect', + 'clip-path', + 'mask', +] as const; const GRID_GAP = 25; const DEFAULT_WORKFLOW_IMAGE_FILENAME = 'My Workflow'; @@ -60,6 +163,35 @@ export const getWorkflowImageDimensions = (bounds: Rect): WorkflowImageDimension return { width, height, canvasWidth: width * EXPORT_SCALE, canvasHeight: height * EXPORT_SCALE }; }; +export const getWorkflowExportCloneStyle = (dimensions: WorkflowImageDimensions) => ({ + width: `${dimensions.width}px`, + height: `${dimensions.height}px`, + position: 'relative', + left: '0', + top: '0', + pointerEvents: 'none', +}); + +export const getWorkflowExportOptions = (dimensions: WorkflowImageDimensions, backgroundColor: string) => ({ + width: dimensions.width, + height: dimensions.height, + canvasWidth: dimensions.canvasWidth, + canvasHeight: dimensions.canvasHeight, + backgroundColor, + pixelRatio: 1, + includeStyleProperties: [...EXPORT_STYLE_PROPERTIES], + skipFonts: true, +}); + +export const getWorkflowSvgExportStyles = (computedStyle: Pick) => + SVG_EXPORT_STYLE_PROPERTIES.reduce>((styles, property) => { + const value = computedStyle.getPropertyValue(property); + if (value) { + styles[property] = value; + } + return styles; + }, {}); + export const sanitizeWorkflowImageFilename = (workflowName: string): string => { const sanitizedName = workflowName .replace(/[<>:"/\\|?*]/g, '-') @@ -76,6 +208,15 @@ const setExportElementStyle = (element: HTMLElement, property: string, value: st element.style.setProperty(property, value, 'important'); }; +export const getWorkflowExportStagingStyle = (dimensions: WorkflowImageDimensions) => ({ + position: 'fixed', + left: '-100000px', + top: '0', + width: `${dimensions.width}px`, + height: `${dimensions.height}px`, + pointerEvents: 'none', +}); + const setBackgroundGridForExport = (root: HTMLElement, translation: { x: number; y: number }) => { const background = root.querySelector('.react-flow__background'); const pattern = background?.querySelector('pattern'); @@ -100,6 +241,25 @@ const setBackgroundGridForExport = (root: HTMLElement, translation: { x: number; dot?.setAttribute('r', '0.5'); }; +const inlineSvgStylesForExport = (root: HTMLElement) => { + root + .querySelectorAll( + '.react-flow__edges svg, .react-flow__edges svg *, .react-flow__background, .react-flow__background *' + ) + .forEach((element) => { + const styles = getWorkflowSvgExportStyles(getComputedStyle(element)); + Object.entries(styles).forEach(([property, value]) => { + element.style.setProperty(property, value, 'important'); + }); + }); +}; + +export const setWorkflowExportNodeOpacity = (root: HTMLElement) => { + root.querySelectorAll('.react-flow__node > [data-is-selected]').forEach((element) => { + setExportElementStyle(element, 'opacity', '1'); + }); +}; + const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: WorkflowImageDimensions) => { const root = clone.matches('.react-flow') ? clone : clone.querySelector('.react-flow'); const viewport = clone.querySelector('.react-flow__viewport'); @@ -112,15 +272,11 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl y: EXPORT_PADDING - bounds.y, }; - clone.style.width = `${dimensions.width}px`; - clone.style.height = `${dimensions.height}px`; - clone.style.position = 'absolute'; - clone.style.left = '-100000px'; - clone.style.top = '0'; - clone.style.pointerEvents = 'none'; + Object.assign(clone.style, getWorkflowExportCloneStyle(dimensions)); root.style.width = `${dimensions.width}px`; root.style.height = `${dimensions.height}px`; + setExportElementStyle(root, 'background-color', 'var(--invoke-colors-base-900)'); viewport.style.transform = `translate(${translation.x}px, ${translation.y}px) scale(1)`; setBackgroundGridForExport(root, translation); @@ -146,6 +302,7 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl clone.querySelectorAll('[data-connector-node-icon="true"]').forEach((element) => { setExportElementStyle(element, 'color', 'var(--invoke-colors-base-100)'); }); + setWorkflowExportNodeOpacity(clone); clone .querySelectorAll('.react-flow__edges, .react-flow__edges > svg, .react-flow__edge') @@ -165,13 +322,15 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl }); }; -const downloadPng = (dataUrl: string, workflowName: string) => { +const downloadPng = (blob: Blob, workflowName: string) => { + const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.download = `${sanitizeWorkflowImageFilename(workflowName)}.png`; - anchor.href = dataUrl; + anchor.href = objectUrl; document.body.appendChild(anchor); anchor.click(); anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); }; export const exportWorkflowAsPng = async ({ @@ -186,20 +345,21 @@ export const exportWorkflowAsPng = async ({ const contentBounds = getWorkflowContentBounds(flowElement, bounds); const dimensions = getWorkflowImageDimensions(contentBounds); const clone = flowElement.cloneNode(true) as HTMLElement; + const stagingWrapper = document.createElement('div'); prepareExportClone(clone, contentBounds, dimensions); - (flowElement.parentElement ?? document.body).appendChild(clone); + Object.assign(stagingWrapper.style, getWorkflowExportStagingStyle(dimensions)); + stagingWrapper.appendChild(clone); + (flowElement.parentElement ?? document.body).appendChild(stagingWrapper); try { - const dataUrl = await toPng(clone, { - width: dimensions.width, - height: dimensions.height, - canvasWidth: dimensions.canvasWidth, - canvasHeight: dimensions.canvasHeight, - pixelRatio: 1, - }); - downloadPng(dataUrl, workflowName); + inlineSvgStylesForExport(clone); + const blob = await toBlob(clone, getWorkflowExportOptions(dimensions, getComputedStyle(clone).backgroundColor)); + if (!blob) { + throw new Error('Workflow image export returned an empty Blob'); + } + downloadPng(blob, workflowName); } finally { - clone.remove(); + stagingWrapper.remove(); } }; From b2fba5b659a2c6018e8fbb4a0e3239e1759ebb47 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 13 Aug 2026 19:45:00 -0500 Subject: [PATCH 3/7] fix(frontend): hide node status in workflow images --- .../InvocationNodeStatusIndicator.tsx | 9 +++++++- .../nodes/util/workflowImageExport.test.ts | 23 ++++++++++++++++++- .../nodes/util/workflowImageExport.ts | 12 +++++++++- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx index a740a2ee3df..2786a127db3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeStatusIndicator.tsx @@ -30,7 +30,14 @@ const InvocationNodeStatusIndicator = ({ nodeId }: Props) => { return ( } placement="top"> - + diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index 43e2c297545..42dade2ff70 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -9,6 +9,7 @@ import { getWorkflowExportStagingStyle, getWorkflowImageDimensions, getWorkflowSvgExportStyles, + hideWorkflowExportStatusIndicators, sanitizeWorkflowImageFilename, setWorkflowExportNodeOpacity, SVG_EXPORT_STYLE_PROPERTIES, @@ -60,6 +61,12 @@ describe('workflow image export', () => { }); }); + it('preserves single-line field title styles in the export clone', () => { + expect(EXPORT_STYLE_PROPERTIES).toEqual( + expect.arrayContaining(['text-overflow', '-webkit-line-clamp', '-webkit-box-orient']) + ); + }); + it('extracts computed SVG edge styles for inline capture', () => { const computedStyle = { getPropertyValue: (property: string) => @@ -79,7 +86,8 @@ describe('workflow image export', () => { const setProperty = vi.fn(); const nodeWrapper = { style: { setProperty } } as unknown as HTMLElement; const root = { - querySelectorAll: (selector: string) => (selector === '.react-flow__node > [data-is-selected]' ? [nodeWrapper] : []), + querySelectorAll: (selector: string) => + selector === '.react-flow__node > [data-is-selected]' ? [nodeWrapper] : [], } as unknown as HTMLElement; setWorkflowExportNodeOpacity(root); @@ -87,6 +95,19 @@ describe('workflow image export', () => { expect(setProperty).toHaveBeenCalledWith('opacity', '1', 'important'); }); + it('hides node status indicators from the export clone', () => { + const setProperty = vi.fn(); + const statusIndicator = { style: { setProperty } } as unknown as HTMLElement; + const root = { + querySelectorAll: (selector: string) => + selector === '[data-node-status-indicator="true"]' ? [statusIndicator] : [], + } as unknown as HTMLElement; + + hideWorkflowExportStatusIndicators(root); + + expect(setProperty).toHaveBeenCalledWith('display', 'none', 'important'); + }); + it('keeps ordinary workflow names unchanged', () => { expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage')).toBe('Hi-Res Two Stage'); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index 4a503029872..506980d72fd 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -73,9 +73,12 @@ export const EXPORT_STYLE_PROPERTIES = [ 'line-height', 'letter-spacing', 'text-align', + 'text-overflow', 'text-decoration', 'text-transform', 'text-shadow', + '-webkit-line-clamp', + '-webkit-box-orient', 'white-space', 'word-break', 'overflow-wrap', @@ -251,7 +254,7 @@ const inlineSvgStylesForExport = (root: HTMLElement) => { Object.entries(styles).forEach(([property, value]) => { element.style.setProperty(property, value, 'important'); }); - }); + }); }; export const setWorkflowExportNodeOpacity = (root: HTMLElement) => { @@ -260,6 +263,12 @@ export const setWorkflowExportNodeOpacity = (root: HTMLElement) => { }); }; +export const hideWorkflowExportStatusIndicators = (root: HTMLElement) => { + root.querySelectorAll('[data-node-status-indicator="true"]').forEach((element) => { + setExportElementStyle(element, 'display', 'none'); + }); +}; + const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: WorkflowImageDimensions) => { const root = clone.matches('.react-flow') ? clone : clone.querySelector('.react-flow'); const viewport = clone.querySelector('.react-flow__viewport'); @@ -303,6 +312,7 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl setExportElementStyle(element, 'color', 'var(--invoke-colors-base-100)'); }); setWorkflowExportNodeOpacity(clone); + hideWorkflowExportStatusIndicators(clone); clone .querySelectorAll('.react-flow__edges, .react-flow__edges > svg, .react-flow__edge') From 5c1aa654f9ea8e0401db742c7dd8c40975502a36 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 13 Aug 2026 19:53:42 -0500 Subject: [PATCH 4/7] fix(frontend): clean up workflow image labels --- .../Invocation/InvocationNodeInfoIcon.tsx | 2 +- .../Invocation/fields/InputFieldTitle.tsx | 1 + .../nodes/util/workflowImageExport.test.ts | 29 +++++++++++++++++++ .../nodes/util/workflowImageExport.ts | 19 +++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx index a257326f929..9368073f19a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeInfoIcon.tsx @@ -15,7 +15,7 @@ interface Props { export const InvocationNodeInfoIcon = memo(({ nodeId }: Props) => { return ( } placement="top" shouldWrapChildren> - + ); }); diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx index 396b05c2ac2..40c2db08ce3 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldTitle.tsx @@ -100,6 +100,7 @@ export const InputFieldTitle = memo((props: Props) => { className={NO_FIT_ON_DOUBLE_CLICK_CLASS} sx={labelSx} noOfLines={1} + data-node-input-field-title="true" data-is-invalid={isInvalid} data-is-disabled={isDisabled} data-is-added-to-form={isAddedToForm} diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index 42dade2ff70..4f92b3fbad2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -9,8 +9,10 @@ import { getWorkflowExportStagingStyle, getWorkflowImageDimensions, getWorkflowSvgExportStyles, + hideWorkflowExportInfoIcons, hideWorkflowExportStatusIndicators, sanitizeWorkflowImageFilename, + setWorkflowExportInputFieldTitleStyles, setWorkflowExportNodeOpacity, SVG_EXPORT_STYLE_PROPERTIES, } from './workflowImageExport'; @@ -108,6 +110,33 @@ describe('workflow image export', () => { expect(setProperty).toHaveBeenCalledWith('display', 'none', 'important'); }); + it('hides node information icons from the export clone', () => { + const setProperty = vi.fn(); + const infoIcon = { style: { setProperty } } as unknown as SVGElement; + const root = { + querySelectorAll: (selector: string) => (selector === '[data-node-info-icon="true"]' ? [infoIcon] : []), + } as unknown as HTMLElement; + + hideWorkflowExportInfoIcons(root); + + expect(setProperty).toHaveBeenCalledWith('display', 'none', 'important'); + }); + + it('keeps input field titles on one line in the export clone', () => { + const setProperty = vi.fn(); + const fieldTitle = { style: { setProperty } } as unknown as HTMLElement; + const root = { + querySelectorAll: (selector: string) => (selector === '[data-node-input-field-title="true"]' ? [fieldTitle] : []), + } as unknown as HTMLElement; + + setWorkflowExportInputFieldTitleStyles(root); + + expect(setProperty).toHaveBeenCalledWith('display', 'block', 'important'); + expect(setProperty).toHaveBeenCalledWith('white-space', 'nowrap', 'important'); + expect(setProperty).toHaveBeenCalledWith('overflow', 'hidden', 'important'); + expect(setProperty).toHaveBeenCalledWith('text-overflow', 'ellipsis', 'important'); + }); + it('keeps ordinary workflow names unchanged', () => { expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage')).toBe('Hi-Res Two Stage'); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index 506980d72fd..0e2dd44f2ee 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -207,7 +207,7 @@ export const sanitizeWorkflowImageFilename = (workflowName: string): string => { return sanitizedName || DEFAULT_WORKFLOW_IMAGE_FILENAME; }; -const setExportElementStyle = (element: HTMLElement, property: string, value: string) => { +const setExportElementStyle = (element: HTMLElement | SVGElement, property: string, value: string) => { element.style.setProperty(property, value, 'important'); }; @@ -269,6 +269,21 @@ export const hideWorkflowExportStatusIndicators = (root: HTMLElement) => { }); }; +export const hideWorkflowExportInfoIcons = (root: HTMLElement) => { + root.querySelectorAll('[data-node-info-icon="true"]').forEach((element) => { + setExportElementStyle(element, 'display', 'none'); + }); +}; + +export const setWorkflowExportInputFieldTitleStyles = (root: HTMLElement) => { + root.querySelectorAll('[data-node-input-field-title="true"]').forEach((element) => { + setExportElementStyle(element, 'display', 'block'); + setExportElementStyle(element, 'white-space', 'nowrap'); + setExportElementStyle(element, 'overflow', 'hidden'); + setExportElementStyle(element, 'text-overflow', 'ellipsis'); + }); +}; + const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: WorkflowImageDimensions) => { const root = clone.matches('.react-flow') ? clone : clone.querySelector('.react-flow'); const viewport = clone.querySelector('.react-flow__viewport'); @@ -313,6 +328,8 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl }); setWorkflowExportNodeOpacity(clone); hideWorkflowExportStatusIndicators(clone); + hideWorkflowExportInfoIcons(clone); + setWorkflowExportInputFieldTitleStyles(clone); clone .querySelectorAll('.react-flow__edges, .react-flow__edges > svg, .react-flow__edge') From a08a6c350d3366cfabef3bdedc6187ad5770fd53 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 13 Aug 2026 19:58:27 -0500 Subject: [PATCH 5/7] fix(frontend): preserve full workflow field labels --- .../web/src/features/nodes/util/workflowImageExport.test.ts | 4 ++-- .../web/src/features/nodes/util/workflowImageExport.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index 4f92b3fbad2..3fcc8c920e8 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -133,8 +133,8 @@ describe('workflow image export', () => { expect(setProperty).toHaveBeenCalledWith('display', 'block', 'important'); expect(setProperty).toHaveBeenCalledWith('white-space', 'nowrap', 'important'); - expect(setProperty).toHaveBeenCalledWith('overflow', 'hidden', 'important'); - expect(setProperty).toHaveBeenCalledWith('text-overflow', 'ellipsis', 'important'); + expect(setProperty).toHaveBeenCalledWith('overflow', 'visible', 'important'); + expect(setProperty).toHaveBeenCalledWith('text-overflow', 'clip', 'important'); }); it('keeps ordinary workflow names unchanged', () => { diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index 0e2dd44f2ee..7e8dc630fac 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -279,8 +279,8 @@ export const setWorkflowExportInputFieldTitleStyles = (root: HTMLElement) => { root.querySelectorAll('[data-node-input-field-title="true"]').forEach((element) => { setExportElementStyle(element, 'display', 'block'); setExportElementStyle(element, 'white-space', 'nowrap'); - setExportElementStyle(element, 'overflow', 'hidden'); - setExportElementStyle(element, 'text-overflow', 'ellipsis'); + setExportElementStyle(element, 'overflow', 'visible'); + setExportElementStyle(element, 'text-overflow', 'clip'); }); }; From 158ff14652189d7e96e246ee1d2c5c0e0672c413 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Fri, 14 Aug 2026 21:51:06 -0500 Subject: [PATCH 6/7] fix(frontend): harden workflow image export --- .../features/nodes/components/flow/Flow.tsx | 4 +- .../BottomLeftPanel/ViewportControls.tsx | 19 +- .../web/src/features/nodes/types/constants.ts | 5 + .../workflowImageExport.edgeCases.test.ts | 189 ++++++++++++++++++ .../nodes/util/workflowImageExport.test.ts | 4 +- .../nodes/util/workflowImageExport.ts | 105 +++++++++- 6 files changed, 311 insertions(+), 15 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index 697d1a1182d..7f0e9d544a2 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -52,7 +52,7 @@ import { getConnectorDeletionSpliceConnections } from 'features/nodes/store/util import { connectionToEdge } from 'features/nodes/store/util/reactFlowUtil'; import { validateConnection } from 'features/nodes/store/util/validateConnection'; import { selectSelectionMode, selectShouldSnapToGrid } from 'features/nodes/store/workflowSettingsSlice'; -import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS } from 'features/nodes/types/constants'; +import { NO_DRAG_CLASS, NO_PAN_CLASS, NO_WHEEL_CLASS, WORKFLOW_GRID_SIZE } from 'features/nodes/types/constants'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { buildConnectorNode } from 'features/nodes/util/node/buildConnectorNode'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; @@ -88,7 +88,7 @@ const nodeTypes = { // TODO: can we support reactflow? if not, we could style the attribution so it matches the app const proOptions: ProOptions = { hideAttribution: true }; -const snapGrid: [number, number] = [25, 25]; +const snapGrid: [number, number] = [WORKFLOW_GRID_SIZE, WORKFLOW_GRID_SIZE]; const selectCancelConnection = (state: ReactFlowState) => state.cancelConnection; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 87978dfb117..31ef4b9f42a 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -1,5 +1,6 @@ import { ButtonGroup, IconButton } from '@invoke-ai/ui-library'; import { useReactFlow } from '@xyflow/react'; +import { logger } from 'app/logging/logger'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectWorkflowName } from 'features/nodes/store/selectors'; import { @@ -20,6 +21,8 @@ import { import { AutoLayoutPopover } from './AutoLayoutPopover'; +const log = logger('workflows'); + const ViewportControls = () => { const { t } = useTranslation(); const { zoomIn, zoomOut, fitView, getNodes, getNodesBounds } = useReactFlow(); @@ -44,9 +47,19 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, dispatch]); - const handleWorkflowImageExportError = useCallback(() => { - toast({ id: 'DOWNLOAD_WORKFLOW_IMAGE_ERROR', status: 'error', description: t('nodes.downloadWorkflowImageError') }); - }, [t]); + const handleWorkflowImageExportError = useCallback( + (error?: unknown) => { + if (error) { + log.error({ error: error instanceof Error ? error.message : String(error) }, 'Workflow image export failed'); + } + toast({ + id: 'DOWNLOAD_WORKFLOW_IMAGE_ERROR', + status: 'error', + description: t('nodes.downloadWorkflowImageError'), + }); + }, + [t] + ); const handleClickedExportWorkflow = useCallback(() => { if (isExportingWorkflow) { diff --git a/invokeai/frontend/web/src/features/nodes/types/constants.ts b/invokeai/frontend/web/src/features/nodes/types/constants.ts index 7f8b8891f40..6b0570cf389 100644 --- a/invokeai/frontend/web/src/features/nodes/types/constants.ts +++ b/invokeai/frontend/web/src/features/nodes/types/constants.ts @@ -10,6 +10,11 @@ export const HANDLE_TOOLTIP_OPEN_DELAY = 500; */ export const NODE_WIDTH = 320; +/** + * The grid spacing used by the workflow editor and workflow image export. + */ +export const WORKFLOW_GRID_SIZE = 25; + /** * This class name is special - reactflow uses it to identify the drag handle of a node, * applying the appropriate listeners to it. diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts new file mode 100644 index 00000000000..11cbe9e16da --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('html-to-image', () => ({ + toBlob: vi.fn(), +})); + +import { toBlob } from 'html-to-image'; + +import { + EXPORT_STYLE_PROPERTIES, + exportWorkflowAsPng, + getWorkflowContentBounds, + getWorkflowExportOptions, + WORKFLOW_EXPORT_TIMEOUT_MS, +} from './workflowImageExport'; + +type FakeElement = { + appendChild: (child: FakeElement) => void; + attributes: Array<{ name: string; value: string }>; + children: FakeElement[]; + cloneNode: () => FakeElement; + id?: string; + matches: () => boolean; + parentElement: FakeElement | null; + getBoundingClientRect: () => { left: number; top: number; width: number; height: number }; + querySelector: (selector: string) => FakeElement | null; + querySelectorAll: (selector: string) => FakeElement[]; + remove: () => void; + setAttribute: (name: string, value: string) => void; + style: { setProperty: ReturnType } & Record; +}; + +const createFakeElement = (overrides: Partial = {}): FakeElement => { + const element: FakeElement = { + appendChild: (child) => element.children.push(child), + attributes: [], + children: [], + cloneNode: () => element, + getBoundingClientRect: () => ({ left: 0, top: 0, width: 1000, height: 1000 }), + matches: () => false, + parentElement: null, + querySelector: () => null, + querySelectorAll: () => [], + remove: vi.fn(), + setAttribute: (name, value) => { + const attribute = element.attributes.find((candidate) => candidate.name === name); + if (attribute) { + attribute.value = value; + } else { + element.attributes.push({ name, value }); + } + }, + style: { setProperty: vi.fn() }, + ...overrides, + }; + return element; +}; + +const createExportDom = () => { + const parent = createFakeElement(); + const root = createFakeElement(); + const viewport = createFakeElement(); + const clone = createFakeElement({ id: 'workflow-editor' }); + const stagingWrapper = createFakeElement(); + const flowElement = createFakeElement({ id: 'workflow-editor', parentElement: parent, cloneNode: () => clone }); + flowElement.getBoundingClientRect = () => ({ left: 0, top: 0, width: 1000, height: 1000 }); + + clone.querySelector = (selector) => { + if (selector === '.react-flow') { + return root; + } + if (selector === '.react-flow__viewport') { + return viewport; + } + return null; + }; + stagingWrapper.remove = vi.fn(); + + vi.stubGlobal('document', { + body: parent, + createElement: () => stagingWrapper, + }); + vi.stubGlobal('getComputedStyle', () => ({ backgroundColor: 'rgb(1, 2, 3)', transform: 'none', direction: 'ltr' })); + + return { clone, flowElement, stagingWrapper }; +}; + +describe('workflow image export edge cases', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('settles and cleans up when rasterization never settles', async () => { + vi.useFakeTimers(); + vi.mocked(toBlob).mockReturnValue(new Promise(() => {})); + const { flowElement, stagingWrapper } = createExportDom(); + const exportPromise = exportWorkflowAsPng({ + flowElement: flowElement as unknown as HTMLElement, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + workflowName: 'Workflow', + }); + const rejection = expect(exportPromise).rejects.toThrow('timed out'); + try { + await vi.advanceTimersByTimeAsync(WORKFLOW_EXPORT_TIMEOUT_MS); + + await rejection; + expect(stagingWrapper.remove).toHaveBeenCalledOnce(); + } finally { + vi.useRealTimers(); + } + }); + + it('configures failed image embedding to degrade instead of aborting export', () => { + const options = getWorkflowExportOptions( + { width: 100, height: 100, canvasWidth: 200, canvasHeight: 200 }, + 'rgb(1, 2, 3)' + ); + + expect(options.imagePlaceholder).toBeTruthy(); + }); + + it('keeps the Invoke font available to the serialized image', () => { + const options = getWorkflowExportOptions( + { width: 100, height: 100, canvasWidth: 200, canvasHeight: 200 }, + 'rgb(1, 2, 3)' + ); + + expect(options.skipFonts).toBe(false); + }); + + it('includes overflowing input labels in content bounds', () => { + const label = { + getBoundingClientRect: () => ({ left: 590, top: 220, width: 100, height: 20 }), + scrollWidth: 100, + scrollHeight: 20, + }; + const viewport = { getBoundingClientRect: () => ({ left: 100, top: 200, width: 1000, height: 1000 }) }; + const flowElement = { + getBoundingClientRect: () => ({ left: 100, top: 200 }), + querySelector: (selector: string) => (selector === '.react-flow__viewport' ? viewport : null), + querySelectorAll: (selector: string) => (selector === '[data-node-input-field-title="true"]' ? [label] : []), + }; + + expect( + getWorkflowContentBounds(flowElement as unknown as HTMLElement, { x: 0, y: 0, width: 500, height: 100 }) + ).toMatchObject({ x: 0, y: 0, width: 590, height: 100 }); + }); + + it('preserves flex wrapping and document direction in the export clone', () => { + expect(EXPORT_STYLE_PROPERTIES).toEqual(expect.arrayContaining(['flex-wrap', 'direction'])); + }); + + it('does not put a duplicate workflow-editor id in the live document', async () => { + vi.mocked(toBlob).mockResolvedValue(null); + const { clone, flowElement } = createExportDom(); + + await expect( + exportWorkflowAsPng({ + flowElement: flowElement as unknown as HTMLElement, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + workflowName: 'Workflow', + }) + ).rejects.toThrow('empty Blob'); + + expect(clone.id).not.toBe(flowElement.id); + }); + + it('namespaces cloned SVG ids and references', async () => { + vi.mocked(toBlob).mockResolvedValue(null); + const marker = createFakeElement({ id: 'edge-marker' }); + const edgePath = createFakeElement({ + attributes: [{ name: 'marker-end', value: 'url(#edge-marker)' }], + }); + const { clone, flowElement } = createExportDom(); + clone.querySelectorAll = (selector) => (selector === '*' ? [marker, edgePath] : []); + + await expect( + exportWorkflowAsPng({ + flowElement: flowElement as unknown as HTMLElement, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + workflowName: 'Workflow', + }) + ).rejects.toThrow('empty Blob'); + + expect(marker.id).toBe('edge-marker-workflow-export'); + expect(edgePath.attributes).toEqual([{ name: 'marker-end', value: 'url(#edge-marker-workflow-export)' }]); + }); +}); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index 3fcc8c920e8..f7ffd31f571 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -59,7 +59,9 @@ describe('workflow image export', () => { backgroundColor: 'rgb(1, 2, 3)', pixelRatio: 1, includeStyleProperties: [...EXPORT_STYLE_PROPERTIES], - skipFonts: true, + imagePlaceholder: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', + onImageErrorHandler: expect.any(Function), + skipFonts: false, }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index 7e8dc630fac..e1d62ebfea3 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -1,8 +1,11 @@ import type { Rect } from '@xyflow/react'; +import { WORKFLOW_GRID_SIZE } from 'features/nodes/types/constants'; import { toBlob } from 'html-to-image'; export const EXPORT_PADDING = 100; export const EXPORT_SCALE = 2; +export const WORKFLOW_EXPORT_TIMEOUT_MS = 30_000; +const WORKFLOW_EXPORT_IMAGE_PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; export const EXPORT_STYLE_PROPERTIES = [ 'box-sizing', 'display', @@ -30,6 +33,7 @@ export const EXPORT_STYLE_PROPERTIES = [ 'margin-left', 'flex', 'flex-direction', + 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis', @@ -70,6 +74,7 @@ export const EXPORT_STYLE_PROPERTIES = [ 'font-size', 'font-weight', 'font-style', + 'direction', 'line-height', 'letter-spacing', 'text-align', @@ -109,7 +114,6 @@ export const SVG_EXPORT_STYLE_PROPERTIES = [ 'clip-path', 'mask', ] as const; -const GRID_GAP = 25; const DEFAULT_WORKFLOW_IMAGE_FILENAME = 'My Workflow'; type WorkflowImageDimensions = { @@ -126,7 +130,7 @@ const getPaddedWorkflowBounds = (bounds: Rect): Rect => ({ height: bounds.height + EXPORT_PADDING * 2, }); -const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: Rect): Rect => { +export const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: Rect): Rect => { let minX = nodeBounds.x; let minY = nodeBounds.y; let maxX = nodeBounds.x + nodeBounds.width; @@ -154,6 +158,33 @@ const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: Rect): R maxY = Math.max(maxY, pathBounds.y + pathBounds.height); }); + const flowRect = flowElement.getBoundingClientRect(); + const viewport = flowElement.querySelector('.react-flow__viewport'); + const viewportRect = viewport?.getBoundingClientRect() ?? flowRect; + const transform = viewport ? (getComputedStyle(viewport).transform ?? 'none') : 'none'; + const matrix = transform + .match(/^matrix\(([^)]+)\)$/)?.[1] + ?.split(',') + .map(Number); + const zoom = matrix?.[0] && Number.isFinite(matrix[0]) && matrix[0] > 0 ? matrix[0] : 1; + + flowElement.querySelectorAll('[data-node-input-field-title="true"]').forEach((label) => { + const labelRect = label.getBoundingClientRect(); + const intrinsicWidth = Math.max(labelRect.width, label.scrollWidth); + const intrinsicHeight = Math.max(labelRect.height, label.scrollHeight); + const overflowWidth = Math.max(0, intrinsicWidth - labelRect.width) / zoom; + const direction = getComputedStyle(label).direction; + const labelX = (labelRect.left - viewportRect.left) / zoom - (direction === 'rtl' ? overflowWidth : 0); + const labelY = (labelRect.top - viewportRect.top) / zoom; + const labelWidth = intrinsicWidth / zoom; + const labelHeight = intrinsicHeight / zoom; + + minX = Math.min(minX, labelX); + minY = Math.min(minY, labelY); + maxX = Math.max(maxX, labelX + labelWidth); + maxY = Math.max(maxY, labelY + labelHeight); + }); + return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; }; @@ -183,7 +214,9 @@ export const getWorkflowExportOptions = (dimensions: WorkflowImageDimensions, ba backgroundColor, pixelRatio: 1, includeStyleProperties: [...EXPORT_STYLE_PROPERTIES], - skipFonts: true, + imagePlaceholder: WORKFLOW_EXPORT_IMAGE_PLACEHOLDER, + onImageErrorHandler: () => WORKFLOW_EXPORT_IMAGE_PLACEHOLDER, + skipFonts: false, }); export const getWorkflowSvgExportStyles = (computedStyle: Pick) => @@ -229,11 +262,11 @@ const setBackgroundGridForExport = (root: HTMLElement, translation: { x: number; const patternId = `${pattern.id}-export`; pattern.id = patternId; - pattern.setAttribute('width', `${GRID_GAP}`); - pattern.setAttribute('height', `${GRID_GAP}`); - pattern.setAttribute('x', `${((translation.x % GRID_GAP) + GRID_GAP) % GRID_GAP}`); - pattern.setAttribute('y', `${((translation.y % GRID_GAP) + GRID_GAP) % GRID_GAP}`); - pattern.setAttribute('patternTransform', `translate(-${GRID_GAP},-${GRID_GAP})`); + pattern.setAttribute('width', `${WORKFLOW_GRID_SIZE}`); + pattern.setAttribute('height', `${WORKFLOW_GRID_SIZE}`); + pattern.setAttribute('x', `${((translation.x % WORKFLOW_GRID_SIZE) + WORKFLOW_GRID_SIZE) % WORKFLOW_GRID_SIZE}`); + pattern.setAttribute('y', `${((translation.y % WORKFLOW_GRID_SIZE) + WORKFLOW_GRID_SIZE) % WORKFLOW_GRID_SIZE}`); + pattern.setAttribute('patternTransform', `translate(-${WORKFLOW_GRID_SIZE},-${WORKFLOW_GRID_SIZE})`); const patternReference = `url(#${patternId})`; background.querySelector('rect')?.setAttribute('fill', patternReference); @@ -284,6 +317,37 @@ export const setWorkflowExportInputFieldTitleStyles = (root: HTMLElement) => { }); }; +const namespaceWorkflowExportIds = (clone: HTMLElement) => { + const elements = [clone, ...clone.querySelectorAll('*')]; + const ids = new Map(); + + elements.forEach((element) => { + if (element.id) { + ids.set(element.id, `${element.id}-workflow-export`); + } + }); + + elements.forEach((element) => { + const id = element.id; + if (id) { + element.id = ids.get(id) ?? id; + } + }); + + const sortedIds = [...ids.entries()].sort(([first], [second]) => second.length - first.length); + const rewriteReferences = (value: string) => + sortedIds.reduce((rewritten, [id, namespacedId]) => rewritten.split(`#${id}`).join(`#${namespacedId}`), value); + + elements.forEach((element) => { + Array.from(element.attributes).forEach((attribute) => { + const rewritten = rewriteReferences(attribute.value); + if (rewritten !== attribute.value) { + element.setAttribute(attribute.name, rewritten); + } + }); + }); +}; + const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: WorkflowImageDimensions) => { const root = clone.matches('.react-flow') ? clone : clone.querySelector('.react-flow'); const viewport = clone.querySelector('.react-flow__viewport'); @@ -349,6 +413,25 @@ const prepareExportClone = (clone: HTMLElement, bounds: Rect, dimensions: Workfl }); }; +const toBlobWithTimeout = async (clone: HTMLElement, options: ReturnType) => { + let timeoutId: ReturnType | undefined; + + try { + return await Promise.race([ + toBlob(clone, options), + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new Error(`Workflow image export timed out after ${WORKFLOW_EXPORT_TIMEOUT_MS} ms`)); + }, WORKFLOW_EXPORT_TIMEOUT_MS); + }), + ]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } +}; + const downloadPng = (blob: Blob, workflowName: string) => { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); @@ -374,6 +457,7 @@ export const exportWorkflowAsPng = async ({ const clone = flowElement.cloneNode(true) as HTMLElement; const stagingWrapper = document.createElement('div'); + namespaceWorkflowExportIds(clone); prepareExportClone(clone, contentBounds, dimensions); Object.assign(stagingWrapper.style, getWorkflowExportStagingStyle(dimensions)); stagingWrapper.appendChild(clone); @@ -381,7 +465,10 @@ export const exportWorkflowAsPng = async ({ try { inlineSvgStylesForExport(clone); - const blob = await toBlob(clone, getWorkflowExportOptions(dimensions, getComputedStyle(clone).backgroundColor)); + const blob = await toBlobWithTimeout( + clone, + getWorkflowExportOptions(dimensions, getComputedStyle(clone).backgroundColor) + ); if (!blob) { throw new Error('Workflow image export returned an empty Blob'); } From ac4ee764d78d11cd02af1f3ca20ba897a4348997 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sun, 16 Aug 2026 12:58:05 -0500 Subject: [PATCH 7/7] fix(frontend): address workflow image export review --- .../features/Workflows/editor-interface.mdx | 2 +- .../BottomLeftPanel/ViewportControls.tsx | 11 +- .../workflowImageExport.edgeCases.test.ts | 36 +++++- .../nodes/util/workflowImageExport.test.ts | 16 ++- .../nodes/util/workflowImageExport.ts | 115 +++++++++++------- 5 files changed, 129 insertions(+), 51 deletions(-) diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx index 69351a378cf..a54003438f0 100644 --- a/docs/src/content/docs/features/Workflows/editor-interface.mdx +++ b/docs/src/content/docs/features/Workflows/editor-interface.mdx @@ -48,7 +48,7 @@ If you're not familiar with Diffusion, take a look at our [Diffusion Overview](. Nodes have a **"Use Cache"** option in their footer. This allows for performance improvements by reusing previously cached values during workflow processing. - Click the camera button in the workflow editor controls to download a 2x PNG of the complete workflow. The image includes connectors, the background grid, and the selected Invoke color scheme, with nodes shown deselected and the MiniMap omitted. + Click the camera button in the workflow editor controls to download a PNG of the complete workflow, rendered at up to 2x resolution. The image includes connectors, the background grid, and the selected Invoke color scheme, with nodes shown deselected and the MiniMap omitted. ### Managing Nodes diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 31ef4b9f42a..faa67d44748 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx @@ -29,6 +29,7 @@ const ViewportControls = () => { const dispatch = useAppDispatch(); const shouldShowMinimapPanel = useAppSelector(selectShouldShowMinimapPanel); const workflowName = useAppSelector(selectWorkflowName); + const fallbackWorkflowName = t('workflows.unnamedWorkflow'); const [isExportingWorkflow, setIsExportingWorkflow] = useState(false); const handleClickedZoomIn = useCallback(() => { @@ -81,11 +82,19 @@ const ViewportControls = () => { flowElement, bounds: getNodesBounds(getNodes()), workflowName, + fallbackWorkflowName, }) ) .catch(handleWorkflowImageExportError) .finally(() => setIsExportingWorkflow(false)); - }, [getNodes, getNodesBounds, handleWorkflowImageExportError, isExportingWorkflow, workflowName]); + }, [ + fallbackWorkflowName, + getNodes, + getNodesBounds, + handleWorkflowImageExportError, + isExportingWorkflow, + workflowName, + ]); return ( diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts index 11cbe9e16da..67b54040bad 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.edgeCases.test.ts @@ -27,6 +27,8 @@ type FakeElement = { querySelectorAll: (selector: string) => FakeElement[]; remove: () => void; setAttribute: (name: string, value: string) => void; + scrollHeight?: number; + scrollWidth?: number; style: { setProperty: ReturnType } & Record; }; @@ -99,6 +101,7 @@ describe('workflow image export edge cases', () => { flowElement: flowElement as unknown as HTMLElement, bounds: { x: 0, y: 0, width: 100, height: 100 }, workflowName: 'Workflow', + fallbackWorkflowName: 'Unnamed Workflow', }); const rejection = expect(exportPromise).rejects.toThrow('timed out'); try { @@ -132,7 +135,7 @@ describe('workflow image export edge cases', () => { it('includes overflowing input labels in content bounds', () => { const label = { getBoundingClientRect: () => ({ left: 590, top: 220, width: 100, height: 20 }), - scrollWidth: 100, + scrollWidth: 200, scrollHeight: 20, }; const viewport = { getBoundingClientRect: () => ({ left: 100, top: 200, width: 1000, height: 1000 }) }; @@ -144,7 +147,34 @@ describe('workflow image export edge cases', () => { expect( getWorkflowContentBounds(flowElement as unknown as HTMLElement, { x: 0, y: 0, width: 500, height: 100 }) - ).toMatchObject({ x: 0, y: 0, width: 590, height: 100 }); + ).toMatchObject({ x: 0, y: 0, width: 690, height: 100 }); + }); + + it('measures overflowing labels after export styles are applied', async () => { + vi.mocked(toBlob).mockResolvedValue(null); + const label = createFakeElement({ + getBoundingClientRect: () => ({ left: 500, top: 100, width: 100, height: 20 }), + scrollWidth: 100, + scrollHeight: 20, + }); + label.style.setProperty = vi.fn((property: string) => { + if (property === 'white-space') { + label.scrollWidth = 200; + } + }); + const { clone, flowElement } = createExportDom(); + clone.querySelectorAll = (selector) => (selector === '[data-node-input-field-title="true"]' ? [label] : []); + + await expect( + exportWorkflowAsPng({ + flowElement: flowElement as unknown as HTMLElement, + bounds: { x: 0, y: 0, width: 100, height: 100 }, + workflowName: 'Workflow', + fallbackWorkflowName: 'Unnamed Workflow', + }) + ).rejects.toThrow('empty Blob'); + + expect(vi.mocked(toBlob).mock.calls[0]?.[1]).toMatchObject({ width: 900, height: 320 }); }); it('preserves flex wrapping and document direction in the export clone', () => { @@ -160,6 +190,7 @@ describe('workflow image export edge cases', () => { flowElement: flowElement as unknown as HTMLElement, bounds: { x: 0, y: 0, width: 100, height: 100 }, workflowName: 'Workflow', + fallbackWorkflowName: 'Unnamed Workflow', }) ).rejects.toThrow('empty Blob'); @@ -180,6 +211,7 @@ describe('workflow image export edge cases', () => { flowElement: flowElement as unknown as HTMLElement, bounds: { x: 0, y: 0, width: 100, height: 100 }, workflowName: 'Workflow', + fallbackWorkflowName: 'Unnamed Workflow', }) ).rejects.toThrow('empty Blob'); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts index f7ffd31f571..9a89916f482 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { + EXPORT_MAX_CANVAS_DIMENSION, EXPORT_PADDING, EXPORT_SCALE, EXPORT_STYLE_PROPERTIES, @@ -27,6 +28,13 @@ describe('workflow image export', () => { }); }); + it('preserves the aspect ratio when large exports reach the canvas limit', () => { + const dimensions = getWorkflowImageDimensions({ x: 0, y: 0, width: 9000, height: 9000 }); + + expect(dimensions.canvasWidth).toBe(EXPORT_MAX_CANVAS_DIMENSION); + expect(dimensions.canvasHeight).toBe(EXPORT_MAX_CANVAS_DIMENSION); + }); + it('keeps capture clone local to an offscreen staging wrapper', () => { const dimensions = getWorkflowImageDimensions({ x: 0, y: 0, width: 400, height: 300 }); @@ -67,7 +75,7 @@ describe('workflow image export', () => { it('preserves single-line field title styles in the export clone', () => { expect(EXPORT_STYLE_PROPERTIES).toEqual( - expect.arrayContaining(['text-overflow', '-webkit-line-clamp', '-webkit-box-orient']) + expect.arrayContaining(['aspect-ratio', 'text-overflow', '-webkit-line-clamp', '-webkit-box-orient']) ); }); @@ -140,11 +148,11 @@ describe('workflow image export', () => { }); it('keeps ordinary workflow names unchanged', () => { - expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage')).toBe('Hi-Res Two Stage'); + expect(sanitizeWorkflowImageFilename('Hi-Res Two Stage', 'Unnamed Workflow')).toBe('Hi-Res Two Stage'); }); it('replaces filesystem-invalid characters and falls back for blank names', () => { - expect(sanitizeWorkflowImageFilename('Workflow: 01 / test?')).toBe('Workflow- 01 - test-'); - expect(sanitizeWorkflowImageFilename(' ... ')).toBe('My Workflow'); + expect(sanitizeWorkflowImageFilename('Workflow: 01 / test?', 'Unnamed Workflow')).toBe('Workflow- 01 - test-'); + expect(sanitizeWorkflowImageFilename(' ... ', 'Unnamed Workflow')).toBe('Unnamed Workflow'); }); }); diff --git a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts index e1d62ebfea3..700828e4525 100644 --- a/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -4,6 +4,7 @@ import { toBlob } from 'html-to-image'; export const EXPORT_PADDING = 100; export const EXPORT_SCALE = 2; +export const EXPORT_MAX_CANVAS_DIMENSION = 16_384; export const WORKFLOW_EXPORT_TIMEOUT_MS = 30_000; const WORKFLOW_EXPORT_IMAGE_PLACEHOLDER = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; export const EXPORT_STYLE_PROPERTIES = [ @@ -34,6 +35,7 @@ export const EXPORT_STYLE_PROPERTIES = [ 'flex', 'flex-direction', 'flex-wrap', + 'aspect-ratio', 'flex-grow', 'flex-shrink', 'flex-basis', @@ -114,7 +116,6 @@ export const SVG_EXPORT_STYLE_PROPERTIES = [ 'clip-path', 'mask', ] as const; -const DEFAULT_WORKFLOW_IMAGE_FILENAME = 'My Workflow'; type WorkflowImageDimensions = { width: number; @@ -130,7 +131,15 @@ const getPaddedWorkflowBounds = (bounds: Rect): Rect => ({ height: bounds.height + EXPORT_PADDING * 2, }); -export const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: Rect): Rect => { +type WorkflowContentBoundsOptions = { + includeInputFieldLabels?: boolean; +}; + +export const getWorkflowContentBounds = ( + flowElement: HTMLElement, + nodeBounds: Rect, + { includeInputFieldLabels = true }: WorkflowContentBoundsOptions = {} +): Rect => { let minX = nodeBounds.x; let minY = nodeBounds.y; let maxX = nodeBounds.x + nodeBounds.width; @@ -158,32 +167,34 @@ export const getWorkflowContentBounds = (flowElement: HTMLElement, nodeBounds: R maxY = Math.max(maxY, pathBounds.y + pathBounds.height); }); - const flowRect = flowElement.getBoundingClientRect(); - const viewport = flowElement.querySelector('.react-flow__viewport'); - const viewportRect = viewport?.getBoundingClientRect() ?? flowRect; - const transform = viewport ? (getComputedStyle(viewport).transform ?? 'none') : 'none'; - const matrix = transform - .match(/^matrix\(([^)]+)\)$/)?.[1] - ?.split(',') - .map(Number); - const zoom = matrix?.[0] && Number.isFinite(matrix[0]) && matrix[0] > 0 ? matrix[0] : 1; - - flowElement.querySelectorAll('[data-node-input-field-title="true"]').forEach((label) => { - const labelRect = label.getBoundingClientRect(); - const intrinsicWidth = Math.max(labelRect.width, label.scrollWidth); - const intrinsicHeight = Math.max(labelRect.height, label.scrollHeight); - const overflowWidth = Math.max(0, intrinsicWidth - labelRect.width) / zoom; - const direction = getComputedStyle(label).direction; - const labelX = (labelRect.left - viewportRect.left) / zoom - (direction === 'rtl' ? overflowWidth : 0); - const labelY = (labelRect.top - viewportRect.top) / zoom; - const labelWidth = intrinsicWidth / zoom; - const labelHeight = intrinsicHeight / zoom; - - minX = Math.min(minX, labelX); - minY = Math.min(minY, labelY); - maxX = Math.max(maxX, labelX + labelWidth); - maxY = Math.max(maxY, labelY + labelHeight); - }); + if (includeInputFieldLabels) { + const flowRect = flowElement.getBoundingClientRect(); + const viewport = flowElement.querySelector('.react-flow__viewport'); + const viewportRect = viewport?.getBoundingClientRect() ?? flowRect; + const transform = viewport ? (getComputedStyle(viewport).transform ?? 'none') : 'none'; + const matrix = transform + .match(/^matrix\(([^)]+)\)$/)?.[1] + ?.split(',') + .map(Number); + const zoom = matrix?.[0] && Number.isFinite(matrix[0]) && matrix[0] > 0 ? matrix[0] : 1; + + flowElement.querySelectorAll('[data-node-input-field-title="true"]').forEach((label) => { + const labelRect = label.getBoundingClientRect(); + const intrinsicWidth = Math.max(labelRect.width, label.scrollWidth); + const intrinsicHeight = Math.max(labelRect.height, label.scrollHeight); + const overflowWidth = Math.max(0, intrinsicWidth - labelRect.width) / zoom; + const direction = getComputedStyle(label).direction; + const labelX = (labelRect.left - viewportRect.left) / zoom - (direction === 'rtl' ? overflowWidth : 0); + const labelY = (labelRect.top - viewportRect.top) / zoom; + const labelWidth = intrinsicWidth / zoom; + const labelHeight = intrinsicHeight / zoom; + + minX = Math.min(minX, labelX); + minY = Math.min(minY, labelY); + maxX = Math.max(maxX, labelX + labelWidth); + maxY = Math.max(maxY, labelY + labelHeight); + }); + } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; }; @@ -194,7 +205,11 @@ export const getWorkflowImageDimensions = (bounds: Rect): WorkflowImageDimension const width = Math.max(1, Math.ceil(paddedBounds.width)); const height = Math.max(1, Math.ceil(paddedBounds.height)); - return { width, height, canvasWidth: width * EXPORT_SCALE, canvasHeight: height * EXPORT_SCALE }; + const scale = Math.min(EXPORT_SCALE, EXPORT_MAX_CANVAS_DIMENSION / width, EXPORT_MAX_CANVAS_DIMENSION / height); + const canvasWidth = Math.max(1, Math.floor(width * scale)); + const canvasHeight = Math.max(1, Math.floor(height * scale)); + + return { width, height, canvasWidth, canvasHeight }; }; export const getWorkflowExportCloneStyle = (dimensions: WorkflowImageDimensions) => ({ @@ -228,7 +243,7 @@ export const getWorkflowSvgExportStyles = (computedStyle: Pick { +export const sanitizeWorkflowImageFilename = (workflowName: string, fallbackWorkflowName: string): string => { const sanitizedName = workflowName .replace(/[<>:"/\\|?*]/g, '-') .split('') @@ -237,7 +252,7 @@ export const sanitizeWorkflowImageFilename = (workflowName: string): string => { .trim() .replace(/[. ]+$/g, ''); - return sanitizedName || DEFAULT_WORKFLOW_IMAGE_FILENAME; + return sanitizedName || fallbackWorkflowName; }; const setExportElementStyle = (element: HTMLElement | SVGElement, property: string, value: string) => { @@ -260,7 +275,7 @@ const setBackgroundGridForExport = (root: HTMLElement, translation: { x: number; return; } - const patternId = `${pattern.id}-export`; + const patternId = pattern.id.endsWith('-export') ? pattern.id : `${pattern.id}-export`; pattern.id = patternId; pattern.setAttribute('width', `${WORKFLOW_GRID_SIZE}`); pattern.setAttribute('height', `${WORKFLOW_GRID_SIZE}`); @@ -432,10 +447,10 @@ const toBlobWithTimeout = async (clone: HTMLElement, options: ReturnType { +const downloadPng = (blob: Blob, workflowName: string, fallbackWorkflowName: string) => { const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); - anchor.download = `${sanitizeWorkflowImageFilename(workflowName)}.png`; + anchor.download = `${sanitizeWorkflowImageFilename(workflowName, fallbackWorkflowName)}.png`; anchor.href = objectUrl; document.body.appendChild(anchor); anchor.click(); @@ -447,32 +462,46 @@ export const exportWorkflowAsPng = async ({ flowElement, bounds, workflowName, + fallbackWorkflowName, }: { flowElement: HTMLElement; bounds: Rect; workflowName: string; + fallbackWorkflowName: string; }): Promise => { - const contentBounds = getWorkflowContentBounds(flowElement, bounds); + const contentBounds = getWorkflowContentBounds(flowElement, bounds, { includeInputFieldLabels: false }); const dimensions = getWorkflowImageDimensions(contentBounds); const clone = flowElement.cloneNode(true) as HTMLElement; const stagingWrapper = document.createElement('div'); - namespaceWorkflowExportIds(clone); - prepareExportClone(clone, contentBounds, dimensions); - Object.assign(stagingWrapper.style, getWorkflowExportStagingStyle(dimensions)); - stagingWrapper.appendChild(clone); - (flowElement.parentElement ?? document.body).appendChild(stagingWrapper); - try { + namespaceWorkflowExportIds(clone); + prepareExportClone(clone, contentBounds, dimensions); + Object.assign(stagingWrapper.style, getWorkflowExportStagingStyle(dimensions)); + stagingWrapper.appendChild(clone); + (flowElement.parentElement ?? document.body).appendChild(stagingWrapper); + + const measuredContentBounds = getWorkflowContentBounds(clone, contentBounds); + const measuredDimensions = getWorkflowImageDimensions(measuredContentBounds); + if ( + measuredContentBounds.x !== contentBounds.x || + measuredContentBounds.y !== contentBounds.y || + measuredDimensions.width !== dimensions.width || + measuredDimensions.height !== dimensions.height + ) { + prepareExportClone(clone, measuredContentBounds, measuredDimensions); + Object.assign(stagingWrapper.style, getWorkflowExportStagingStyle(measuredDimensions)); + } + inlineSvgStylesForExport(clone); const blob = await toBlobWithTimeout( clone, - getWorkflowExportOptions(dimensions, getComputedStyle(clone).backgroundColor) + getWorkflowExportOptions(measuredDimensions, getComputedStyle(clone).backgroundColor) ); if (!blob) { throw new Error('Workflow image export returned an empty Blob'); } - downloadPng(blob, workflowName); + downloadPng(blob, workflowName, fallbackWorkflowName); } finally { stagingWrapper.remove(); }