You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
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:
html-to-image@1.11.13createImage 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.
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.
.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:
html-to-image walks every node and calls embedImageNode on each HTMLImageElement / SVGImageElement.
Its resourceToDataURL catches the fetch failure, console.warns, and returns options.imagePlaceholder || ''.
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.
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
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:
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.
So a label wider than the node renders past the node's right edge onto the canvas.
getWorkflowContentBounds (workflowImageExport.ts:129-158) derives the canvas from getNodesBounds plus .react-flow__edge-pathgetBBox() only. Rendered text extents are never measured.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds camera-button workflow PNG export.
.pngdownload.html-to-image.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
What's Newcopy (if doing a release after this PR)