Compare commits

...

11 Commits

Author SHA1 Message Date
jaeone94
dff90de25d test: clarify execution error locate controls 2026-06-07 04:56:42 +09:00
jaeone94
d94fb3c1b7 fix: refine execution error presentation 2026-06-07 04:43:45 +09:00
jaeone94
e46be82b88 fix: simplify validation error rows 2026-06-07 02:10:09 +09:00
jaeone94
e7019146d1 fix: group validation errors by catalog id 2026-06-07 02:10:09 +09:00
jaeone94
0f27431c37 test: cover validation error catalog grouping 2026-06-07 02:10:09 +09:00
Dante
f8187cec4c test: e2e for on-node grid thumbnail previews (FE-741) (#12667)
## Summary

Adds an end-to-end (Playwright) test for the on-node image-grid
thumbnail behavior introduced in #12561 (FE-741), kept in its own PR so
#12561 stays scoped to the fix + unit tests.

#12561 makes small on-node grid cells request a lightweight thumbnail
URL (`preview=webp;75`, server-resized via `res` on cloud) instead of
downloading the full-resolution `/api/view` image. That PR covers the
helper and component with unit tests. This PR adds the missing
**integration** coverage: it drives a real `Preview Image` node in the
browser, injects a multi-image grid over the websocket, and asserts the
rendered grid `<img>` elements request thumbnails.

It exercises the full path the unit tests can't:

`executed` WS output → `nodeOutputStore.buildImageUrls` →
`getGridThumbnailUrl` → rendered grid `<img>` `src`

## What it checks

- A 4-image `Preview Image` grid renders 4 cells (`viewMode ===
'grid'`).
- Every grid cell `<img>` `src` carries the compact thumbnail format
(`preview=webp;75`; the `;` may be percent-encoded as `%3B`).
- Each `src` still points at the real `/api/view` URL for that output
(`filename=grid-<n>.png`), confirming it's the thumbnailed view URL, not
a placeholder/blob.

Lives next to the existing batch-preview test in
`browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts`,
reusing the `ExecutionHelper` + `webSocketFixture` injection pattern.

## Notes

- **Stacked on #12561** (`jaewon/fe-741-onnode-grid-thumbnail-preview`).
Without that fix the grid cells use the full-res URL and this test is
red — i.e. it's a true regression guard. Retarget to `main` once #12561
merges.
- Gallery/full-view URLs staying at full resolution is already covered
by the unit tests in #12561 (`currentImageUrl` is left untransformed);
this test deliberately scopes to the grid path to avoid depending on
injected previews actually resolving on the backend.

## Test Plan

- [x] `pnpm typecheck:browser` clean
- [x] ESLint + oxlint clean
- [ ] e2e CI (`ci-tests-e2e`) green on this PR
2026-06-06 13:10:44 +00:00
Terry Jia
7e61358724 FE-905 fix(load3d): cache scene capture so unchanged runs hit backend cache (#12627)
## Summary
The scene widget's serializeValue uploaded a fresh temp image on every
queue, so the `image / mask / normal` filenames in the prompt JSON were
new each run. The backend cache key (which hashes those input strings)
never matched, forcing Load3D and every downstream node to re-execute
even when the user changed nothing.

Track a session-scoped dirty flag and last-output cache in module-level
WeakMaps keyed by LGraphNode. serializeValue returns the cached output
when nothing has changed; user actions that mutate the visible scene
(scene/model/camera/light config, animation, recording, gizmo, camera
orbit) mark dirty through useLoad3d watchers and event handlers. The
model_file / width / height widget callbacks invalidate via a new
optional onSceneInvalidated hook plumbed through Load3DConfiguration, so
the captured screenshot stays consistent with the inputs the backend
sees.

## Screenshots (if applicable)
Before

https://github.com/user-attachments/assets/5ee5f79f-dd38-401e-babe-4d6ea156e56d

After

https://github.com/user-attachments/assets/5e00beb4-937c-4c66-abb2-e455f5301de6
2026-06-06 11:58:52 +00:00
Alexander Brown
ff9e6415b5 fix(nodes-2): apply Textarea widget font-size setting in Vue Nodes 2.0 (#12386)
*PR Created by the Glary-Bot Agent*

---

## Summary

`Settings → Appearance → Node Widget → Textarea widget font size`
(`Comfy.TextareaWidget.FontSize`) was wired through the legacy LiteGraph
textarea only. The Vue Nodes 2.0 `WidgetTextarea.vue` hardcoded Tailwind
`text-xs`, so once Vue nodes were enabled the slider had no effect.

`GraphView.vue` already writes the setting value to
`--comfy-textarea-font-size` on `:root` for the legacy
`.comfy-multiline-input` rule. This PR makes `WidgetTextarea` consume
the same variable via Tailwind v4's parenthesized CSS-variable
shorthand, keeping `GraphView` as the single source of truth.

- `text-xs` → `text-(length:--comfy-textarea-font-size) leading-normal`.
The `length:` type hint is required because `text-` is ambiguous between
`font-size` and `color`. `leading-normal` keeps line-height proportional
to font-size across the 8–24 px range so multi-line text doesn't clip at
the high end.
- Initialize `--comfy-textarea-font-size: 10px` on `:root` in the
design-system stylesheet so isolated renders (Storybook, tests) that do
not mount `GraphView` still pick up the documented default.

- Fixes
[FE-799](https://linear.app/comfyorg/issue/FE-799/bug-textarea-widget-font-size-setting-not-working-in-nodes-20)

## Verification

- `pnpm typecheck`, `pnpm lint`, `pnpm exec stylelint`, `pnpm exec oxfmt
--check`, `pnpm knip`, and `WidgetTextarea.test.ts` (20 tests) all pass.
- Manual browser verification with Vue Nodes 2.0 enabled and a
`CLIPTextEncode` node:
  - setting `8` → computed `font-size: 8px`
  - setting `22` → computed `font-size: 22px`
- setting `24` → computed `font-size: 24px`, computed `line-height:
36px` (ratio 1.5, no clipping)
- Confirmed the legacy LiteGraph path still resolves to
`comfy-multiline-input` with `font-size: 22px` when Vue Nodes is
disabled (no regression).
- Confirmed the `:root` default resolves to `10px` when `GraphView`'s
inline override is absent (Storybook-like environments).

## Out of scope (follow-up)

`WidgetMarkdown.vue` (the Vue Nodes 2.0 markdown/tiptap widget) also
hardcodes `text-sm`. The legacy `.comfy-markdown .tiptap` rule reads the
same `--comfy-textarea-font-size` variable, so the setting historically
governed markdown widgets in Nodes 1.0. Bringing that into line with
this PR's approach is a follow-up the design team should weigh in on
before changing.

## Screenshots


![textarea-fontsize-8px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449630-3046bbe9-cb29-41f7-8994-9d251bd0ab5d.png)


![textarea-fontsize-22px](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307449987-46aed4a1-b09c-4b2e-88cf-e6302944c319.png)


![textarea-fontsize-24px-multiline](https://pub-1fd11710d4c8405b948c9edc4287a3f2.r2.dev/sessions/6f2a0d3f4193afb5583ebf6c01c558f759abf5059f5f0535c4168e081a68ea47/pr-images/1779307450337-164136c9-b1e2-4dac-8390-4d935d416675.png)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12386-fix-nodes-2-apply-Textarea-widget-font-size-setting-in-Vue-Nodes-2-0-3666d73d365081fd8084e84a41ee357b)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Glary-Bot <glary-bot@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2026-06-06 00:26:51 +00:00
Terry Jia
c695aa1ee0 fix(load3d): load Preview3DAdvanced / splat / pointcloud previews from temp/ (#12671)
BE is https://github.com/Comfy-Org/ComfyUI/pull/14294, need FE as well

Pair with backend
[BE-1172](https://github.com/Comfy-Org/ComfyUI/pull/14294). Two changes
bundled because both touch the same Preview3DAdvanced /
PreviewGaussianSplat / PreviewPointCloud

1. Backend now saves the incoming File3D into temp/ instead of output/
2. viewport input renamed from 'image' to 'viewport_state', Backend
renamed IO.Load3D.Input("image") to ("viewport_state") on the three
nodes (the field carries the viewport snapshot).
3. Exsting Load3D / Preview3D keep 'image' for workflow JSON
compatibility.
2026-06-05 20:04:09 -04:00
Willie
b907423526 feat: add Arrange action to multi-select toolbox (#12068)
## Summary

Adds an Arrange popover to the multi-select toolbox that repositions
selected nodes into vertical, horizontal, or grid layouts, with a
follow-up slider to tune the spacing.



https://github.com/user-attachments/assets/f9a55ef9-2619-462b-a83f-2b86eb076fe3


## Changes

- **What**: New `ArrangeButton` placed between the color picker and
frame icons. Three layouts (vertical, horizontal, grid) sort selected
nodes by current position and lay them out from the smallest-`x+y`
anchor with a 12-unit gap. Visual bounds account for
`LiteGraph.NODE_TITLE_HEIGHT` so titles don't overlap stacked bodies.
After picking a layout, the popover swaps to a 0–48 spacing slider —
drag previews live (rAF-throttled, no undo capture), release commits one
undo entry. Closing the popover ends the session.
- **Breaking**: none
- **Dependencies**: none

## Review Focus

- `useArrangeNodes.computeArrangement` — pure layout math separated from
side effects; covered by 8 unit tests including the title-height
handling and `TitleMode.NO_TITLE` case.
- `useArrangeSession` — owns the slider's state machine. rAF-throttled
`previewGap` collapses rapid drag events into one frame; `commitGap`
cancels any pending preview before capturing undo. Covered by 5 unit
tests.
- Position mutations go through `useLayoutMutations().batchMoveNodes`
(single batched layout-store transaction) followed by
`changeTracker.captureCanvasState()` for undo — same pattern as drag and
other selection-toolbox actions.
- Anchor selection uses smallest `pos.x + pos.y` rather than min-x or
min-y alone, to keep the layout origin stable across re-runs (slider
drags).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12068-feat-add-Arrange-action-to-multi-select-toolbox-3596d73d365081e58c25ffac41dc0b2a)
by [Unito](https://www.unito.io)

---------

Co-authored-by: AustinMroz <austin@comfy.org>
2026-06-05 21:56:54 +00:00
AustinMroz
7d99189211 Ensure dropdowns display over selection toolbox (#12513)
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/a7fc3432-3db3-40a5-b28e-11a309db76ce"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/a6a702cd-2ba3-4900-8afa-227cd9d61492"
/>|

Dropdowns were appended to self, but the selectionToolbox isappended to
body. As a result, changes to z-index on the dropdown would not allow it
to display above the selectionToolbox. Since dropdowns have been
migrated to reka-ui, dropdowns can now be safely appended to body as
well. Doing so cleans up a lot of no-longer-needed code. Of note
WidgetSelectDropdown seemed to never actually bind the `appendTo` and is
unaffected by the removal

As a secondary consequence of this change, dropdowns will no longer
scale with the current zoom level of the graph. Since litegraph would
not scale the size of popovers, this had been reported as a regression
by some users.
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/0cb200c2-0811-4023-9ff1-aaa61113cbd5"/>
| <img width="360" alt="after"
src="https://github.com/user-attachments/assets/c82c087f-1f25-49c5-a85d-d9502b438526"
/>|
2026-06-05 21:49:03 +00:00
49 changed files with 2179 additions and 463 deletions

View File

@@ -1,4 +1,4 @@
import type { Page } from '@playwright/test'
import type { Locator, Page } from '@playwright/test'
import { SELECTION_BOUNDS_PADDING } from '@/base/common/selectionBounds'
import type { CanvasRect } from '@/base/common/selectionBounds'
@@ -91,3 +91,21 @@ export async function measureSelectionBounds(
{ ids: nodeIds, padding: SELECTION_BOUNDS_PADDING }
) as Promise<MeasureResult>
}
export async function intersection(a: Locator, b: Locator) {
const aBounds = await a.boundingBox()
const bBounds = await b.boundingBox()
if (!aBounds || !bBounds) return undefined
const y = Math.max(aBounds.y, bBounds.y)
const x = Math.max(aBounds.x, bBounds.x)
const bot = Math.min(aBounds.y + aBounds.height, bBounds.y + bBounds.height)
const right = Math.min(aBounds.x + aBounds.width, bBounds.x + bBounds.width)
if (y > bot || x > right) return undefined
const width = right - x
const height = bot - y
return { x, y, width, height }
}

View File

@@ -0,0 +1,63 @@
import { expect } from '@playwright/test'
import { ExecutionHelper } from '@e2e/fixtures/helpers/ExecutionHelper'
import { load3dTest as test } from '@e2e/fixtures/helpers/Load3DFixtures'
type Load3dImageInput = {
image: string
mask: string
normal: string
recording: string
}
type PromptBody = {
prompt?: Record<
string,
{ class_type?: string; inputs?: Record<string, unknown> }
>
}
function getLoad3dImageInput(body: unknown, nodeId: string): Load3dImageInput {
const prompt = (body as PromptBody).prompt ?? {}
const node = prompt[nodeId]
expect(node?.class_type, `node ${nodeId} should be Load3D`).toBe('Load3D')
const input = node!.inputs!.image as Load3dImageInput
expect(typeof input.image).toBe('string')
expect(typeof input.recording).toBe('string')
return input
}
test.describe('Load3D serialize cache', () => {
test('starting a recording forces the next queue to re-capture (FE-905)', async ({
comfyPage,
load3d
}) => {
const exec = new ExecutionHelper(comfyPage)
let firstBody: unknown
await exec.run({
onPromptRequest: (body) => {
firstBody = body
}
})
const firstInput = getLoad3dImageInput(firstBody, '1')
expect(firstInput.recording).toBe('')
await load3d.recordingButton.click()
await expect(load3d.stopRecordingButton).toBeVisible()
let secondBody: unknown
await exec.run({
onPromptRequest: (body) => {
secondBody = body
}
})
const secondInput = getLoad3dImageInput(secondBody, '1')
expect(
secondInput.image,
'after starting a recording, the next queue must re-capture ' +
'(image filename must change) so the recording is not dropped'
).not.toBe(firstInput.image)
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await comfyPage.setup()
})
test('Should filter execution errors by search query', async ({
test('Should keep execution errors matching the search query', async ({
comfyPage
}) => {
await comfyPage.workflow.loadWorkflow('nodes/execution_error')
@@ -62,9 +62,9 @@ test.describe('Errors tab - common', { tag: '@ui' }, () => {
await expect(runtimePanel).toBeVisible()
const searchInput = comfyPage.page.getByPlaceholder(/^Search/)
await searchInput.fill('nonexistent_query_xyz_12345')
await searchInput.fill('Execution failed')
await expect(runtimePanel).toHaveCount(0)
await expect(runtimePanel).toBeVisible()
})
})
})

View File

@@ -41,7 +41,7 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
).toBeVisible()
})
test('Should show error message in runtime error panel', async ({
test('Should show runtime error log in the execution error group', async ({
comfyPage
}) => {
await openExecutionErrorTab(comfyPage)
@@ -50,6 +50,6 @@ test.describe('Errors tab - Execution errors', { tag: '@ui' }, () => {
TestIds.dialogs.runtimeErrorPanel
)
await expect(runtimePanel).toBeVisible()
await expect(runtimePanel).toContainText(/\S/)
await expect(runtimePanel).toContainText('Error log')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -0,0 +1,30 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
test.describe(
'Textarea widget font size',
{ tag: ['@widget', '@vue-nodes'] },
() => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow('default')
})
test('applies Comfy.TextareaWidget.FontSize to Vue Nodes 2.0 textarea widget', async ({
comfyPage
}) => {
const textarea = comfyPage.vueNodes.nodes.locator('textarea').first()
await expect(textarea).toBeVisible()
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 14)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('14px')
await comfyPage.settings.setSetting('Comfy.TextareaWidget.FontSize', 22)
await expect
.poll(() => textarea.evaluate((el) => getComputedStyle(el).fontSize))
.toBe('22px')
})
}
)

View File

@@ -180,4 +180,44 @@ test.describe('Vue Nodes Batch Image Preview', { tag: '@vue-nodes' }, () => {
await expect.poll(() => countColumns(node.imageGrid)).toBeLessThan(10)
}
)
wstest(
'requests lightweight thumbnail URLs for grid cells',
async ({ comfyPage, getWebSocket }) => {
const execution = new ExecutionHelper(comfyPage, await getWebSocket())
await test.step('Add node', async () => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Preview Image')
const previewImage = comfyPage.vueNodes.getNodeByTitle('Preview Image')
await expect(previewImage).toBeVisible()
})
const node = await comfyPage.vueNodes.getFixtureByTitle('Preview Image')
const gridImages = node.imageGrid.locator('img')
await test.step('Inject a multi-image grid', async () => {
const images = Array.from({ length: 4 }, (_, index) => ({
filename: `grid-${index}.png`,
subfolder: '',
type: 'output'
}))
execution.executed('', '1', { images })
await expect(gridImages).toHaveCount(4)
})
// FE-741: small on-node grid cells must request a server re-encoded
// thumbnail (`preview=webp;75`, `;` may be percent-encoded) instead of
// downloading the full-resolution image, while still pointing at the
// real `/api/view` URL for that output. Verifies the full path: WS
// output -> nodeOutputStore.buildImageUrls -> getGridThumbnailUrl ->
// rendered grid `<img>`.
for (const cell of await gridImages.all()) {
await expect(cell).toHaveAttribute('src', /[?&]preview=webp(%3B|;)75/)
await expect(cell).toHaveAttribute('src', /[?&]filename=grid-\d+\.png/)
}
}
)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

View File

@@ -1,10 +1,12 @@
import type { Locator } from '@playwright/test'
import {
comfyExpect as expect,
comfyPageFixture as test
} from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
import type { Locator } from '@playwright/test'
import { intersection } from '@e2e/fixtures/utils/boundsUtils'
test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
async function openSamplerDropdown(comfyPage: ComfyPage) {
@@ -278,4 +280,31 @@ test.describe('Vue Combo Widget', { tag: ['@vue-nodes', '@widget'] }, () => {
.getByRole('combobox', { name: 'scheduler', exact: true })
await expect(schedulerComboAfterReload).toContainText('karras')
})
test('Dropdown displays over Selection Toolbox', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
const nodeName = 'Resize Image/Mask'
await comfyPage.searchBoxV2.addNode(nodeName, {
position: { x: 200, y: 630 }
})
const node = await comfyPage.vueNodes.getFixtureByTitle(nodeName)
await node.select()
await expect(comfyPage.selectionToolbox).toBeVisible()
const combo = comfyPage.vueNodes.getWidgetByName(nodeName, 'resize_type')
await combo.click()
const dropdown = comfyPage.page.getByTestId(
TestIds.widgets.selectDefaultViewport
)
await expect(dropdown).toBeVisible()
const bounds = (await intersection(dropdown, comfyPage.selectionToolbox))!
expect(bounds, 'toolbox and dropdown overlap').toBeDefined()
const cX = bounds.x + bounds.width / 2
const cY = bounds.y + bounds.height / 2
const dropdownBounds = (await dropdown.boundingBox())!
const position = { x: cX - dropdownBounds.x, y: cY - dropdownBounds.y }
await dropdown.click({ position, trial: true })
})
})

View File

@@ -121,6 +121,7 @@
--comfy-topbar-height: 2.5rem;
--workflow-tabs-height: 2.375rem;
--comfy-input-bg: #222;
--comfy-textarea-font-size: 10px;
--input-text: #ddd;
--descrip-text: #999;
--drag-text: #ccc;

View File

@@ -8,7 +8,6 @@ import { useI18n } from 'vue-i18n'
import Popover from '@/components/ui/Popover.vue'
import Button from '@/components/ui/button/Button.vue'
import { extractVueNodeData } from '@/composables/graph/useGraphNodeManager'
import { OverlayAppendToKey } from '@/composables/useTransformCompatOverlayProps'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { LGraphEventMode } from '@/lib/litegraph/src/types/globalEnums'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
@@ -50,7 +49,6 @@ const { onPointerDown } = useAppModeWidgetResizing((widget, config) =>
)
provide(HideLayoutFieldKey, true)
provide(OverlayAppendToKey, 'body')
const resolvedInputs = useResolvedSelectedInputs()

View File

@@ -20,6 +20,7 @@
<InfoButton v-if="canOpenNodeInfo" />
<ColorPickerButton v-if="showColorPicker" />
<ArrangeButton v-if="showArrange" />
<FrameNodes v-if="showFrameNodes" />
<ConvertToSubgraphButton v-if="showConvertToSubgraph" />
<ConfigureSubgraph v-if="showSubgraphButtons" />
@@ -49,6 +50,7 @@
import Panel from 'primevue/panel'
import { computed, ref } from 'vue'
import ArrangeButton from '@/components/graph/selectionToolbox/ArrangeButton.vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConfigureSubgraph from '@/components/graph/selectionToolbox/ConfigureSubgraph.vue'
@@ -110,6 +112,7 @@ const {
const showColorPicker = computed(() => hasAnySelection.value)
const showConvertToSubgraph = computed(() => hasAnySelection.value)
const showArrange = computed(() => hasMultipleSelection.value)
const showFrameNodes = computed(() => hasMultipleSelection.value)
const showSubgraphButtons = computed(() => isSingleSubgraph.value)
@@ -128,6 +131,7 @@ const showAnyPrimaryActions = computed(
() =>
showColorPicker.value ||
showConvertToSubgraph.value ||
showArrange.value ||
showFrameNodes.value ||
showSubgraphButtons.value
)

View File

@@ -0,0 +1,115 @@
<template>
<PopoverRoot v-model:open="isOpen">
<PopoverTrigger as-child>
<Button
v-tooltip.top="{ value: t('g.arrange'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrange')"
>
<div class="flex items-center gap-1 px-0">
<i class="icon-[lucide--layout-grid]" />
<i class="icon-[lucide--chevron-down]" />
</div>
</Button>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent
side="bottom"
:side-offset="5"
:collision-padding="10"
class="data-[state=open]:data-[side=top]:animate-slideDownAndFade data-[state=open]:data-[side=right]:animate-slideLeftAndFade data-[state=open]:data-[side=bottom]:animate-slideUpAndFade data-[state=open]:data-[side=left]:animate-slideRightAndFade z-1700 rounded-lg border border-border-subtle bg-base-background p-2 shadow-sm will-change-[transform,opacity]"
>
<div
v-if="activeLayout"
class="flex w-32 flex-row items-center px-2 py-1"
>
<Slider
:model-value="[gap]"
:min="MIN_ARRANGE_GAP"
:max="MAX_ARRANGE_GAP"
:step="1"
:aria-label="t('g.arrangeSpacing')"
@update:model-value="onSliderUpdate"
@value-commit="onSliderCommit"
/>
</div>
<div v-else class="flex flex-row gap-1">
<Button
v-tooltip.top="{
value: t('g.arrangeVertically'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeVertically')"
@click="start('vertical')"
>
<i class="icon-[lucide--stretch-horizontal]" />
</Button>
<Button
v-tooltip.top="{
value: t('g.arrangeHorizontally'),
showDelay: 1000
}"
variant="muted-textonly"
:aria-label="t('g.arrangeHorizontally')"
@click="start('horizontal')"
>
<i class="icon-[lucide--stretch-vertical]" />
</Button>
<Button
v-tooltip.top="{ value: t('g.arrangeAsGrid'), showDelay: 1000 }"
variant="muted-textonly"
:aria-label="t('g.arrangeAsGrid')"
@click="start('grid')"
>
<i class="icon-[lucide--grid-3x3]" />
</Button>
</div>
<PopoverArrow class="fill-base-background stroke-border-subtle" />
</PopoverContent>
</PopoverPortal>
</PopoverRoot>
</template>
<script setup lang="ts">
import {
PopoverArrow,
PopoverContent,
PopoverPortal,
PopoverRoot,
PopoverTrigger
} from 'reka-ui'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import Slider from '@/components/ui/slider/Slider.vue'
import {
MAX_ARRANGE_GAP,
MIN_ARRANGE_GAP
} from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const { t } = useI18n()
const { activeLayout, gap, start, previewGap, commitGap, reset } =
useArrangeSession()
const isOpen = ref(false)
watch(isOpen, (open) => {
if (!open) reset()
})
const firstValue = (value: number[] | undefined): number | undefined =>
value?.[0]
const onSliderUpdate = (value: number[] | undefined) => {
const next = firstValue(value)
if (next !== undefined) previewGap(next)
}
const onSliderCommit = (value: number[]) => {
const next = firstValue(value)
if (next !== undefined) commitGap(next)
}
</script>

View File

@@ -2,20 +2,12 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ErrorNodeCard from './ErrorNodeCard.vue'
import type { ErrorCardData } from './types'
/**
* ErrorNodeCard displays a single error card inside the error tab.
* It shows the node header (ID badge, title, action buttons)
* and the list of error items (message, traceback, copy button).
*/
const meta: Meta<typeof ErrorNodeCard> = {
title: 'RightSidePanel/Errors/ErrorNodeCard',
component: ErrorNodeCard,
parameters: {
layout: 'centered'
},
argTypes: {
showNodeIdBadge: { control: 'boolean' }
},
decorators: [
(story) => ({
components: { story },
@@ -105,58 +97,36 @@ const promptOnlyCard: ErrorCardData = {
]
}
/** Single validation error with node ID badge visible */
export const WithNodeIdBadge: Story = {
export const SingleValidationError: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
}
}
/** Single validation error without node ID badge */
export const WithoutNodeIdBadge: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: false
card: singleErrorCard
}
}
/** Subgraph node error — shows "Enter subgraph" button */
export const WithEnterSubgraphButton: Story = {
args: {
card: subgraphErrorCard,
showNodeIdBadge: true
}
}
/** Regular node error — no "Enter subgraph" button */
export const WithoutEnterSubgraphButton: Story = {
args: {
card: singleErrorCard,
showNodeIdBadge: true
card: subgraphErrorCard
}
}
/** Multiple validation errors on one node */
export const MultipleErrors: Story = {
args: {
card: multipleErrorsCard,
showNodeIdBadge: true
card: multipleErrorsCard
}
}
/** Runtime execution error with full traceback */
export const RuntimeError: Story = {
args: {
card: runtimeErrorCard,
showNodeIdBadge: true
card: runtimeErrorCard
}
}
/** Prompt-level error (no node header) */
export const PromptError: Story = {
args: {
card: promptOnlyCard,
showNodeIdBadge: false
card: promptOnlyCard
}
}

View File

@@ -71,6 +71,7 @@ describe('ErrorNodeCard.vue', () => {
en: {
g: {
copy: 'Copy',
details: 'Details',
findIssues: 'Find Issues',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
@@ -78,6 +79,7 @@ describe('ErrorNodeCard.vue', () => {
rightSidePanel: {
locateNode: 'Locate Node',
enterSubgraph: 'Enter Subgraph',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues for related problems',
getHelpTooltip:
'Report this error and we\u0027ll help you resolve it'
@@ -96,8 +98,9 @@ describe('ErrorNodeCard.vue', () => {
) {
const user = userEvent.setup()
const onCopyToClipboard = vi.fn()
const onLocateNode = vi.fn()
render(ErrorNodeCard, {
props: { card, onCopyToClipboard },
props: { card, onCopyToClipboard, onLocateNode },
global: {
plugins: [
PrimeVue,
@@ -131,14 +134,20 @@ describe('ErrorNodeCard.vue', () => {
})
],
stubs: {
TransitionCollapse: { template: '<div><slot /></div>' },
Button: {
template:
'<button :aria-label="$attrs[\'aria-label\']"><slot /></button>'
template: '<button v-bind="$attrs"><slot /></button>'
}
}
}
})
return { user, onCopyToClipboard }
return { user, onCopyToClipboard, onLocateNode }
}
async function toggleRuntimeDetails(
user: ReturnType<typeof userEvent.setup>
) {
await user.click(screen.getByRole('button', { name: /Details/ }))
}
let cardIdCounter = 0
@@ -160,40 +169,67 @@ describe('ErrorNodeCard.vue', () => {
}
}
function makeValidationErrorCard(): ErrorCardData {
function makePromptErrorCard(): ErrorCardData {
return {
id: `node-${++cardIdCounter}`,
title: 'CLIPTextEncode',
nodeId: '6',
nodeTitle: 'CLIP Text Encode',
id: '__prompt__',
title: 'Prompt has no outputs',
errors: [
{
message: 'Required input is missing',
details: 'Input: text'
message: 'Server Error: No outputs',
details: 'Error details',
displayMessage:
'The workflow does not contain any output nodes to produce a result.'
}
]
}
}
it('displays enriched report for runtime errors on mount', async () => {
it('shows runtime details by default and can collapse them', async () => {
const reportText =
'# ComfyUI Error Report\n## System Information\n- OS: Linux'
mockGenerateErrorReport.mockReturnValue(reportText)
renderCard(makeRuntimeErrorCard())
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.queryByRole('listitem')).not.toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
const detailsButton = screen.getByRole('button', { name: /Details/ })
const detailsRegion = screen.getByRole('region', { name: 'Error log' })
expect(detailsButton).toHaveAttribute(
'aria-controls',
detailsRegion.getAttribute('id')
)
expect(screen.getByText(/ComfyUI Error Report/)).toBeInTheDocument()
expect(screen.getByText(/System Information/)).toBeInTheDocument()
expect(screen.getByText(/OS: Linux/)).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
await toggleRuntimeDetails(user)
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: /Find on GitHub/ })
).not.toBeInTheDocument()
})
it('locates the node when the runtime node title is clicked', async () => {
const { user, onLocateNode } = renderCard(makeRuntimeErrorCard())
await user.click(screen.getByRole('button', { name: 'KSampler' }))
expect(onLocateNode).toHaveBeenCalledWith('10')
})
it('does not generate report for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(mockGetLogs).not.toHaveBeenCalled()
@@ -201,15 +237,15 @@ describe('ErrorNodeCard.vue', () => {
})
it('displays original details for non-runtime errors', async () => {
renderCard(makeValidationErrorCard())
renderCard(makePromptErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
expect(screen.getByText('Error details')).toBeInTheDocument()
})
expect(screen.queryByText(/ComfyUI Error Report/)).not.toBeInTheDocument()
})
it('displays catalog-resolved copy when available', async () => {
it('hides grouped catalog copy and shows the item label as a list item', async () => {
renderCard({
id: `node-${++cardIdCounter}`,
title: 'KSampler',
@@ -229,17 +265,17 @@ describe('ErrorNodeCard.vue', () => {
})
await waitFor(() => {
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('KSampler - model')).toBeInTheDocument()
})
expect(screen.getByRole('listitem')).toHaveTextContent('KSampler - model')
expect(screen.queryByText('Missing connection')).not.toBeInTheDocument()
expect(
screen.getByText('Required input slots have no connection feeding them.')
).toBeInTheDocument()
screen.queryByText(
'Required input slots have no connection feeding them.'
)
).not.toBeInTheDocument()
expect(
screen.getByText('KSampler is missing a required input: model')
).toBeInTheDocument()
expect(screen.queryByText('KSampler - model')).not.toBeInTheDocument()
expect(
screen.queryByText('Required input is missing')
screen.queryByText('KSampler is missing a required input: model')
).not.toBeInTheDocument()
})
@@ -250,8 +286,9 @@ describe('ErrorNodeCard.vue', () => {
const { user, onCopyToClipboard } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Full Report Content/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /Copy/ }))
@@ -261,21 +298,6 @@ describe('ErrorNodeCard.vue', () => {
)
})
it('copies original details when copy button is clicked for validation error', async () => {
const { user, onCopyToClipboard } = renderCard(makeValidationErrorCard())
await waitFor(() => {
expect(screen.getByText('Input: text')).toBeInTheDocument()
})
await user.click(screen.getByRole('button', { name: /Copy/ }))
expect(onCopyToClipboard).toHaveBeenCalledTimes(1)
expect(onCopyToClipboard.mock.calls[0][0]).toBe(
'Required input is missing\n\nInput: text'
)
})
it('generates report with fallback logs when getLogs fails', async () => {
mockGetLogs.mockRejectedValue(new Error('Network error'))
@@ -300,8 +322,9 @@ describe('ErrorNodeCard.vue', () => {
renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
it('opens GitHub issues search when Find Issue button is clicked', async () => {
@@ -310,9 +333,7 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Find on GitHub/ })
).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Find on GitHub/ }))
@@ -335,9 +356,7 @@ describe('ErrorNodeCard.vue', () => {
const { user } = renderCard(makeRuntimeErrorCard())
await waitFor(() => {
expect(
screen.getByRole('button', { name: /Get Help/ })
).toBeInTheDocument()
expect(mockGenerateErrorReport).toHaveBeenCalledOnce()
})
await user.click(screen.getByRole('button', { name: /Get Help/ }))
@@ -398,9 +417,7 @@ describe('ErrorNodeCard.vue', () => {
}
})
await waitFor(() => {
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
})
expect(screen.getByText(/Traceback line 1/)).toBeInTheDocument()
expect(mockGenerateErrorReport).not.toHaveBeenCalled()
})

View File

@@ -1,18 +1,19 @@
<template>
<div class="flex min-h-0 flex-1 flex-col overflow-hidden">
<!-- Card Header -->
<div
v-if="card.nodeId && !compact"
class="flex flex-wrap items-center gap-2 py-2"
>
<span
v-if="showNodeIdBadge"
class="shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
<button
v-if="hasRuntimeError && (card.nodeTitle || card.title)"
type="button"
class="m-0 min-w-0 flex-1 cursor-pointer appearance-none truncate border-0 bg-transparent p-0 text-left text-sm font-medium text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
#{{ card.nodeId }}
</span>
{{ card.nodeTitle || card.title }}
</button>
<span
v-if="card.nodeTitle || card.title"
v-else-if="card.nodeTitle || card.title"
class="flex-1 truncate text-sm font-medium text-muted-foreground"
>
{{ card.nodeTitle || card.title }}
@@ -27,6 +28,24 @@
>
{{ t('rightSidePanel.enterSubgraph') }}
</Button>
<Button
v-if="hasRuntimeError"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-8 shrink-0 text-muted-foreground hover:text-base-foreground',
runtimeDetailsExpanded &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="t('g.details')"
:aria-controls="runtimeDetailsControlIds || undefined"
:aria-expanded="runtimeDetailsExpanded"
@click.stop="toggleRuntimeDetails"
>
<i class="icon-[lucide--monitor-x] size-4" />
</Button>
<Button
variant="textonly"
size="icon-sm"
@@ -39,120 +58,143 @@
</div>
</div>
<!-- Multiple Errors within one Card -->
<div
class="flex min-h-0 flex-1 flex-col space-y-4 divide-y divide-interface-stroke/20"
>
<!-- Card Content -->
<div
v-for="(error, idx) in card.errors"
:key="idx"
:class="
cn(
'flex min-h-0 flex-col gap-3',
fullHeight && error.isRuntimeError && 'flex-1'
)
"
class="flex min-h-0 flex-col gap-3"
>
<!-- Human-friendly category/title when resolved by the error catalog. -->
<p
v-if="error.displayTitle"
class="m-0 px-0.5 text-sm font-semibold text-destructive-background-hover"
>
{{ error.displayTitle }}
</p>
<!-- Error Message -->
<p
v-if="getDisplayMessage(error)"
v-if="getInlineMessage(error)"
class="m-0 max-h-[4lh] overflow-y-auto px-0.5 text-sm/relaxed wrap-break-word whitespace-pre-wrap"
>
{{ getDisplayMessage(error) }}
{{ getInlineMessage(error) }}
</p>
<!-- Traceback / Details (enriched with full report for runtime errors) -->
<ul
v-if="getInlineItemLabel(error)"
class="m-0 list-disc space-y-1 pl-5 text-sm/relaxed text-muted-foreground marker:text-muted-foreground"
>
<li class="min-w-0 wrap-break-word">
<button
v-if="card.nodeId"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode"
>
{{ getInlineItemLabel(error) }}
</button>
<span v-else>
{{ getInlineItemLabel(error) }}
</span>
</li>
</ul>
<div
v-if="displayedDetailsMap[idx]"
v-if="!error.isRuntimeError && getInlineDetails(error, idx)"
:class="
cn(
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background-hover p-2.5',
error.isRuntimeError
? fullHeight
? 'min-h-0 flex-1'
: 'max-h-[15lh]'
: 'max-h-[6lh]'
'overflow-y-auto rounded-lg border border-interface-stroke/30 bg-secondary-background p-2.5',
'max-h-[6lh]'
)
"
>
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ displayedDetailsMap[idx] }}
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex gap-2">
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="secondary"
size="sm"
class="h-8 w-2/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
{{ t('g.findOnGithub') }}
<i class="icon-[lucide--github] size-3.5" />
</Button>
<Button
variant="secondary"
size="sm"
class="h-8 w-1/3 justify-center gap-1 rounded-lg text-xs"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
{{ t('g.copy') }}
<i class="icon-[lucide--copy] size-3.5" />
</Button>
</div>
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="secondary"
size="sm"
class="h-8 w-full justify-center gap-1 rounded-lg text-xs"
@click="handleGetHelp"
<TransitionCollapse>
<div
v-if="error.isRuntimeError && isRuntimeDisclosureExpanded"
:id="getRuntimeDetailsId(idx)"
role="region"
data-testid="runtime-error-panel"
:aria-label="t('rightSidePanel.errorLog')"
class="flex min-h-0 flex-col gap-3"
>
{{ t('g.getHelpAction') }}
<i class="icon-[lucide--external-link] size-3.5" />
</Button>
</div>
<div
v-if="getInlineDetails(error, idx)"
class="overflow-hidden rounded-lg border border-interface-stroke/30 bg-secondary-background"
>
<div
class="flex items-center justify-between gap-2 px-3 pt-3 pb-2"
>
<span
class="text-xs font-semibold tracking-wide text-base-foreground uppercase"
>
{{ t('rightSidePanel.errorLog') }}
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-7 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="t('g.copy')"
data-testid="error-card-copy"
@click="handleCopyError(idx)"
>
<i class="icon-[lucide--copy] size-4" />
</Button>
</div>
<div class="max-h-[15lh] overflow-y-auto px-3 pb-3">
<p
class="m-0 font-mono text-xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ getInlineDetails(error, idx) }}
</p>
</div>
<div class="mx-3 mt-1 h-px bg-base-foreground/20" />
<div class="mx-3 flex items-center justify-between gap-2 py-2">
<Button
v-tooltip.top="t('rightSidePanel.getHelpTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-start gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
@click="handleGetHelp"
>
<i class="icon-[lucide--external-link] size-3.5" />
{{ t('g.getHelpAction') }}
</Button>
<Button
v-tooltip.top="t('rightSidePanel.findOnGithubTooltip')"
variant="textonly"
size="sm"
class="h-8 justify-end gap-1 rounded-lg px-0 text-sm hover:bg-transparent hover:text-base-foreground"
data-testid="error-card-find-on-github"
@click="handleCheckGithub(error)"
>
<i class="icon-[lucide--github] size-3.5" />
{{ t('g.findOnGithub') }}
</Button>
</div>
</div>
</div>
</TransitionCollapse>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import Button from '@/components/ui/button/Button.vue'
import { cn } from '@comfyorg/tailwind-utils'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import type { ErrorCardData, ErrorItem } from './types'
import { useErrorActions } from './useErrorActions'
import { useErrorReport } from './useErrorReport'
const {
card,
showNodeIdBadge = false,
compact = false,
fullHeight = false
} = defineProps<{
const { card, compact = false } = defineProps<{
card: ErrorCardData
showNodeIdBadge?: boolean
/** Hide card header and error message (used in single-node selection mode) */
compact?: boolean
/** Allow runtime error details to fill available height (used in dedicated panel) */
fullHeight?: boolean
}>()
const emit = defineEmits<{
@@ -164,6 +206,23 @@ const emit = defineEmits<{
const { t } = useI18n()
const { displayedDetailsMap } = useErrorReport(() => card)
const { findOnGitHub, contactSupport: handleGetHelp } = useErrorActions()
const runtimeDetailsExpanded = ref(true)
const hasRuntimeError = computed(() =>
card.errors.some((error) => error.isRuntimeError)
)
const isRuntimeDisclosureExpanded = computed(
() => compact || runtimeDetailsExpanded.value
)
const runtimeDetailsControlIds = computed(() =>
card.errors
.map((error, idx) => (error.isRuntimeError ? getRuntimeDetailsId(idx) : ''))
.filter(Boolean)
.join(' ')
)
function toggleRuntimeDetails() {
runtimeDetailsExpanded.value = !runtimeDetailsExpanded.value
}
function handleLocateNode() {
if (card.nodeId) {
@@ -179,7 +238,7 @@ function handleEnterSubgraph() {
function handleCopyError(idx: number) {
const details = displayedDetailsMap.value[idx]
const message = getDisplayMessage(card.errors[idx])
const message = getCopyMessage(card.errors[idx])
emit('copyToClipboard', [message, details].filter(Boolean).join('\n\n'))
}
@@ -187,7 +246,26 @@ function handleCheckGithub(error: ErrorItem) {
findOnGitHub(error.message)
}
function getDisplayMessage(error: ErrorItem | undefined) {
function getCopyMessage(error: ErrorItem | undefined) {
return error?.displayMessage ?? error?.message
}
function getInlineMessage(error: ErrorItem | undefined) {
if (!error || error.displayMessage) return undefined
return error.message
}
function getInlineItemLabel(error: ErrorItem | undefined) {
if (!error || error.isRuntimeError) return undefined
return error.displayItemLabel
}
function getInlineDetails(error: ErrorItem | undefined, idx: number) {
if (getInlineItemLabel(error)) return undefined
return displayedDetailsMap.value[idx]
}
function getRuntimeDetailsId(idx: number) {
return `${card.id}-runtime-details-${idx}`
}
</script>

View File

@@ -1,15 +1,18 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { render, screen, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import PrimeVue from 'primevue/config'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TabErrors from './TabErrors.vue'
import { useMissingModelStore } from '@/platform/missingModel/missingModelStore'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
import type { MissingModelCandidate } from '@/platform/missingModel/types'
import type { MissingNodeType } from '@/types/comfy'
const mockFocusNode = vi.hoisted(() => vi.fn())
const mockEnterSubgraph = vi.hoisted(() => vi.fn())
vi.mock('@/scripts/app', () => ({
app: {
rootGraph: {
@@ -38,6 +41,13 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/composables/canvas/useFocusNode', () => ({
useFocusNode: vi.fn(() => ({
focusNode: mockFocusNode,
enterSubgraph: mockEnterSubgraph
}))
}))
vi.mock('@/platform/missingModel/missingModelDownload', () => ({
downloadModel: vi.fn(),
fetchModelMetadata: vi.fn().mockResolvedValue({
@@ -52,6 +62,7 @@ describe('TabErrors.vue', () => {
let i18n: ReturnType<typeof createI18n>
beforeEach(() => {
vi.clearAllMocks()
i18n = createI18n({
legacy: false,
locale: 'en',
@@ -59,11 +70,22 @@ describe('TabErrors.vue', () => {
en: {
g: {
workflow: 'Workflow',
copy: 'Copy'
copy: 'Copy',
details: 'Details',
findOnGithub: 'Find on GitHub',
getHelpAction: 'Get Help'
},
rightSidePanel: {
noErrors: 'No errors',
noneSearchDesc: 'No results found',
errorHelp: 'Error help',
errorLog: 'Error log',
findOnGithubTooltip: 'Search GitHub issues',
getHelpTooltip: 'Get help',
info: 'Info',
infoFor: 'Info for {item}',
locateNode: 'Locate node',
locateNodeFor: 'Locate {item}',
missingModels: {
missingModelsTitle: 'Missing Models',
downloadAll: 'Download all',
@@ -144,29 +166,111 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('Error details')).not.toBeInTheDocument()
})
it('renders node validation errors grouped by class_type', async () => {
it('renders node validation errors grouped by catalog copy', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'CLIP Text Encode'
} as ReturnType<typeof getNodeByExecutionId>)
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'1': 'KSampler',
'2': 'CLIP Text Encode'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
renderComponent({
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'6': {
'2': {
class_type: 'CLIPTextEncode',
errors: [
{ message: 'Required input is missing', details: 'Input: text' }
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: clip',
extra_info: {
input_name: 'clip'
}
}
]
},
'1': {
class_type: 'KSampler',
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: positive',
extra_info: {
input_name: 'positive'
}
},
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'Input: model',
extra_info: {
input_name: 'model'
}
}
]
}
}
}
})
expect(screen.getByText('CLIPTextEncode')).toBeInTheDocument()
expect(screen.getByText('#6')).toBeInTheDocument()
expect(screen.getByText('CLIP Text Encode')).toBeInTheDocument()
expect(screen.getByText('Required input is missing')).toBeInTheDocument()
expect(screen.getByText('Missing connection')).toBeInTheDocument()
expect(screen.getByText('(3)')).toBeInTheDocument()
expect(
screen.getAllByText(
'Required input slots have no connection feeding them.'
)
).toHaveLength(1)
expect(screen.queryByText('#1')).not.toBeInTheDocument()
expect(screen.queryByText('#2')).not.toBeInTheDocument()
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
expect(screen.queryByText('CLIP Text Encode')).not.toBeInTheDocument()
const itemRows = screen.getAllByRole('listitem')
expect(itemRows).toHaveLength(3)
expect(itemRows[0]).toHaveTextContent('KSampler - model')
expect(itemRows[1]).toHaveTextContent('KSampler - positive')
expect(itemRows[2]).toHaveTextContent('CLIP Text Encode - clip')
const infoButton = within(itemRows[1]).getByRole('button', {
name: 'Info for KSampler - positive'
})
await user.click(infoButton)
const itemDetail = screen.getByText(
'KSampler is missing a required input: positive'
)
expect(infoButton).toHaveAttribute(
'aria-controls',
itemDetail.getAttribute('id')
)
const labelLocateButton = within(itemRows[1]).getByRole('button', {
name: 'KSampler - positive'
})
await user.click(labelLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('1')
const iconLocateButton = within(itemRows[2]).getByRole('button', {
name: 'Locate CLIP Text Encode - clip'
})
await user.click(iconLocateButton)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('2')
expect(
screen.queryByText('Required input is missing')
).not.toBeInTheDocument()
expect(screen.queryByText('Input: model')).not.toBeInTheDocument()
expect(screen.queryByText('Input: positive')).not.toBeInTheDocument()
expect(screen.queryByText('Input: clip')).not.toBeInTheDocument()
})
it('renders runtime execution errors from WebSocket', async () => {
@@ -175,7 +279,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -190,12 +294,16 @@ describe('TabErrors.vue', () => {
})
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('#10')).toBeInTheDocument()
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(
screen.getByText('Node threw an error during execution.')
).toBeInTheDocument()
expect(screen.getByText('Error log')).toBeInTheDocument()
expect(screen.getByText(/Line 1/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Details' }))
expect(screen.queryByText(/Line 1/)).not.toBeInTheDocument()
})
it('filters errors based on search query', async () => {
@@ -230,7 +338,7 @@ describe('TabErrors.vue', () => {
expect(screen.queryByText('KSampler')).not.toBeInTheDocument()
})
it('calls copyToClipboard when copy button is clicked', async () => {
it('calls copyToClipboard when a runtime error copy button is clicked', async () => {
const { useCopyToClipboard } =
await import('@/composables/useCopyToClipboard')
const mockCopy = vi.fn()
@@ -238,21 +346,26 @@ describe('TabErrors.vue', () => {
const { user } = renderComponent({
executionError: {
lastNodeErrors: {
'1': {
class_type: 'TestNode',
errors: [{ message: 'Test message', details: 'Test details' }]
}
lastExecutionError: {
prompt_id: 'abc',
node_id: '1',
node_type: 'TestNode',
exception_message: 'Test message',
exception_type: 'RuntimeError',
traceback: ['Test details'],
timestamp: Date.now()
}
}
})
await user.click(screen.getByTestId('error-card-copy'))
expect(mockCopy).toHaveBeenCalledWith('Test message\n\nTest details')
expect(mockCopy).toHaveBeenCalledWith(
'Node threw an error during execution.\n\nTest details'
)
})
it('renders single runtime error outside accordion in full-height panel', async () => {
it('renders a single runtime error in the normal execution group', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockReturnValue({
title: 'KSampler'
@@ -274,7 +387,11 @@ describe('TabErrors.vue', () => {
expect(screen.getAllByText('KSampler').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('Execution failed')).toBeInTheDocument()
expect(screen.getByTestId('runtime-error-panel')).toBeInTheDocument()
expect(
within(screen.getByTestId('error-group-execution')).getByTestId(
'runtime-error-panel'
)
).toBeInTheDocument()
expect(screen.getAllByText('Execution failed')).toHaveLength(1)
})

View File

@@ -11,32 +11,7 @@
/>
</div>
<!-- Runtime error: full-height panel outside accordion -->
<div
v-if="singleRuntimeErrorCard"
data-testid="runtime-error-panel"
aria-live="polite"
class="flex min-h-0 flex-1 flex-col overflow-hidden px-4 py-3"
>
<div
class="shrink-0 pb-2 text-sm font-semibold text-destructive-background-hover"
>
{{ singleRuntimeErrorGroup?.displayTitle }}
</div>
<ErrorNodeCard
:key="singleRuntimeErrorCard.id"
:card="singleRuntimeErrorCard"
:show-node-id-badge="showNodeIdBadge"
full-height
class="min-h-0 flex-1"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@copy-to-clipboard="copyToClipboard"
/>
</div>
<!-- Scrollable content (non-runtime or mixed errors) -->
<div v-else class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<div class="min-w-0 flex-1 overflow-y-auto" aria-live="polite">
<TransitionGroup tag="div" name="list-scale" class="relative">
<div
v-if="filteredGroups.length === 0"
@@ -70,10 +45,13 @@
{{ group.displayTitle }}
</span>
<span
v-if="group.type === 'execution' && group.cards.length > 1"
v-if="
group.type === 'execution' &&
getExecutionGroupCount(group) > 1
"
class="text-destructive-background-hover"
>
({{ group.cards.length }})
({{ getExecutionGroupCount(group) }})
</span>
</span>
<Button
@@ -155,7 +133,7 @@
</template>
<div
v-if="group.type !== 'execution' && group.displayMessage"
v-if="group.displayMessage"
data-testid="error-group-display-message"
class="px-4 pt-1 pb-3"
>
@@ -186,12 +164,79 @@
/>
<!-- Execution Errors -->
<div v-if="group.type === 'execution'" class="space-y-3 px-4">
<div v-if="isExecutionItemListGroup(group)" class="px-4">
<ul class="m-0 list-none space-y-1 p-0">
<li
v-for="item in getExecutionItemList(group)"
:key="item.key"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1 items-center gap-1">
<button
v-tooltip.top="{
value: item.displayDetails || undefined,
showDelay: 300
}"
type="button"
class="m-0 inline max-w-full cursor-pointer appearance-none border-0 bg-transparent p-0 text-left text-sm/relaxed font-normal wrap-break-word text-muted-foreground outline-none hover:text-base-foreground focus:outline-none focus-visible:underline focus-visible:ring-0 focus-visible:outline-none"
@click="handleLocateNode(item.nodeId)"
>
{{ item.label }}
</button>
<Button
v-if="item.displayDetails"
variant="textonly"
size="icon-sm"
:class="
cn(
'size-6 shrink-0 text-muted-foreground hover:text-base-foreground',
isExecutionItemDetailExpanded(item.key) &&
'bg-secondary-background-selected text-base-foreground hover:bg-secondary-background-selected'
)
"
:aria-label="
t('rightSidePanel.infoFor', { item: item.label })
"
:aria-controls="getExecutionItemDetailId(item.key)"
:aria-expanded="isExecutionItemDetailExpanded(item.key)"
@click.stop="toggleExecutionItemDetail(item.key)"
>
<i class="icon-[lucide--info] size-3.5" />
</Button>
</span>
<Button
variant="textonly"
size="icon-sm"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
:aria-label="
t('rightSidePanel.locateNodeFor', { item: item.label })
"
@click.stop="handleLocateNode(item.nodeId)"
>
<i class="icon-[lucide--locate] size-4" />
</Button>
</div>
<TransitionCollapse>
<p
v-if="
item.displayDetails &&
isExecutionItemDetailExpanded(item.key)
"
:id="getExecutionItemDetailId(item.key)"
class="m-0 mt-0.5 pr-10 text-2xs/relaxed wrap-break-word whitespace-pre-wrap text-muted-foreground"
>
{{ item.displayDetails }}
</p>
</TransitionCollapse>
</li>
</ul>
</div>
<div v-else-if="group.type === 'execution'" class="space-y-3 px-4">
<ErrorNodeCard
v-for="card in group.cards"
:key="card.id"
:card="card"
:show-node-id-badge="showNodeIdBadge"
:compact="isSingleNodeSelected"
@locate-node="handleLocateNode"
@enter-subgraph="handleEnterSubgraph"
@@ -255,6 +300,7 @@
<script setup lang="ts">
import { computed, defineAsyncComponent, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useFocusNode } from '@/composables/canvas/useFocusNode'
@@ -266,6 +312,7 @@ import { NodeBadgeMode } from '@/types/nodeSource'
import PropertiesAccordionItem from '../layout/PropertiesAccordionItem.vue'
import CollapseToggleButton from '../layout/CollapseToggleButton.vue'
import TransitionCollapse from '../layout/TransitionCollapse.vue'
import AsyncSearchInput from '@/components/ui/search-input/AsyncSearchInput.vue'
import ErrorNodeCard from './ErrorNodeCard.vue'
import MissingNodeCard from './MissingNodeCard.vue'
@@ -285,6 +332,13 @@ import type { SwapNodeGroup } from './useErrorGroups'
import type { ErrorGroup } from './types'
import { useNodeReplacement } from '@/platform/nodeReplacement/useNodeReplacement'
interface ExecutionItemListEntry {
key: string
nodeId: string
label: string
displayDetails?: string
}
const ErrorPanelSurveyCta =
isNightly && !isCloud && !isDesktop
? defineAsyncComponent(
@@ -307,6 +361,7 @@ const { isInstalling: isInstallingAll, installAllPacks: installAll } =
const { replaceGroup, replaceAllGroups } = useNodeReplacement()
const searchQuery = ref('')
const expandedExecutionItemDetailKeys = ref(new Set<string>())
const isSearching = computed(() => searchQuery.value.trim() !== '')
const fullSizeGroupTypes = new Set([
@@ -325,6 +380,78 @@ const showNodeIdBadge = computed(
NodeBadgeMode.None
)
function isExecutionItemListGroup(group: ErrorGroup) {
return (
group.type === 'execution' &&
group.cards.length > 0 &&
group.cards.every(
(card) =>
card.nodeId &&
card.errors.length > 0 &&
card.errors.every(
(error) => !error.isRuntimeError && Boolean(error.displayItemLabel)
)
)
)
}
function getExecutionItemList(group: ErrorGroup): ExecutionItemListEntry[] {
if (group.type !== 'execution') return []
const items: ExecutionItemListEntry[] = []
for (const card of group.cards) {
if (!card.nodeId) continue
for (let idx = 0; idx < card.errors.length; idx++) {
const error = card.errors[idx]
const label = error.displayItemLabel
if (!label) continue
items.push({
key: `${card.id}:${idx}`,
nodeId: card.nodeId,
label,
displayDetails: error.displayDetails
})
}
}
return items.sort(compareExecutionItemListEntry)
}
function compareExecutionItemListEntry(
a: ExecutionItemListEntry,
b: ExecutionItemListEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.label.localeCompare(b.label)
)
}
function getExecutionGroupCount(group: ErrorGroup) {
if (group.type !== 'execution') return 0
if (isExecutionItemListGroup(group)) {
return group.cards.reduce((count, card) => count + card.errors.length, 0)
}
return group.cards.length
}
function isExecutionItemDetailExpanded(key: string) {
return expandedExecutionItemDetailKeys.value.has(key)
}
function toggleExecutionItemDetail(key: string) {
const nextKeys = new Set(expandedExecutionItemDetailKeys.value)
if (nextKeys.has(key)) {
nextKeys.delete(key)
} else {
nextKeys.add(key)
}
expandedExecutionItemDetailKeys.value = nextKeys
}
function getExecutionItemDetailId(key: string) {
return `execution-item-detail-${key}`
}
const {
allErrorGroups,
tabErrorGroups,
@@ -356,20 +483,6 @@ function handleMissingModelRefresh() {
void missingModelStore.refreshMissingModels()
}
const singleRuntimeErrorGroup = computed(() => {
if (filteredGroups.value.length !== 1) return null
const group = filteredGroups.value[0]
const isSoleRuntimeError =
group.type === 'execution' &&
group.cards.length === 1 &&
group.cards[0].errors.every((e) => e.isRuntimeError)
return isSoleRuntimeError ? group : null
})
const singleRuntimeErrorCard = computed(
() => singleRuntimeErrorGroup.value?.cards[0] ?? null
)
const isAllCollapsed = computed({
get() {
return filteredGroups.value.every((g) => isSectionCollapsed(g.groupKey))

View File

@@ -23,6 +23,9 @@ vi.mock('@/utils/graphTraversalUtil', () => ({
}))
const mockIsCloud = vi.hoisted(() => ({ value: false }))
const unknownValidationMessage = vi.hoisted(
() => 'A node returned a validation error ComfyUI does not recognize.'
)
vi.mock('@/platform/distribution/types', () => ({
get isCloud() {
return mockIsCloud.value
@@ -43,6 +46,18 @@ vi.mock('@/i18n', () => {
'Required input missing',
'errorCatalog.validationErrors.required_input_missing.toastMessage':
'{nodeName} is missing a required input: {inputName}',
'errorCatalog.validationErrors.unknown_validation_error.title':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.message':
unknownValidationMessage,
'errorCatalog.validationErrors.unknown_validation_error.detailsWithRawDetails':
'{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}',
'errorCatalog.validationErrors.unknown_validation_error.itemLabel':
'{nodeName}',
'errorCatalog.validationErrors.unknown_validation_error.toastTitle':
'Validation failed',
'errorCatalog.validationErrors.unknown_validation_error.toastMessage':
'{nodeName} returned an unrecognized validation error.',
'errorCatalog.promptErrors.prompt_no_outputs.title':
'Prompt has no outputs',
'errorCatalog.promptErrors.prompt_no_outputs.desc':
@@ -384,7 +399,7 @@ describe('useErrorGroups', () => {
expect(swapIdx).toBeLessThan(missingIdx)
})
it('includes execution error groups from node errors', async () => {
it('uses fallback catalog grouping for unknown node validation errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -405,8 +420,8 @@ describe('useErrorGroups', () => {
(g) => g.type === 'execution'
)
expect(execGroups.length).toBeGreaterThan(0)
expect(execGroups[0].groupKey).toBe('execution:KSampler')
expect(execGroups[0].displayTitle).toBe('KSampler')
expect(execGroups[0].groupKey).toBe('execution:unknown_validation_error')
expect(execGroups[0].displayTitle).toBe('Validation failed')
})
it('resolves required_input_missing item display copy', async () => {
@@ -455,6 +470,55 @@ describe('useErrorGroups', () => {
)
})
it('groups node validation errors by catalog id across node types', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
class_type: 'KSampler',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'model',
extra_info: {
input_name: 'model'
}
}
]
},
'2': {
class_type: 'CLIPLoader',
dependent_outputs: [],
errors: [
{
type: 'required_input_missing',
message: 'Required input is missing',
details: 'clip',
extra_info: {
input_name: 'clip'
}
}
]
}
}
await nextTick()
const execGroups = groups.allErrorGroups.value.filter(
(g) => g.type === 'execution'
)
expect(execGroups).toHaveLength(1)
const [group] = execGroups
expect(group.groupKey).toBe('execution:missing_connection')
expect(group.displayTitle).toBe('Missing connection')
expect(group.cards.map((card) => card.title)).toEqual([
'KSampler',
'CLIPLoader'
])
expect(group.cards.flatMap((card) => card.errors)).toHaveLength(2)
})
it('uses general execution_failed display fields for unrecognized runtime execution errors', async () => {
mockIsCloud.value = true
const { store, groups } = createErrorGroups()
@@ -716,7 +780,7 @@ describe('useErrorGroups', () => {
expect(groups.groupedErrorMessages.value).toEqual([])
})
it('collects unique error messages from node errors', async () => {
it('collects unique display messages from node errors', async () => {
const { store, groups } = createErrorGroups()
store.lastNodeErrors = {
'1': {
@@ -736,10 +800,7 @@ describe('useErrorGroups', () => {
await nextTick()
const messages = groups.groupedErrorMessages.value
expect(messages).toContain('Error A')
expect(messages).toContain('Error B')
// Deduplication: Error A appears twice but should only be listed once
expect(messages.filter((m) => m === 'Error A')).toHaveLength(1)
expect(messages).toEqual([unknownValidationMessage])
})
it('includes missing node group display message', async () => {

View File

@@ -30,6 +30,7 @@ import type {
MissingModelCandidate,
MissingModelGroup
} from '@/platform/missingModel/types'
import type { ResolvedCatalogErrorMessage } from '@/platform/errorCatalog/types'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { groupCandidatesByName } from '@/platform/missingModel/missingModelScan'
import { groupCandidatesByMediaType } from '@/platform/missingMedia/missingMediaScan'
@@ -43,7 +44,6 @@ import {
} from '@/types/nodeIdentification'
const PROMPT_CARD_ID = '__prompt__'
const SINGLE_GROUP_KEY = '__single__'
/** Sentinel: distinguishes "fetch in-flight" from "fetch done, pack not found (null)". */
const RESOLVING = '__RESOLVING__'
@@ -66,6 +66,7 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
displayMessage?: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -75,10 +76,14 @@ interface ErrorSearchItem {
cardIndex: number
searchableNodeId: string
searchableNodeTitle: string
searchableRawMessage: string
searchableRawDetails: string
searchableMessage: string
searchableDetails: string
}
type CataloguedErrorItem = ErrorItem & ResolvedCatalogErrorMessage
/**
* Resolve display info for a node by its execution ID.
* For group node internals, resolves the parent group node's title instead.
@@ -106,17 +111,21 @@ function getOrCreateGroup(
groupsMap: Map<string, GroupEntry>,
groupKey: string,
displayTitle = groupKey,
priority = 1
priority = 1,
displayMessage?: string
): Map<string, ErrorCardData> {
let entry = groupsMap.get(groupKey)
if (!entry) {
entry = {
type: 'execution',
displayTitle,
displayMessage,
priority,
cards: new Map()
}
groupsMap.set(groupKey, entry)
} else if (!entry.displayMessage && displayMessage) {
entry.displayMessage = displayMessage
}
return entry.cards
}
@@ -138,44 +147,6 @@ function createErrorCard(
}
}
/**
* In single-node mode, regroup cards by error message instead of class_type.
* This lets the user see "what kinds of errors this node has" at a glance.
*/
function regroupByErrorMessage(
groupsMap: Map<string, GroupEntry>
): Map<string, GroupEntry> {
const allCards = Array.from(groupsMap.values()).flatMap((g) =>
Array.from(g.cards.values())
)
const cardErrorPairs = allCards.flatMap((card) =>
card.errors.map((error) => ({ card, error }))
)
const messageMap = new Map<string, GroupEntry>()
for (const { card, error } of cardErrorPairs) {
addCardErrorToGroup(messageMap, card, error)
}
return messageMap
}
function addCardErrorToGroup(
messageMap: Map<string, GroupEntry>,
card: ErrorCardData,
error: ErrorItem
) {
const displayTitle =
error.displayTitle ?? error.displayMessage ?? error.message
const groupKey = error.catalogId ?? displayTitle
const group = getOrCreateGroup(messageMap, groupKey, displayTitle, 1)
if (!group.has(card.id)) {
group.set(card.id, { ...card, errors: [] })
}
group.get(card.id)?.errors.push(error)
}
function compareNodeId(a: ErrorCardData, b: ErrorCardData): number {
return compareExecutionId(a.nodeId, b.nodeId)
}
@@ -186,6 +157,7 @@ function toSortedGroups(groupsMap: Map<string, GroupEntry>): ErrorGroup[] {
type: 'execution' as const,
groupKey: `execution:${rawGroupKey}`,
displayTitle: groupData.displayTitle,
displayMessage: groupData.displayMessage,
cards: Array.from(groupData.cards.values()).sort(compareNodeId),
priority: groupData.priority
}))
@@ -209,6 +181,8 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
cardIndex: ci,
searchableNodeId: card.nodeId ?? '',
searchableNodeTitle: card.nodeTitle ?? '',
searchableRawMessage: card.errors.map((e) => e.message).join(' '),
searchableRawDetails: card.errors.map((e) => e.details).join(' '),
searchableMessage: card.errors
.map((e) =>
[e.displayTitle, e.displayMessage, e.message]
@@ -225,9 +199,11 @@ function searchErrorGroups(groups: ErrorGroup[], query: string) {
const fuseOptions: IFuseOptions<ErrorSearchItem> = {
keys: [
{ name: 'searchableNodeId', weight: 0.3 },
{ name: 'searchableNodeTitle', weight: 0.3 },
{ name: 'searchableMessage', weight: 0.3 },
{ name: 'searchableRawMessage', weight: 0.3 },
{ name: 'searchableNodeId', weight: 0.2 },
{ name: 'searchableNodeTitle', weight: 0.2 },
{ name: 'searchableMessage', weight: 0.2 },
{ name: 'searchableRawDetails', weight: 0.1 },
{ name: 'searchableDetails', weight: 0.1 }
],
threshold: 0.3
@@ -333,18 +309,23 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
nodeId: string,
classType: string,
idPrefix: string,
errors: ErrorItem[],
error: CataloguedErrorItem,
filterBySelection = false
) {
if (filterBySelection && !isErrorInSelection(nodeId)) return
const groupKey = isSingleNodeSelected.value ? SINGLE_GROUP_KEY : classType
const cards = getOrCreateGroup(groupsMap, groupKey, classType, 1)
const cards = getOrCreateGroup(
groupsMap,
error.catalogId,
error.displayTitle ?? classType,
1,
error.displayMessage
)
if (!cards.has(nodeId)) {
cards.set(nodeId, createErrorCard(nodeId, classType, idPrefix))
}
const card = cards.get(nodeId)
if (!card) return
card.errors.push(...errors)
card.errors.push(error)
}
function processPromptError(
@@ -368,7 +349,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
groupsMap,
`prompt:${error.type}`,
groupDisplayTitle,
0
0,
resolvedDisplay.displayMessage
)
// Prompt errors are not tied to a node, so they bypass addNodeErrorToGroup.
@@ -395,13 +377,13 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
)) {
const nodeDisplayName =
resolveNodeInfo(nodeId).title || nodeError.class_type
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
nodeError.errors.map((e) => {
return {
for (const e of nodeError.errors) {
addNodeErrorToGroup(
groupsMap,
nodeId,
nodeError.class_type,
'node',
{
message: e.message,
details: e.details ?? undefined,
...resolveRunErrorMessage({
@@ -409,10 +391,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
},
filterBySelection
)
}
}
}
@@ -428,20 +410,18 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
String(e.node_id),
e.node_type,
'exec',
[
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
}
],
{
message: `${e.exception_type}: ${e.exception_message}`,
details: e.traceback.join('\n'),
isRuntimeError: true,
exceptionType: e.exception_type,
...resolveRunErrorMessage({
kind: 'execution',
error: e,
nodeDisplayName:
resolveNodeInfo(String(e.node_id)).title || e.node_type
})
},
filterBySelection
)
}
@@ -867,10 +847,6 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
processNodeErrors(groupsMap, true)
processExecutionError(groupsMap, true)
const executionGroups = isSingleNodeSelected.value
? toSortedGroups(regroupByErrorMessage(groupsMap))
: toSortedGroups(groupsMap)
const filterByNode = selectedNodeInfo.value.nodeIds !== null
// Missing nodes are intentionally unfiltered — they represent
@@ -883,7 +859,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...(filterByNode
? buildMissingMediaGroupsFiltered()
: buildMissingMediaGroups()),
...executionGroups
...toSortedGroups(groupsMap)
]
})

View File

@@ -0,0 +1,182 @@
import { describe, expect, it } from 'vitest'
import { computeArrangement } from '@/composables/graph/useArrangeNodes'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
interface MockNodeSpec {
id: number | string
pos: [number, number]
size: [number, number]
title_mode?: TitleMode
}
const makeNode = (spec: MockNodeSpec): LGraphNode =>
({
id: spec.id,
pos: spec.pos,
size: spec.size,
title_mode: spec.title_mode
}) as unknown as LGraphNode
const GAP = 12
const TITLE = 30 // LiteGraph.NODE_TITLE_HEIGHT default
describe('computeArrangement', () => {
it('returns no updates when fewer than 2 nodes are selected', () => {
expect(computeArrangement([], 'vertical')).toEqual([])
expect(
computeArrangement(
[makeNode({ id: 1, pos: [0, 0], size: [100, 50] })],
'grid'
)
).toEqual([])
})
describe('vertical', () => {
it('left-aligns to anchor x and stacks downward sorted by current y', () => {
const nodes = [
makeNode({ id: 'a', pos: [10, 100], size: [100, 50] }),
makeNode({ id: 'b', pos: [200, 0], size: [80, 30] }),
makeNode({ id: 'c', pos: [50, 200], size: [120, 40] })
]
// Anchor: 'a' has smallest x+y (110). Sort by Y: b(0), a(100), c(200).
// Visual top of layout = anchor.posY - TITLE = 100 - 30 = 70.
// Each node's pos.y = visualTop + its titleHeight (30).
// b: pos.y = 70+30 = 100; visualTop += (30+30)+12 = 142
// a: pos.y = 142+30 = 172; visualTop += (50+30)+12 = 234
// c: pos.y = 234+30 = 264
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 10, y: 100 } },
{ nodeId: 'a', position: { x: 10, y: 172 } },
{ nodeId: 'c', position: { x: 10, y: 264 } }
])
})
it('omits the title-height contribution for NO_TITLE nodes', () => {
const nodes = [
makeNode({
id: 1,
pos: [0, 0],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
}),
makeNode({
id: 2,
pos: [0, 200],
size: [100, 100],
title_mode: TitleMode.NO_TITLE
})
]
// No titles: visualHeight = size[1] = 100. visualTop = 0. Gap = 12.
// 1: pos.y = 0; visualTop = 0 + 100 + 12 = 112.
// 2: pos.y = 112.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 100 + GAP } }
])
})
it('preserves heterogeneous heights when computing gaps', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 200] }),
makeNode({ id: 2, pos: [0, 50], size: [100, 50] })
]
// visualTop=-30. 1: pos.y=0; visualTop += (200+30)+12 = 212.
// 2: pos.y = 212+30 = 242.
const result = computeArrangement(nodes, 'vertical')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 0, y: 200 + TITLE + GAP } }
])
})
})
describe('horizontal', () => {
it('top-aligns to anchor y and lays out rightward sorted by current x', () => {
const nodes = [
makeNode({ id: 'a', pos: [100, 50], size: [80, 40] }),
makeNode({ id: 'b', pos: [0, 200], size: [60, 30] }),
makeNode({ id: 'c', pos: [300, 80], size: [50, 50] })
]
// Anchor: smallest x+y → a(150), b(200), c(380) → anchor 'a' at (100, 50).
// Sort by X: b(0), a(100), c(300)
// Lay out from (100, 50):
// b at (100, 50)
// a at (100 + 60 + 12, 50) = (172, 50)
// c at (172 + 80 + 12, 50) = (264, 50)
const result = computeArrangement(nodes, 'horizontal')
expect(result).toEqual([
{ nodeId: 'b', position: { x: 100, y: 50 } },
{ nodeId: 'a', position: { x: 172, y: 50 } },
{ nodeId: 'c', position: { x: 264, y: 50 } }
])
})
})
describe('grid', () => {
it('lays out 4 nodes as 2x2 with column/row sizes from max width/height', () => {
const nodes = [
makeNode({ id: 1, pos: [0, 0], size: [100, 50] }),
makeNode({ id: 2, pos: [200, 0], size: [80, 60] }),
makeNode({ id: 3, pos: [0, 100], size: [120, 40] }),
makeNode({ id: 4, pos: [200, 100], size: [90, 30] })
]
// Anchor: 1 at (0,0). Sort by Y then X: 1, 2, 3, 4. cols=2, rows=2.
// Col widths: col0=max(100,120)=120; col1=max(80,90)=90.
// Row visual heights: row0=max(50+30,60+30)=90; row1=max(40+30,30+30)=70.
// colX=[0, 132]. rowVisualTop=[-30, -30+90+12=72].
// pos.y = rowVisualTop + 30 (titleHeight).
const result = computeArrangement(nodes, 'grid')
expect(result).toEqual([
{ nodeId: 1, position: { x: 0, y: 0 } },
{ nodeId: 2, position: { x: 132, y: 0 } },
{ nodeId: 3, position: { x: 0, y: 102 } },
{ nodeId: 4, position: { x: 132, y: 102 } }
])
})
it('uses ceil(sqrt(n)) columns for non-square counts', () => {
// 5 nodes → ceil(sqrt(5))=3 cols, 2 rows. Last cell empty.
const nodes = Array.from({ length: 5 }, (_, i) =>
makeNode({
id: i + 1,
pos: [i * 50, i * 50],
size: [40, 40]
})
)
const result = computeArrangement(nodes, 'grid')
expect(result).toHaveLength(5)
// Sorted by Y then X = original order. Anchor = node 1 at (0,0).
// colWidths=[40,40,40]. rowVisualHeight = 40+30 = 70 each.
// colX=[0,52,104]. rowVisualTop=[-30, -30+70+12=52]. pos.y = visualTop+30.
expect(result[0].position).toEqual({ x: 0, y: 0 })
expect(result[1].position).toEqual({ x: 52, y: 0 })
expect(result[2].position).toEqual({ x: 104, y: 0 })
expect(result[3].position).toEqual({ x: 0, y: 82 })
expect(result[4].position).toEqual({ x: 52, y: 82 })
})
})
describe('anchor selection', () => {
it('picks the node with smallest x+y, not min-x or min-y alone', () => {
const nodes = [
// min y but large x: x+y = 1000
makeNode({ id: 'minY', pos: [1000, 0], size: [50, 50] }),
// min x but large y: x+y = 1000
makeNode({ id: 'minX', pos: [0, 1000], size: [50, 50] }),
// smallest x+y: 600
makeNode({ id: 'anchor', pos: [300, 300], size: [50, 50] })
]
const result = computeArrangement(nodes, 'vertical')
// All updates left-align to anchor.x = 300. First in sort = minY (y=0).
expect(result[0]).toEqual({
nodeId: 'minY',
position: { x: 300, y: 300 }
})
expect(result.every((u) => u.position.x === 300)).toBe(true)
})
})
})

View File

@@ -0,0 +1,186 @@
import { useSelectionState } from '@/composables/graph/useSelectionState'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, NodeId } from '@/lib/litegraph/src/litegraph'
import { TitleMode } from '@/lib/litegraph/src/types/globalEnums'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { Point } from '@/renderer/core/layout/types'
import { app } from '@/scripts/app'
export type ArrangeLayout = 'vertical' | 'horizontal' | 'grid'
export const DEFAULT_ARRANGE_GAP = 12
export const MIN_ARRANGE_GAP = 0
export const MAX_ARRANGE_GAP = 48
interface NodeBox {
id: NodeId
posX: number
posY: number
visualWidth: number
visualHeight: number
titleHeight: number
}
interface ArrangeUpdate {
nodeId: NodeId
position: Point
}
const titleHeightOf = (node: LGraphNode): number => {
const mode = node.title_mode
if (mode === TitleMode.TRANSPARENT_TITLE || mode === TitleMode.NO_TITLE) {
return 0
}
return LiteGraph.NODE_TITLE_HEIGHT
}
const toBox = (node: LGraphNode): NodeBox => {
const titleHeight = titleHeightOf(node)
return {
id: node.id,
posX: node.pos[0],
posY: node.pos[1],
visualWidth: node.size[0],
visualHeight: node.size[1] + titleHeight,
titleHeight
}
}
const byTopDown = (a: NodeBox, b: NodeBox) => a.posY - b.posY || a.posX - b.posX
const byLeftRight = (a: NodeBox, b: NodeBox) =>
a.posX - b.posX || a.posY - b.posY
const findAnchor = (boxes: NodeBox[]): NodeBox =>
boxes.reduce((best, box) =>
box.posX + box.posY < best.posX + best.posY ? box : best
)
const cumulativeOffsets = (
sizes: number[],
origin: number,
gap: number
): number[] => {
const offsets: number[] = [origin]
for (let i = 1; i < sizes.length; i++) {
offsets.push(offsets[i - 1] + sizes[i - 1] + gap)
}
return offsets
}
const arrangeVertical = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
let visualTop = anchor.posY - anchor.titleHeight
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: anchor.posX, y: visualTop + box.titleHeight }
}
visualTop += box.visualHeight + gap
return update
})
}
const arrangeHorizontal = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byLeftRight)
const visualTop = anchor.posY - anchor.titleHeight
let cursorX = anchor.posX
return sorted.map((box) => {
const update: ArrangeUpdate = {
nodeId: box.id,
position: { x: cursorX, y: visualTop + box.titleHeight }
}
cursorX += box.visualWidth + gap
return update
})
}
const arrangeGrid = (
boxes: NodeBox[],
anchor: NodeBox,
gap: number
): ArrangeUpdate[] => {
const sorted = [...boxes].sort(byTopDown)
const cols = Math.ceil(Math.sqrt(sorted.length))
const rows = Math.ceil(sorted.length / cols)
const colWidths = new Array<number>(cols).fill(0)
const rowHeights = new Array<number>(rows).fill(0)
sorted.forEach((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
if (box.visualWidth > colWidths[col]) colWidths[col] = box.visualWidth
if (box.visualHeight > rowHeights[row]) rowHeights[row] = box.visualHeight
})
const colX = cumulativeOffsets(colWidths, anchor.posX, gap)
const rowVisualTop = cumulativeOffsets(
rowHeights,
anchor.posY - anchor.titleHeight,
gap
)
return sorted.map((box, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return {
nodeId: box.id,
position: {
x: colX[col],
y: rowVisualTop[row] + box.titleHeight
}
}
})
}
export function computeArrangement(
nodes: LGraphNode[],
layout: ArrangeLayout,
gap: number = DEFAULT_ARRANGE_GAP
): ArrangeUpdate[] {
if (nodes.length < 2) return []
const boxes = nodes.map(toBox)
const anchor = findAnchor(boxes)
if (layout === 'vertical') return arrangeVertical(boxes, anchor, gap)
if (layout === 'horizontal') return arrangeHorizontal(boxes, anchor, gap)
return arrangeGrid(boxes, anchor, gap)
}
interface ArrangeOptions {
gap?: number
captureUndo?: boolean
}
export function useArrangeNodes() {
const { selectedNodes, hasMultipleSelection } = useSelectionState()
const mutations = useLayoutMutations()
const workflowStore = useWorkflowStore()
const arrangeNodes = (
layout: ArrangeLayout,
{ gap = DEFAULT_ARRANGE_GAP, captureUndo = true }: ArrangeOptions = {}
) => {
if (!hasMultipleSelection.value) return
const updates = computeArrangement(selectedNodes.value, layout, gap)
if (updates.length === 0) return
mutations.setSource(LayoutSource.Canvas)
mutations.batchMoveNodes(updates)
app.canvas?.setDirty(true, true)
if (captureUndo) {
workflowStore.activeWorkflow?.changeTracker?.captureCanvasState()
}
}
return { arrangeNodes, canArrange: hasMultipleSelection }
}

View File

@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as ArrangeNodesModule from '@/composables/graph/useArrangeNodes'
import { useArrangeSession } from '@/composables/graph/useArrangeSession'
const mockArrangeNodes = vi.fn()
vi.mock('@/composables/graph/useArrangeNodes', async () => {
const actual = await vi.importActual<typeof ArrangeNodesModule>(
'@/composables/graph/useArrangeNodes'
)
return {
...actual,
useArrangeNodes: () => ({ arrangeNodes: mockArrangeNodes })
}
})
describe('useArrangeSession', () => {
let frameCallbacks: Array<FrameRequestCallback>
let nextHandle: number
beforeEach(() => {
mockArrangeNodes.mockReset()
frameCallbacks = []
nextHandle = 1
vi.spyOn(globalThis, 'requestAnimationFrame').mockImplementation(
(cb: FrameRequestCallback) => {
frameCallbacks.push(cb)
return nextHandle++
}
)
vi.spyOn(globalThis, 'cancelAnimationFrame').mockImplementation((id) => {
frameCallbacks[id - 1] = () => {}
})
})
afterEach(() => {
vi.restoreAllMocks()
})
const flushFrames = () => {
const callbacks = frameCallbacks
frameCallbacks = []
callbacks.forEach((cb) => cb(performance.now()))
}
it('start() applies layout immediately and captures undo', () => {
const session = useArrangeSession()
session.start('vertical')
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('vertical', {
gap: 12,
captureUndo: true
})
expect(session.activeLayout.value).toBe('vertical')
expect(session.gap.value).toBe(12)
})
it('previewGap() throttles repeated calls into a single frame', () => {
const session = useArrangeSession()
session.start('grid')
mockArrangeNodes.mockClear()
session.previewGap(20)
session.previewGap(30)
session.previewGap(40)
expect(mockArrangeNodes).not.toHaveBeenCalled()
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('grid', {
gap: 40,
captureUndo: false
})
})
it('previewGap() is a no-op outside an active session', () => {
const session = useArrangeSession()
session.previewGap(20)
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
})
it('commitGap() cancels any pending preview frame', () => {
const session = useArrangeSession()
session.start('horizontal')
mockArrangeNodes.mockClear()
session.previewGap(25)
session.commitGap(36)
flushFrames()
expect(mockArrangeNodes).toHaveBeenCalledTimes(1)
expect(mockArrangeNodes).toHaveBeenCalledWith('horizontal', {
gap: 36,
captureUndo: true
})
})
it('reset() ends the session and prevents pending frames from arranging', () => {
const session = useArrangeSession()
session.start('vertical')
mockArrangeNodes.mockClear()
session.previewGap(40)
session.reset()
flushFrames()
expect(mockArrangeNodes).not.toHaveBeenCalled()
expect(session.activeLayout.value).toBeNull()
expect(session.gap.value).toBe(12)
})
})

View File

@@ -0,0 +1,60 @@
import { readonly, ref } from 'vue'
import {
DEFAULT_ARRANGE_GAP,
useArrangeNodes
} from '@/composables/graph/useArrangeNodes'
import type { ArrangeLayout } from '@/composables/graph/useArrangeNodes'
export function useArrangeSession() {
const { arrangeNodes } = useArrangeNodes()
const activeLayout = ref<ArrangeLayout | null>(null)
const gap = ref(DEFAULT_ARRANGE_GAP)
let pendingFrame: number | null = null
const cancelPendingFrame = () => {
if (pendingFrame === null) return
cancelAnimationFrame(pendingFrame)
pendingFrame = null
}
const reset = () => {
cancelPendingFrame()
activeLayout.value = null
gap.value = DEFAULT_ARRANGE_GAP
}
const start = (layout: ArrangeLayout) => {
gap.value = DEFAULT_ARRANGE_GAP
activeLayout.value = layout
arrangeNodes(layout, { gap: gap.value, captureUndo: true })
}
const previewGap = (nextGap: number) => {
if (activeLayout.value === null) return
gap.value = nextGap
cancelPendingFrame()
pendingFrame = requestAnimationFrame(() => {
pendingFrame = null
if (activeLayout.value === null) return
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: false })
})
}
const commitGap = (nextGap: number) => {
if (activeLayout.value === null) return
cancelPendingFrame()
gap.value = nextGap
arrangeNodes(activeLayout.value, { gap: nextGap, captureUndo: true })
}
return {
activeLayout: readonly(activeLayout),
gap: readonly(gap),
start,
previewGap,
commitGap,
reset
}
}

View File

@@ -3,7 +3,14 @@ import { nextTick, reactive, ref, shallowRef } from 'vue'
import type { Pinia } from 'pinia'
import { getActivePinia } from 'pinia'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
@@ -186,6 +193,7 @@ describe('useLoad3d', () => {
resetGizmoTransform: vi.fn(),
applyGizmoTransform: vi.fn(),
fitToViewer: vi.fn(),
centerCameraOnModel: vi.fn(),
getGizmoTransform: vi.fn().mockReturnValue({
position: { x: 0, y: 0, z: 0 },
rotation: { x: 0, y: 0, z: 0 },
@@ -1742,4 +1750,184 @@ describe('useLoad3d', () => {
expect(originalOnRemoved).toHaveBeenCalledTimes(1)
})
})
describe('scene dirty tracking', () => {
const fakeCache = {
image: 'threed/scene-1.png [temp]',
mask: 'threed/scene_mask-1.png [temp]',
normal: 'threed/scene_normal-1.png [temp]',
camera_info: null,
recording: '',
model_3d_info: []
}
it('treats an unseen node as dirty by default', () => {
const fresh = createMockLGraphNode({ properties: {} })
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('markLoad3dSceneDirty sets the node dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
markLoad3dSceneDirty(fresh)
expect(isLoad3dSceneDirty(fresh)).toBe(true)
})
it('setLoad3dOutputCache stores the output and clears dirty', () => {
const fresh = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(fresh, fakeCache)
expect(getLoad3dOutputCache(fresh)).toBe(fakeCache)
expect(isLoad3dSceneDirty(fresh)).toBe(false)
})
it('two nodes keep independent dirty state', () => {
const a = createMockLGraphNode({ properties: {} })
const b = createMockLGraphNode({ properties: {} })
setLoad3dOutputCache(a, fakeCache)
expect(isLoad3dSceneDirty(a)).toBe(false)
expect(isLoad3dSceneDirty(b)).toBe(true)
markLoad3dSceneDirty(a)
expect(isLoad3dSceneDirty(a)).toBe(true)
expect(isLoad3dSceneDirty(b)).toBe(true)
})
it('markLoad3dSceneDirty on null is a no-op', () => {
expect(() => markLoad3dSceneDirty(null)).not.toThrow()
})
it('sceneConfig changes flip the node dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.sceneConfig.value.backgroundColor = '#ffffff'
await nextTick()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('cameraChanged event marks the node dirty', async () => {
let cameraChangedHandler: ((state: unknown) => void) | undefined
vi.mocked(mockLoad3d.addEventListener!).mockImplementation(
(event: string, handler: unknown) => {
if (event === 'cameraChanged') {
cameraChangedHandler = handler as (state: unknown) => void
}
}
)
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
cameraChangedHandler!({ position: { x: 1, y: 2, z: 3 } })
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording marks dirty when a recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(5)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStopRecording leaves dirty alone when no recording was produced', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
vi.mocked(mockLoad3d.getRecordingDuration!).mockReturnValue(0)
composable.handleStopRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
})
it('handleClearRecording marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleClearRecording()
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleStartRecording marks dirty so an in-progress recording forces a re-capture', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
await composable.handleStartRecording()
expect(mockLoad3d.startRecording).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleCenterCameraOnModel marks dirty', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
setLoad3dOutputCache(mockNode, fakeCache)
expect(isLoad3dSceneDirty(mockNode)).toBe(false)
composable.handleCenterCameraOnModel()
expect(mockLoad3d.centerCameraOnModel).toHaveBeenCalledTimes(1)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
it('handleSeek marks dirty when the animation has a duration', async () => {
const composable = useLoad3d(mockNode)
const containerRef = document.createElement('div')
await composable.initializeLoad3d(containerRef)
await nextTick()
const calls = vi.mocked(mockLoad3d.addEventListener!).mock.calls
const match = calls.find(([event]) => event === 'animationProgressChange')
const animationProgressHandler = match![1] as (d: {
progress: number
currentTime: number
duration: number
}) => void
animationProgressHandler({ progress: 0, currentTime: 0, duration: 10 })
setLoad3dOutputCache(mockNode, fakeCache)
composable.handleSeek(50)
expect(isLoad3dSceneDirty(mockNode)).toBe(true)
})
})
})

View File

@@ -22,6 +22,7 @@ import type {
GizmoMode,
LightConfig,
MaterialMode,
Model3DInfo,
ModelConfig,
SceneConfig,
UpDirection
@@ -38,6 +39,38 @@ import { useLoad3dService } from '@/services/load3dService'
type Load3dReadyCallback = (load3d: Load3d) => void
export const nodeToLoad3dMap = new Map<LGraphNode, Load3d>()
export type Load3dCachedOutput = {
image: string
mask: string
normal: string
camera_info: CameraState | null
recording: string
model_3d_info: Model3DInfo
}
const load3dSceneDirty = new WeakMap<LGraphNode, boolean>()
const load3dOutputCache = new WeakMap<LGraphNode, Load3dCachedOutput>()
export const markLoad3dSceneDirty = (node: LGraphNode | null): void => {
if (!node) return
load3dSceneDirty.set(node, true)
}
export const isLoad3dSceneDirty = (node: LGraphNode): boolean =>
load3dSceneDirty.get(node) !== false
export const getLoad3dOutputCache = (
node: LGraphNode
): Load3dCachedOutput | undefined => load3dOutputCache.get(node)
export const setLoad3dOutputCache = (
node: LGraphNode,
output: Load3dCachedOutput
): void => {
load3dOutputCache.set(node, output)
load3dSceneDirty.set(node, false)
}
const pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
const persistentReadyCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
@@ -69,6 +102,11 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
let load3d: Load3d | null = null
let isFirstModelLoad = true
const markDirty = () => {
const rawNode = toRaw(nodeRef.value)
if (rawNode) markLoad3dSceneDirty(rawNode as LGraphNode)
}
const debouncedHandleResize = useDebounceFn(() => {
load3d?.handleResize()
}, 150)
@@ -371,6 +409,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (n) {
n.properties['Light Config'] = lightConfig.value
}
markDirty()
}
const waitForLoad3d = (callback: Load3dReadyCallback) => {
@@ -415,6 +454,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Scene Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -455,6 +495,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (nodeRef.value) {
nodeRef.value.properties['Model Config'] = newValue
}
markDirty()
},
{ deep: true }
)
@@ -488,6 +529,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.toggleCamera(newValue.cameraType)
load3d.setFOV(newValue.fov)
}
markDirty()
},
{ deep: true }
)
@@ -547,18 +589,21 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
load3d.toggleAnimation(newValue)
}
markDirty()
})
watch(selectedSpeed, (newValue) => {
if (load3d && newValue) {
load3d.setAnimationSpeed(newValue)
}
markDirty()
})
watch(selectedAnimation, (newValue) => {
if (load3d && newValue !== undefined) {
load3d.updateSelectedAnimation(newValue)
}
markDirty()
})
const handleMouseEnter = () => {
@@ -573,6 +618,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d) {
await load3d.startRecording()
isRecording.value = true
markDirty()
}
}
@@ -582,6 +628,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
isRecording.value = false
recordingDuration.value = load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
if (hasRecording.value) markDirty()
}
}
@@ -598,6 +645,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
load3d.clearRecording()
hasRecording.value = false
recordingDuration.value = 0
markDirty()
}
}
@@ -605,6 +653,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
if (load3d && animationDuration.value > 0) {
const time = (progress / 100) * animationDuration.value
load3d.setAnimationTime(time)
markDirty()
}
}
@@ -936,6 +985,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
state: cameraState
}
}
markLoad3dSceneDirty(node)
}
},
gizmoTransformChange: (data: GizmoConfig) => {
@@ -976,7 +1026,9 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
}
const handleCenterCameraOnModel = () => {
load3d?.centerCameraOnModel()
if (!load3d) return
load3d.centerCameraOnModel()
markDirty()
}
const handleResetGizmoTransform = () => {

View File

@@ -1,55 +0,0 @@
import type { HintedString } from '@primevue/core'
import type { InjectionKey } from 'vue'
import { computed, inject } from 'vue'
/**
* Options for configuring transform-compatible overlay props
*/
interface TransformCompatOverlayOptions {
/**
* Where to append the overlay. 'self' keeps overlay within component
* for proper transform inheritance, 'body' teleports to document body
*/
appendTo?: HintedString<'body' | 'self'> | undefined | HTMLElement
// Future: other props needed for transform compatibility
// scrollTarget?: string | HTMLElement
// autoZIndex?: boolean
}
export const OverlayAppendToKey: InjectionKey<
HintedString<'body' | 'self'> | undefined | HTMLElement
> = Symbol('OverlayAppendTo')
/**
* Composable that provides props to make PrimeVue overlay components
* compatible with CSS-transformed parent elements.
*
* Vue nodes use CSS transforms for positioning/scaling. PrimeVue overlay
* components (Select, MultiSelect, TreeSelect, etc.) teleport to document
* body by default, breaking transform inheritance. This composable provides
* the necessary props to keep overlays within their component elements.
*
* @param overrides - Optional overrides for specific use cases
* @returns Computed props object to spread on PrimeVue overlay components
*
* @example
* ```vue
* <template>
* <Select v-bind="overlayProps" />
* </template>
*
* <script setup>
* const overlayProps = useTransformCompatOverlayProps()
* </script>
* ```
*/
export function useTransformCompatOverlayProps(
overrides: TransformCompatOverlayOptions = {}
) {
const injectedAppendTo = inject(OverlayAppendToKey, undefined)
return computed(() => ({
appendTo: injectedAppendTo ?? ('self' as const),
...overrides
}))
}

View File

@@ -38,13 +38,27 @@ vi.mock('@/services/load3dService', () => ({
})
}))
vi.mock('@/composables/useLoad3d', () => ({
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap
}))
vi.mock('@/composables/useLoad3d', () => {
const sceneDirty = new WeakMap<LGraphNode, boolean>()
const outputCache = new WeakMap<LGraphNode, unknown>()
return {
useLoad3d: () => ({
waitForLoad3d: waitForLoad3dMock,
onLoad3dReady: onLoad3dReadyMock
}),
nodeToLoad3dMap,
markLoad3dSceneDirty: (node: LGraphNode | null) => {
if (!node) return
sceneDirty.set(node, true)
},
isLoad3dSceneDirty: (node: LGraphNode) => sceneDirty.get(node) !== false,
getLoad3dOutputCache: (node: LGraphNode) => outputCache.get(node),
setLoad3dOutputCache: (node: LGraphNode, value: unknown) => {
outputCache.set(node, value)
sceneDirty.set(node, false)
}
}
})
vi.mock('@/extensions/core/load3d/Load3DConfiguration', () => ({
default: class {
@@ -173,7 +187,7 @@ function makePreview3DAdvancedNode(
constructor: { comfyClass: overrides.comfyClass ?? 'Preview3DAdvanced' },
size: [400, 550],
setSize: vi.fn(),
widgets: overrides.widgets ?? [{ name: 'image', value: '' }],
widgets: overrides.widgets ?? [{ name: 'viewport_state', value: '' }],
properties: overrides.properties ?? {}
} as unknown as LGraphNode
}
@@ -482,13 +496,16 @@ describe('Comfy.Load3D.nodeCreated', () => {
await load3DExt.nodeCreated(node)
expect(configureMock).toHaveBeenCalledWith({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2]
})
expect(configureMock).toHaveBeenCalledWith(
expect.objectContaining({
loadFolder: 'input',
modelWidget: widgets[0],
cameraState: undefined,
width: widgets[1],
height: widgets[2],
onSceneInvalidated: expect.any(Function)
})
)
})
it('attaches a serializeValue function to the scene widget', async () => {
@@ -783,9 +800,9 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
expect(load3dInstance.setCameraState).not.toHaveBeenCalled()
})
it('attaches a camera-only serializeValue to the image widget', async () => {
it('attaches a camera-only serializeValue to the viewport_state widget', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
await preview3DAdvancedExt.nodeCreated(node)
@@ -795,7 +812,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
it('serializeValue returns live camera_info plus empty media fields, omitting model_3d_info when none', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
@@ -819,7 +836,7 @@ describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
it('serializeValue wraps a present getModelInfo result in a single-element list', async () => {
const { preview3DAdvancedExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [{ name: 'image', value: '' }]
const widgets: FakeWidget[] = [{ name: 'viewport_state', value: '' }]
const node = makePreview3DAdvancedNode({ widgets })
const load3d = makeLoad3dMock()
@@ -1014,3 +1031,95 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
])
})
})
describe('Comfy.Load3D scene widget serializeValue caching', () => {
beforeEach(setupBaseMocks)
function makeFullFakeLoad3d() {
return {
getCurrentCameraType: vi.fn(() => 'perspective'),
cameraManager: { perspectiveCamera: { fov: 35 } },
getCameraState: vi.fn(() => ({ position: { x: 0, y: 0, z: 0 } })),
stopRecording: vi.fn(),
captureScene: vi.fn(async () => ({
scene: 'scene-data',
mask: 'mask-data',
normal: 'normal-data'
})),
handleResize: vi.fn(),
getModelInfo: vi.fn(() => null),
getRecordingData: vi.fn(() => null)
}
}
async function setup() {
const { load3DExt } = await loadExtensionsFresh()
const useLoad3dModule = await import('@/composables/useLoad3d')
const utilsModule = await import('@/extensions/core/load3d/Load3dUtils')
const uploadTempImage = utilsModule.default.uploadTempImage as ReturnType<
typeof vi.fn
>
let counter = 0
uploadTempImage.mockImplementation(
async (_data: unknown, kind: string) => ({
name: `${kind}-${++counter}.png`
})
)
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets, properties: {} })
useLoad3dModule.nodeToLoad3dMap.set(node, makeFullFakeLoad3d() as never)
await load3DExt.nodeCreated(node)
const serialize = widgets[3].serializeValue! as () => Promise<{
image: string
} | null>
return { node, serialize, uploadTempImage, useLoad3dModule }
}
it('reuses the cached output when the scene has not been dirtied', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
const first = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(first?.image).toBe('threed/scene-1.png [temp]')
expect(useLoad3dModule.isLoad3dSceneDirty(node)).toBe(false)
expect(useLoad3dModule.getLoad3dOutputCache(node)).toBe(first)
const second = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
expect(second).toBe(first)
})
it('re-captures after the scene is marked dirty', async () => {
const { node, serialize, uploadTempImage, useLoad3dModule } = await setup()
await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(3)
useLoad3dModule.markLoad3dSceneDirty(node)
const refreshed = await serialize()
expect(uploadTempImage).toHaveBeenCalledTimes(6)
expect(refreshed?.image).toBe('threed/scene-4.png [temp]')
})
it('returns null when no load3d instance is registered for the node', async () => {
const { load3DExt } = await loadExtensionsFresh()
const widgets: FakeWidget[] = [
{ name: 'model_file', value: 'm.glb' },
{ name: 'width', value: 256 },
{ name: 'height', value: 256 },
{ name: 'image', value: '' }
]
const node = makeLoad3DNode({ widgets })
await load3DExt.nodeCreated(node)
expect(await widgets[3].serializeValue!()).toBeNull()
})
})

View File

@@ -2,7 +2,15 @@ import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import {
type Load3dCachedOutput,
getLoad3dOutputCache,
isLoad3dSceneDirty,
markLoad3dSceneDirty,
nodeToLoad3dMap,
setLoad3dOutputCache,
useLoad3d
} from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
@@ -96,6 +104,8 @@ async function handleModelUpload(files: FileList, node: LGraphNode) {
modelWidget.value = uploadPath
}
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
@@ -113,6 +123,7 @@ async function handleResourcesUpload(files: FileList, node: LGraphNode) {
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
markLoad3dSceneDirty(node)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
@@ -270,8 +281,16 @@ useExtensionService().registerExtension({
}
],
getCustomWidgets() {
const VIEWPORT_STATE_NODES = new Set([
'Preview3DAdvanced',
'PreviewGaussianSplat',
'PreviewPointCloud'
])
return {
LOAD_3D(node) {
const inputName = VIEWPORT_STATE_NODES.has(node.constructor.comfyClass)
? 'viewport_state'
: 'image'
const hasModelFileWidget = node.widgets?.some(
(w) => w.name === 'model_file'
)
@@ -311,14 +330,15 @@ useExtensionService().registerExtension({
if (modelWidget) {
modelWidget.value = LOAD3D_NONE_MODEL
}
markLoad3dSceneDirty(node)
})
}
const widget = new ComponentWidgetImpl({
node: node,
name: 'image',
name: inputName,
component: Load3D,
inputSpec: inputSpecLoad3D,
inputSpec: { ...inputSpecLoad3D, name: inputName },
options: {}
})
@@ -369,7 +389,8 @@ useExtensionService().registerExtension({
modelWidget,
cameraState,
width,
height
height,
onSceneInvalidated: () => markLoad3dSceneDirty(node)
})
})
@@ -387,6 +408,11 @@ useExtensionService().registerExtension({
return null
}
if (!isLoad3dSceneDirty(node)) {
const cached = getLoad3dOutputCache(node)
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
@@ -418,7 +444,7 @@ useExtensionService().registerExtension({
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal = {
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
@@ -435,9 +461,11 @@ useExtensionService().registerExtension({
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
returnVal.recording = `threed/${recording.name} [temp]`
}
setLoad3dOutputCache(node, returnVal)
return returnVal
}
}
@@ -715,7 +743,7 @@ useExtensionService().registerExtension({
})
useLoad3d(node).waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d

View File

@@ -682,3 +682,138 @@ describe('Load3DConfiguration "none" model handling', () => {
})
})
})
describe('Load3DConfiguration.onSceneInvalidated', () => {
function makeLoad3dMock(): Load3d {
return {
loadModel: vi.fn().mockResolvedValue(undefined),
clearModel: vi.fn(),
setUpDirection: vi.fn(),
setMaterialMode: vi.fn(),
setTargetSize: vi.fn(),
setCameraState: vi.fn(),
toggleGrid: vi.fn(),
setBackgroundColor: vi.fn(),
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
setBackgroundRenderMode: vi.fn(),
toggleCamera: vi.fn(),
setFOV: vi.fn(),
setLightIntensity: vi.fn(),
setHDRIIntensity: vi.fn(),
setHDRIAsBackground: vi.fn(),
setHDRIEnabled: vi.fn(),
emitModelReady: vi.fn()
} as unknown as Load3d
}
async function flush() {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
beforeEach(() => {
vi.mocked(Load3dUtils.splitFilePath).mockReturnValue(['', 'model.glb'])
vi.mocked(Load3dUtils.getResourceURL).mockReturnValue('/view')
})
it('width.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
width.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('height.callback invokes onSceneInvalidated', async () => {
const onSceneInvalidated = vi.fn()
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget: { value: 'none' } as unknown as IBaseWidget,
loadFolder: 'input',
width,
height,
onSceneInvalidated
})
await flush()
height.callback!(2048)
expect(onSceneInvalidated).toHaveBeenCalledTimes(1)
})
it('model_file widget callback invokes onSceneInvalidated after the model loads', async () => {
const onSceneInvalidated = vi.fn()
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('preserves any pre-existing model widget callback alongside the invalidation hook', async () => {
const onSceneInvalidated = vi.fn()
const original = vi.fn()
const modelWidget = {
value: 'none',
callback: original
} as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
onSceneInvalidated
})
await flush()
modelWidget.value = 'model.glb'
await flush()
expect(original).toHaveBeenCalledWith('model.glb')
expect(onSceneInvalidated).toHaveBeenCalled()
})
it('callbacks remain safe when onSceneInvalidated is omitted', async () => {
const width = { value: 1024 } as unknown as IBaseWidget
const height = { value: 1024 } as unknown as IBaseWidget
const modelWidget = { value: 'none' } as unknown as IBaseWidget
const config = new Load3DConfiguration(makeLoad3dMock())
config.configure({
modelWidget,
loadFolder: 'input',
width,
height
})
await flush()
expect(() => width.callback!(2048)).not.toThrow()
expect(() => height.callback!(2048)).not.toThrow()
expect(() => {
modelWidget.value = 'model.glb'
}).not.toThrow()
})
})

View File

@@ -23,6 +23,14 @@ type Load3DConfigurationSettings = {
height?: IBaseWidget
bgImagePath?: string
silentOnNotFound?: boolean
/**
* Called when a user-driven change to one of the wired widgets
* (model_file, width, height) makes the previously captured scene stale.
* Backend caching covers these inputs by themselves; this hook lets the
* caller invalidate any frontend-side capture cache so the next serialize
* re-renders at the new state.
*/
onSceneInvalidated?: () => void
}
const ANNOTATED_FILENAME_PATTERN = / \[(input|output|temp)\]$/
@@ -63,22 +71,33 @@ class Load3DConfiguration {
setting.modelWidget,
setting.loadFolder,
setting.cameraState,
setting.silentOnNotFound ?? false
setting.silentOnNotFound ?? false,
setting.onSceneInvalidated
)
this.setupTargetSize(
setting.width,
setting.height,
setting.onSceneInvalidated
)
this.setupTargetSize(setting.width, setting.height)
this.setupDefaultProperties(setting.bgImagePath)
}
private setupTargetSize(width?: IBaseWidget, height?: IBaseWidget) {
private setupTargetSize(
width?: IBaseWidget,
height?: IBaseWidget,
onSceneInvalidated?: () => void
) {
if (width && height) {
this.load3d.setTargetSize(width.value as number, height.value as number)
width.callback = (value: number) => {
this.load3d.setTargetSize(value, height.value as number)
onSceneInvalidated?.()
}
height.callback = (value: number) => {
this.load3d.setTargetSize(width.value as number, value)
onSceneInvalidated?.()
}
}
}
@@ -103,7 +122,8 @@ class Load3DConfiguration {
modelWidget: IBaseWidget,
loadFolder: string,
cameraState?: CameraState,
silentOnNotFound: boolean = false
silentOnNotFound: boolean = false,
onSceneInvalidated?: () => void
) {
const onModelWidgetUpdate = this.createModelUpdateHandler(
loadFolder,
@@ -137,6 +157,8 @@ class Load3DConfiguration {
if (originalCallback) {
originalCallback(value)
}
onSceneInvalidated?.()
}
}

View File

@@ -186,7 +186,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('scene.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'temp',
'scene.ply',
expect.objectContaining({ silentOnNotFound: true })
)
@@ -231,7 +231,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
const node = makePreviewNode({
widgets: [
{ name: 'model_file', value: '' },
{ name: 'image', value: '' },
{ name: 'viewport_state', value: '' },
widthWidget,
heightWidget
]
@@ -262,7 +262,7 @@ describe('Comfy.PreviewGaussianSplat.nodeCreated', () => {
)
const sceneWidget: FakeWidget & {
serializeValue?: () => Promise<unknown>
} = { name: 'image', value: '' }
} = { name: 'viewport_state', value: '' }
const node = makePreviewNode({
widgets: [{ name: 'model_file', value: '' }, sceneWidget]
})
@@ -318,7 +318,7 @@ describe('Comfy.PreviewPointCloud.nodeCreated', () => {
expect(node.properties['Last Time Model File']).toBe('pointcloud.ply')
expect(configureForSaveMeshMock).toHaveBeenLastCalledWith(
'output',
'temp',
'pointcloud.ply',
expect.objectContaining({ silentOnNotFound: true })
)

View File

@@ -46,7 +46,7 @@ function applyResultToLoad3d(
}
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', normalizedPath, {
config.configureForSaveMesh('temp', normalizedPath, {
silentOnNotFound: true
})
@@ -119,7 +119,7 @@ function createPreview3DExtension(
if (!lastTimeModelFile) return
const config = new Load3DConfiguration(load3d, node.properties)
config.configureForSaveMesh('output', lastTimeModelFile as string, {
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
silentOnNotFound: true
})
@@ -136,7 +136,9 @@ function createPreview3DExtension(
})
waitForLoad3d((load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const sceneWidget = node.widgets?.find(
(w) => w.name === 'viewport_state'
)
const widthWidget = node.widgets?.find((w) => w.name === 'width')
const heightWidget = node.widgets?.find((w) => w.name === 'height')

View File

@@ -268,6 +268,7 @@
"title": "Title",
"edit": "Edit",
"copy": "Copy",
"details": "Details",
"copyJobId": "Copy Job ID",
"copied": "Copied",
"relativeTime": {
@@ -350,6 +351,11 @@
"nodeSlotsError": "Node Slots Error",
"nodeWidgetsError": "Node Widgets Error",
"frameNodes": "Frame Nodes",
"arrange": "Arrange",
"arrangeVertically": "Arrange vertically",
"arrangeHorizontally": "Arrange horizontally",
"arrangeAsGrid": "Arrange as grid",
"arrangeSpacing": "Arrangement spacing",
"listening": "Listening...",
"ready": "Ready",
"play": "Play",
@@ -3549,6 +3555,7 @@
"parameters": "Parameters",
"nodes": "Nodes",
"info": "Info",
"infoFor": "Info for {item}",
"color": "Node color",
"pinned": "Pinned",
"bypass": "Bypass",
@@ -3568,6 +3575,7 @@
"hideInput": "Hide input",
"showInput": "Show input",
"locateNode": "Locate node on canvas",
"locateNodeFor": "Locate {item}",
"favorites": "FAVORITED INPUTS",
"favoritesNone": "NO FAVORITED INPUTS",
"favoritesNoneTooltip": "Star widgets to quickly access them without selecting nodes",
@@ -3601,6 +3609,7 @@
"errors": "Errors",
"noErrors": "No errors",
"executionErrorOccurred": "An error occurred during execution. Check the Errors tab for details.",
"errorLog": "Error log",
"findOnGithubTooltip": "Search GitHub issues for related problems",
"getHelpTooltip": "Report this error and we'll help you resolve it",
"enterSubgraph": "Enter subgraph",
@@ -3805,6 +3814,15 @@
"toastTitle": "Invalid input",
"toastMessage": "{nodeName} rejected the value for {inputName}."
},
"unknown_validation_error": {
"title": "Validation failed",
"message": "A node returned a validation error ComfyUI does not recognize.",
"details": "{nodeName} returned an unrecognized validation error: {errorType}",
"detailsWithRawDetails": "{nodeName} returned an unrecognized validation error ({errorType}): {rawDetails}",
"itemLabel": "{nodeName}",
"toastTitle": "Validation failed",
"toastMessage": "{nodeName} returned an unrecognized validation error."
},
"exception_during_inner_validation": {
"title": "Validation failed",
"message": "The workflow couldn't validate a connected node.",

View File

@@ -2,6 +2,7 @@
// 1:1 to an API error type. Simple validation mappings stay with the validation
// resolver.
export const MISSING_CONNECTION_CATALOG_ID = 'missing_connection'
export const UNKNOWN_VALIDATION_ERROR_CATALOG_ID = 'unknown_validation_error'
export const EXECUTION_FAILED_CATALOG_ID = 'execution_failed'
export const IMAGE_NOT_LOADED_CATALOG_ID = 'image_not_loaded'
export const OUT_OF_MEMORY_CATALOG_ID = 'out_of_memory'

View File

@@ -144,6 +144,26 @@ describe('errorMessageResolver', () => {
})
})
it('resolves unknown validation errors to fallback catalog copy', () => {
expect(
resolveRunErrorMessage({
kind: 'node_validation',
error: nodeValidationError('value_not_valid', undefined, 'some detail'),
nodeDisplayName: 'KSampler'
})
).toEqual({
catalogId: 'unknown_validation_error',
displayTitle: 'Validation failed',
displayMessage:
'A node returned a validation error ComfyUI does not recognize.',
displayDetails:
'KSampler returned an unrecognized validation error (value_not_valid): some detail',
displayItemLabel: 'KSampler',
toastTitle: 'Validation failed',
toastMessage: 'KSampler returned an unrecognized validation error.'
})
})
it('falls back to raw API copy when catalog keys are missing in the active locale', () => {
const originalLocale = i18n.global.locale.value
const originalKoMessages = i18n.global.getLocaleMessage('ko')

View File

@@ -1,4 +1,8 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
ResolvedErrorMessage,
RunErrorMessageSource
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
@@ -9,6 +13,15 @@ import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'node_validation' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: Extract<RunErrorMessageSource, { kind: 'execution' }>
): ResolvedCatalogErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage
export function resolveRunErrorMessage(
source: RunErrorMessageSource
): ResolvedErrorMessage {

View File

@@ -1,4 +1,7 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
RunErrorMessageSource
} from './types'
import { EXECUTION_FAILED_CATALOG_ID } from './catalogIds'
import type { ErrorResolveContext } from './catalogI18n'
@@ -11,7 +14,7 @@ type ExecutionErrorResolveContext = Pick<ErrorResolveContext, 'nodeDisplayName'>
export function resolveExecutionErrorMessage(
error: Extract<RunErrorMessageSource, { kind: 'execution' }>['error'],
context: ExecutionErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const exceptionMessage = error.exception_message.trim()
const match = resolveRuntimeCatalogMatch({
exceptionType: error.exception_type,

View File

@@ -1,4 +1,4 @@
import type { ResolvedErrorMessage } from './types'
import type { ResolvedCatalogErrorMessage } from './types'
import {
normalizeNodeName,
@@ -19,7 +19,7 @@ export function resolveRuntimeCatalogCopy(
params?: CatalogParams
detailsFallback?: string
} = {}
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const keyPrefix = `errorCatalog.runtimeErrors.${catalogId}`
const nodeName = normalizeNodeName(context.nodeDisplayName)
const params = { nodeName, ...options.params }
@@ -27,7 +27,7 @@ export function resolveRuntimeCatalogCopy(
translateCatalogMessage(`${keyPrefix}.${suffix}`, fallback, params)
const displayMessage = resolveMessage('message')
const result: ResolvedErrorMessage = {
const result: ResolvedCatalogErrorMessage = {
catalogId,
displayTitle: resolveMessage('title'),
displayMessage

View File

@@ -25,6 +25,10 @@ export interface ResolvedErrorMessage {
toastMessage?: string
}
export type ResolvedCatalogErrorMessage = ResolvedErrorMessage & {
catalogId: string
}
export type ResolvedMissingErrorMessage = ResolvedErrorMessage & {
displayTitle: string
displayMessage: string

View File

@@ -1,8 +1,9 @@
import type { NodeValidationError, ResolvedErrorMessage } from './types'
import type { NodeValidationError, ResolvedCatalogErrorMessage } from './types'
import {
IMAGE_NOT_LOADED_CATALOG_ID,
MISSING_CONNECTION_CATALOG_ID
MISSING_CONNECTION_CATALOG_ID,
UNKNOWN_VALIDATION_ERROR_CATALOG_ID
} from './catalogIds'
import {
normalizeNodeName,
@@ -117,6 +118,11 @@ const IMAGE_NOT_LOADED_VALIDATION_RULE = {
copyKeys: DEFAULT_COPY_KEYS
} satisfies ValidationCatalogRule
const UNKNOWN_VALIDATION_ERROR_RULE = {
catalogId: UNKNOWN_VALIDATION_ERROR_CATALOG_ID,
itemLabel: 'node'
} satisfies ValidationCatalogRule
function getInputName(error: NodeValidationError): string {
const inputName = error.extra_info?.input_name
return (
@@ -228,7 +234,7 @@ function getValueSpecificCopyKeys(
}
function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
return error.details.trim()
return error.details?.trim()
? {
detailsKey: 'detailsWithRawDetails',
toastMessageKey: 'toastMessageWithRawDetails'
@@ -237,7 +243,7 @@ function getRawDetailsCopyKeys(error: NodeValidationError): CopyKeys {
}
function getRawDetailsOnlyCopyKeys(error: NodeValidationError): CopyKeys {
if (!error.details.trim()) return DEFAULT_COPY_KEYS
if (!error.details?.trim()) return DEFAULT_COPY_KEYS
return {
detailsKey: 'detailsWithRawDetails',
@@ -272,16 +278,17 @@ function resolveValidationCatalogCopy(
context: ErrorResolveContext,
localeKey: string,
rule: ValidationCatalogRule
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
const nodeName = normalizeNodeName(context.nodeDisplayName)
const inputName = getInputName(error)
const trimmedDetails = error.details.trim()
const trimmedDetails = error.details?.trim() ?? ''
const rawDetails =
error.type === 'dependency_cycle'
? formatDependencyCycleDetails(trimmedDetails)
: trimmedDetails
const params = {
...getValidationParams(error, nodeName, inputName),
errorType: error.type || 'unknown',
rawDetails
}
const keyPrefix = `errorCatalog.validationErrors.${localeKey}`
@@ -306,7 +313,7 @@ function resolveValidationCatalogCopy(
),
displayDetails: translateOptionalCatalogMessage(
`${keyPrefix}.${copyKeys.detailsKey}`,
error.details,
error.details ?? '',
params
),
displayItemLabel: translateCatalogMessage(
@@ -330,7 +337,7 @@ function resolveValidationCatalogCopy(
export function resolveNodeValidationErrorMessage(
error: NodeValidationError,
context: ErrorResolveContext
): ResolvedErrorMessage {
): ResolvedCatalogErrorMessage {
if (isImageNotLoadedValidationError(error)) {
return resolveValidationCatalogCopy(
error,
@@ -341,7 +348,17 @@ export function resolveNodeValidationErrorMessage(
}
const rule = VALIDATION_ERROR_RULES[error.type]
if (!rule) return {}
if (!rule) {
return resolveValidationCatalogCopy(
error,
context,
'unknown_validation_error',
{
...UNKNOWN_VALIDATION_ERROR_RULE,
copyKeys: getRawDetailsOnlyCopyKeys(error)
}
)
}
return resolveValidationCatalogCopy(error, context, error.type, rule)
}

View File

@@ -60,7 +60,7 @@
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal :to="portalTarget" :disabled="isPortalDisabled">
<ComboboxPortal>
<ComboboxContent
data-capture-wheel="true"
data-testid="widget-select-default-overlay"
@@ -161,7 +161,6 @@ import {
import { computed, ref } from 'vue'
import type { CSSProperties } from 'vue'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { useRestoreFocusOnViewportPointer } from '@/renderer/extensions/vueNodes/widgets/composables/useRestoreFocusOnViewportPointer'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { cn } from '@comfyorg/tailwind-utils'
@@ -242,19 +241,10 @@ const searchInputContainerRef = ref<HTMLElement>()
const { handleFocusOutside, handleViewportPointerDown } =
useRestoreFocusOnViewportPointer(focusSearchInput)
const transformCompatProps = useTransformCompatOverlayProps()
const widgetOptions = computed(
() => widget.options as SelectWidgetOptions | undefined
)
const portalTarget = computed(() => {
const appendTo = transformCompatProps.value.appendTo
return appendTo === 'self' ? undefined : appendTo
})
const isPortalDisabled = computed(() => !portalTarget.value)
const disabled = computed(() => Boolean(widgetOptions.value?.disabled))
const placeholder = computed(() => widgetOptions.value?.placeholder ?? '')
const filterPlaceholder = computed(

View File

@@ -2,7 +2,6 @@
import { computed, provide, ref, toRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetsApi } from '@/platform/assets/composables/media/useAssetsApi'
import { useFlatOutputAssets } from '@/platform/assets/composables/media/useFlatOutputAssets'
@@ -53,12 +52,9 @@ const outputMediaAssets = isCloud
? useFlatOutputAssets()
: useAssetsApi('output')
const transformCompatProps = useTransformCompatOverlayProps()
const combinedProps = computed(() => ({
...filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS),
...transformCompatProps.value
}))
const combinedProps = computed(() =>
filterWidgetProps(props.widget.options, PANEL_EXCLUDED_PROPS)
)
const getAssetData = () => {
const nodeType: string | undefined =

View File

@@ -22,7 +22,7 @@
:class="
cn(
WidgetInputBaseClass,
'size-full resize-none text-xs',
'size-full resize-none text-(length:--comfy-textarea-font-size) leading-normal',
!hideLayoutField && 'pt-5',
// Avoid overflow-auto when idle to prevent per-textarea compositing layers.
'overflow-hidden hover:overflow-auto focus:overflow-auto'