Skip to content

Workflow screenshot as PNG - #9501

Open
JPPhoto wants to merge 6 commits into
invoke-ai:mainfrom
JPPhoto:workflow-screenshot
Open

Workflow screenshot as PNG#9501
JPPhoto wants to merge 6 commits into
invoke-ai:mainfrom
JPPhoto:workflow-screenshot

Conversation

@JPPhoto

@JPPhoto JPPhoto commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds camera-button workflow PNG export.

  • 2x resolution with 100px padding.
  • Workflow-name-based .png download.
  • Nodes deselected and fully opaque.
  • Connectors rendered beneath all nodes.
  • Edge/background SVG styles preserved.
  • Invoke color scheme and grid preserved.
  • MiniMap, node status, and info icons omitted.
  • Input labels remain one line with full text.
  • Export guarded against duplicate clicks.
  • Added html-to-image.
  • Updated user documentation and translations.

QA Instructions

Build the frontend, click the camera icon when in Workflows. An empty workflow will render a small grid to the PNG, while a real workflow will render the entire workflow.

Related Issues / Discussions

Closes #5076

Merge Plan

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR)

@github-actions github-actions Bot added frontend-deps PRs that change frontend dependencies frontend PRs that change frontend files docs PRs that change docs labels Aug 14, 2026
@JPPhoto JPPhoto added 6.14.1 6.14 Nice-to-Have frontend PRs that change frontend files frontend-deps PRs that change frontend dependencies docs PRs that change docs and removed frontend-deps PRs that change frontend dependencies frontend PRs that change frontend files docs PRs that change docs labels Aug 14, 2026
@JPPhoto
JPPhoto force-pushed the workflow-screenshot branch from f08ea4a to ab58adb Compare August 14, 2026 22:04
@Pfannkuchensack Pfannkuchensack self-assigned this Aug 14, 2026
@Pfannkuchensack

Copy link
Copy Markdown
Member

Findings

1. Medium - a non-settling toBlob permanently disables the export button and leaks a full workflow DOM clone into the live document

invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:382-391 awaits toBlob with no timeout and no abort. invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:62-74 relies entirely on that promise settling: .finally(() => setIsExportingWorkflow(false)).

Chain:

  1. html-to-image@1.11.13 createImage is r.onload = function(){ r.decode().then(...) } with no rejection handler on decode(). If decode() rejects (the realistic trigger is an oversized serialized SVG from a large workflow), the promise it returns never resolves and never rejects.
  2. exportWorkflowAsPng's try/finally never reaches finally, so stagingWrapper.remove() never runs. A complete second copy of #workflow-editor stays appended to flowElement.parentElement for the rest of the session.
  3. .catch(...) and .finally(...) in ViewportControls never run, so isExportingWorkflow stays true and the camera IconButton stays isDisabled + isLoading (ViewportControls.tsx:101-102) until the editor remounts. No toast, no retry.

To expose this issue, add a test that stubs toBlob with a promise that never settles, races exportWorkflowAsPng against a deadline, and asserts the call rejects and that the staging wrapper has been removed. That test can only pass once a timeout is added around toBlob.

2. Medium - one un-embeddable image anywhere in the workflow aborts the whole export

getWorkflowExportOptions (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:178-187) sets neither imagePlaceholder nor onImageErrorHandler.

Chain:

  1. html-to-image walks every node and calls embedImageNode on each HTMLImageElement / SVGImageElement.
  2. Its resourceToDataURL catches the fetch failure, console.warns, and returns options.imagePlaceholder || ''.
  3. embedImageNode then does img.srcset = ''; img.src = '' and awaits onload/onerror. Empty src fires error, and because onImageErrorHandler is unset, onerror is the raw reject.
  4. The rejection propagates out of toBlob, so the entire PNG export fails.

Workflows routinely carry image references (invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/ImageFieldInputComponent.tsx, invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx). A single deleted or 404 image makes the camera button produce only nodes.downloadWorkflowImageError with no indication of the cause. html-to-image also caches the empty result module-globally, so a transient failure poisons every later export in the session.

To expose this issue, add a test that asserts getWorkflowExportOptions returns an imagePlaceholder (or onImageErrorHandler) so a failed image degrades to a placeholder instead of failing the export.

3. Medium - skipFonts: true renders the PNG in a fallback typeface

invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:186.

Invoke ships a webfont: import '@fontsource-variable/inter' at invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx:1. html-to-image serializes the clone into an SVG foreignObject encoded as a data:image/svg+xml URL and loads it through an <img>. SVG in <img> is secure static mode: no external resource loading. The only mechanism that would make Inter available is html-to-image's @font-face inlining, and skipFonts: true disables exactly that. The fontsource CSS is same-origin and bundled by Vite, so cssRules is readable and the inlining path would in fact succeed here.

font-family is copied (line 69), so the PNG asks for Inter Variable and gets the browser default instead. Different metrics also shift every label width, compounding finding 4. This contradicts the PR's "Invoke color scheme ... preserved" framing, which holds for color but not for type.

This is primarily a rendered-text concern and there is no approved DOM testing framework in this repo (no jsdom/happy-dom in invokeai/frontend/web/package.json), so it needs manual verification: export a workflow and compare the label typeface against the editor.

4. Medium - forced one-line field titles can be clipped at the image edge or overlap neighbouring nodes

setWorkflowExportInputFieldTitleStyles (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:278-285) forces white-space: nowrap, overflow: visible, text-overflow: clip on every [data-node-input-field-title="true"].

Chain:

  1. The node body has no clipping: invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNode.tsx:24-37 sets no overflow: hidden, and invokeai/frontend/web/src/features/nodes/components/flow/nodes/common/NodeWrapper.tsx:66-79 sets only borderRadius.
  2. So a label wider than the node renders past the node's right edge onto the canvas.
  3. getWorkflowContentBounds (workflowImageExport.ts:129-158) derives the canvas from getNodesBounds plus .react-flow__edge-path getBBox() only. Rendered text extents are never measured.
  4. For a right-most node, any overflow beyond EXPORT_PADDING (100, workflowImageExport.ts:4) is cut off at the image boundary; elsewhere it silently overlaps whatever is to the right.

This defeats the PR's stated goal ("Input labels remain one line with full text") in exactly the case the override exists for - labels too long to fit.

To expose this issue, add a test that exports getWorkflowContentBounds and asserts it widens the returned rect to cover a measured overflowing label, not only node bounds and edge bounding boxes.

5. Low - the includeStyleProperties allowlist silently drops flex-wrap and direction

EXPORT_STYLE_PROPERTIES (invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:6-98) replaces html-to-image's default "all computed properties" with a fixed 90-entry list. Anything absent is dropped from the capture.

Two confirmed consumers:

  • flex-wrap: invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/SavedWorkflowFieldInputComponent.tsx:146 renders <Flex alignItems="center" gap={1} flexWrap="wrap"> with a workflow name plus up to three badges inside a node body. In the export this collapses to nowrap and the badges are squashed or overflow.
  • direction: invokeai/frontend/web/src/app/hooks/useSyncLangDirection.ts:34 sets document.body.dir. RTL is a shipped feature (invokeai/frontend/web/public/locales/ar.json, invokeai/frontend/web/public/locales/he.json). The clone is serialized into a standalone foreignObject where that ancestor dir no longer applies, and direction is not in the list, so ar/he users get an LTR-laid-out PNG.

To expose this issue, add a test that asserts EXPORT_STYLE_PROPERTIES contains direction and flex-wrap.

6. Low - the grid geometry is duplicated from Flow.tsx with nothing binding the two

GRID_GAP = 25 at invokeai/frontend/web/src/features/nodes/util/workflowImageExport.ts:112 duplicates const snapGrid: [number, number] = [25, 25] at invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx:91, which is what <Background gap={snapGrid} offset={snapGrid} /> (Flow.tsx:525) actually uses. setBackgroundGridForExport (workflowImageExport.ts:241-244) additionally hardcodes cx/cy/r = 0.5, which is only correct because <Background> is left at its default size={1} and the export pins zoom to 1.

Changing snapGrid, or passing size/color to <Background>, desyncs the exported grid from the editor with no failing test and no compile error.

To expose this issue, add a test that imports snapGrid from the flow module (exporting it if necessary) and asserts it equals the export module's grid gap.

7. Low - the export error is discarded

invokeai/frontend/web/src/features/nodes/components/flow/panels/BottomLeftPanel/ViewportControls.tsx:73 is .catch(handleWorkflowImageExportError), and the handler at lines 47-49 takes no arguments. The thrown error is never logged. Combined with findings 1 and 2, a field failure produces one generic toast string and nothing actionable.

8. Low - the export puts a duplicate id="workflow-editor" (and every other id in the subtree) into the live document

workflowImageExport.ts:374 does flowElement.cloneNode(true), which copies ids, and line 380 appends it to flowElement.parentElement. flowElement is #workflow-editor itself (Flow.tsx:490; @xyflow/react applies the id prop to the wrapper div that also carries .react-flow).

This is currently benign: the only other consumer, invokeai/frontend/web/src/features/nodes/hooks/useBuildNode.ts:26, uses document.querySelector, which returns the earlier original. But it is invalid HTML for the duration of the export, duplicates every Chakra-generated aria-labelledby/aria-describedby target and every react-flow SVG marker id, and breaks the moment anyone reaches for getElementById or a nth-match query. The pattern already applied to the background pattern id (workflowImageExport.ts:230) is not applied to the root or to markers.

9. Low - the added tests do not test the export

invokeai/frontend/web/src/features/nodes/util/workflowImageExport.test.ts - 11 tests, all passing (verified, see Verification), but none of them can fail for a real defect in this feature:

  • getWorkflowContentBounds (workflowImageExport.ts:129), which decides what ends up cut off, is module-private and untested. So are prepareExportClone, setBackgroundGridForExport, inlineSvgStylesForExport, downloadPng, and exportWorkflowAsPng.
  • workflowImageExport.test.ts:66-70 ('preserves single-line field title styles in the export clone') asserts only that a literal array declared in the same module contains three strings. It touches no clone. It is also self-contradictory: setWorkflowExportInputFieldTitleStyles forces display: block, which makes the -webkit-line-clamp it asserts on inert.
  • The DOM helper tests (lines 87-138) stub querySelectorAll with a hand-rolled function that returns the element only for the exact selector string the implementation passes. A selector that stops matching real markup - for example if NodeWrapper gains a wrapper element and .react-flow__node > [data-is-selected] no longer matches - still passes.
  • sanitizeWorkflowImageFilename has two cases; there is no coverage for length capping.
  • The suite runs in vitest's default node environment (no jsdom/happy-dom in invokeai/frontend/web/package.json), so real-DOM assertions are not available today.

To expose the selector risk, add a test that asserts the exact selector strings the helpers query, pinned against the data-* attributes added in this PR, so removing data-node-status-indicator / data-node-info-icon / data-node-input-field-title from the components fails a test rather than silently degrading the image.

@JPPhoto
JPPhoto force-pushed the workflow-screenshot branch from ab58adb to 1f21fad Compare August 14, 2026 23:57
@JPPhoto

JPPhoto commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@Pfannkuchensack Thanks for the review. In the current commit:

  • No. 6 is not currently broken: grid gap and snap grid both equal 25. This is a maintainability risk only.
  • No. 7 is a valid low-severity issue: export errors are reduced to a generic toast; details are discarded.
  • No. 9 is a valid coverage gap, not a product defect. I'm adding new focused tests to expose missing behavior coverage.

All of the others are valid and I'm working on fixes now.

@JPPhoto

JPPhoto commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

@Pfannkuchensack Ready for a re-review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14 Nice-to-Have 6.14.1 docs PRs that change docs frontend PRs that change frontend files frontend-deps PRs that change frontend dependencies

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[enhancement]: Export workflow as image

2 participants