diff --git a/docs/src/content/docs/features/Workflows/editor-interface.mdx b/docs/src/content/docs/features/Workflows/editor-interface.mdx index bce33485ad6..a54003438f0 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 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/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/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/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" > - + { return ( } placement="top" shouldWrapChildren> - + ); }); 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/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/components/flow/panels/BottomLeftPanel/ViewportControls.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx index 68801954f50..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 @@ -1,13 +1,18 @@ 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 { selectShouldShowMinimapPanel, shouldShowMinimapPanelChanged, } from 'features/nodes/store/workflowSettingsSlice'; -import { memo, useCallback } from 'react'; +import { exportWorkflowAsPng } from 'features/nodes/util/workflowImageExport'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { + PiCameraBold, PiFrameCornersBold, PiMagnifyingGlassMinusBold, PiMagnifyingGlassPlusBold, @@ -16,11 +21,16 @@ import { import { AutoLayoutPopover } from './AutoLayoutPopover'; +const log = logger('workflows'); + const ViewportControls = () => { 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 fallbackWorkflowName = t('workflows.unnamedWorkflow'); + const [isExportingWorkflow, setIsExportingWorkflow] = useState(false); const handleClickedZoomIn = useCallback(() => { zoomIn({ duration: 300 }); @@ -38,6 +48,54 @@ const ViewportControls = () => { dispatch(shouldShowMinimapPanelChanged(!shouldShowMinimapPanel)); }, [shouldShowMinimapPanel, dispatch]); + 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) { + return; + } + + const flowElement = document.querySelector('#workflow-editor'); + if (!flowElement) { + handleWorkflowImageExportError(); + return; + } + + setIsExportingWorkflow(true); + void new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }) + .then(() => + exportWorkflowAsPng({ + flowElement, + bounds: getNodesBounds(getNodes()), + workflowName, + fallbackWorkflowName, + }) + ) + .catch(handleWorkflowImageExportError) + .finally(() => setIsExportingWorkflow(false)); + }, [ + fallbackWorkflowName, + getNodes, + getNodesBounds, + handleWorkflowImageExportError, + isExportingWorkflow, + workflowName, + ]); + return ( { icon={} /> + } + /> ({ + 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; + scrollHeight?: number; + scrollWidth?: number; + 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', + fallbackWorkflowName: 'Unnamed 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: 200, + 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: 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', () => { + 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', + fallbackWorkflowName: 'Unnamed 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', + fallbackWorkflowName: 'Unnamed 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 new file mode 100644 index 00000000000..9a89916f482 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + EXPORT_MAX_CANVAS_DIMENSION, + EXPORT_PADDING, + EXPORT_SCALE, + EXPORT_STYLE_PROPERTIES, + getWorkflowExportCloneStyle, + getWorkflowExportOptions, + getWorkflowExportStagingStyle, + getWorkflowImageDimensions, + getWorkflowSvgExportStyles, + hideWorkflowExportInfoIcons, + hideWorkflowExportStatusIndicators, + sanitizeWorkflowImageFilename, + setWorkflowExportInputFieldTitleStyles, + setWorkflowExportNodeOpacity, + SVG_EXPORT_STYLE_PROPERTIES, +} from './workflowImageExport'; + +describe('workflow image export', () => { + 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('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 }); + + 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], + imagePlaceholder: 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=', + onImageErrorHandler: expect.any(Function), + skipFonts: false, + }); + }); + + it('preserves single-line field title styles in the export clone', () => { + expect(EXPORT_STYLE_PROPERTIES).toEqual( + expect.arrayContaining(['aspect-ratio', 'text-overflow', '-webkit-line-clamp', '-webkit-box-orient']) + ); + }); + + 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('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('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', 'visible', 'important'); + expect(setProperty).toHaveBeenCalledWith('text-overflow', 'clip', 'important'); + }); + + it('keeps ordinary workflow names unchanged', () => { + 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?', '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 new file mode 100644 index 00000000000..700828e4525 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts @@ -0,0 +1,508 @@ +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 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 = [ + '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-wrap', + 'aspect-ratio', + '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', + 'direction', + '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', + '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; + +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, +}); + +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; + 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); + }); + + 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 }; +}; + +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)); + + 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) => ({ + 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], + imagePlaceholder: WORKFLOW_EXPORT_IMAGE_PLACEHOLDER, + onImageErrorHandler: () => WORKFLOW_EXPORT_IMAGE_PLACEHOLDER, + skipFonts: false, +}); + +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, fallbackWorkflowName: string): string => { + const sanitizedName = workflowName + .replace(/[<>:"/\\|?*]/g, '-') + .split('') + .map((character) => (character.charCodeAt(0) < 32 ? '-' : character)) + .join('') + .trim() + .replace(/[. ]+$/g, ''); + + return sanitizedName || fallbackWorkflowName; +}; + +const setExportElementStyle = (element: HTMLElement | SVGElement, property: string, value: string) => { + 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'); + if (!background || !pattern) { + return; + } + + 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}`); + 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); + + const dot = pattern.querySelector('circle'); + dot?.setAttribute('cx', '0.5'); + dot?.setAttribute('cy', '0.5'); + 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'); + }); +}; + +export const hideWorkflowExportStatusIndicators = (root: HTMLElement) => { + root.querySelectorAll('[data-node-status-indicator="true"]').forEach((element) => { + setExportElementStyle(element, 'display', 'none'); + }); +}; + +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', 'visible'); + setExportElementStyle(element, 'text-overflow', 'clip'); + }); +}; + +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'); + 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, + }; + + 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); + + 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)'); + }); + setWorkflowExportNodeOpacity(clone); + hideWorkflowExportStatusIndicators(clone); + hideWorkflowExportInfoIcons(clone); + setWorkflowExportInputFieldTitleStyles(clone); + + 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 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, fallbackWorkflowName: string) => { + const objectUrl = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.download = `${sanitizeWorkflowImageFilename(workflowName, fallbackWorkflowName)}.png`; + anchor.href = objectUrl; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 0); +}; + +export const exportWorkflowAsPng = async ({ + flowElement, + bounds, + workflowName, + fallbackWorkflowName, +}: { + flowElement: HTMLElement; + bounds: Rect; + workflowName: string; + fallbackWorkflowName: string; +}): Promise => { + const contentBounds = getWorkflowContentBounds(flowElement, bounds, { includeInputFieldLabels: false }); + const dimensions = getWorkflowImageDimensions(contentBounds); + const clone = flowElement.cloneNode(true) as HTMLElement; + const stagingWrapper = document.createElement('div'); + + 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(measuredDimensions, getComputedStyle(clone).backgroundColor) + ); + if (!blob) { + throw new Error('Workflow image export returned an empty Blob'); + } + downloadPng(blob, workflowName, fallbackWorkflowName); + } finally { + stagingWrapper.remove(); + } +};