Compare commits

..

16 Commits

Author SHA1 Message Date
jaeone94
4eee7083c4 fix: align missing media count semantics 2026-06-08 23:46:53 +09:00
jaeone94
7d9fe61669 fix: count missing media rows by node 2026-06-08 20:21:06 +09:00
jaeone94
01c8baf899 test: trim missing media locale fixture 2026-06-08 19:18:36 +09:00
jaeone94
2ddb9c6443 refactor: remove stale missing media state 2026-06-08 19:10:38 +09:00
jaeone94
841b3e89a7 fix: refine missing media item rows 2026-06-08 18:42:28 +09:00
jaeone94
0893becc6d refactor: animate missing media rows 2026-06-08 18:16:43 +09:00
jaeone94
f233dd7291 refactor: simplify missing media error card 2026-06-08 17:57:00 +09:00
jaeone94
74b9f16b62 Refine execution error presentation (#12683)
## Summary

This is the first PR in a planned stack to modernize the Workflow
Overview error tab. It focuses only on execution-style errors:
validation errors, runtime errors, and known prompt errors.

The intent is to establish the catalog-driven presentation model before
touching the larger missing-resource cards. Validation and prompt errors
are known product states, so this PR makes them read more like
structured guidance instead of generic reportable failures. Runtime
errors remain reportable, but their details are reorganized so the error
log is easier to scan and copy.

## What changed

- Groups validation errors by error catalog id instead of node
class/type.
- Adds an `unknown_validation_error` fallback catalog id so validation
grouping can follow one rule without special-case missing catalog ids.
- Shows validation group title and message once, then lists each
affected input as a compact item row.
- Adds per-item validation detail disclosure so detailed validation text
is still available without repeating the group title/message for every
item.
- Keeps locate-node behavior available from validation rows, including
keyboard/ARIA disclosure wiring.
- Removes GitHub, copy, and help actions from validation/prompt errors
because these are known, cataloged errors where the UI copy should guide
the user directly.
- Refines runtime error cards so the error log is visible by default,
has its own header, and keeps copy/report actions inside the log area.
- Removes the special full-panel singleton runtime layout so runtime
errors keep the same fixed card rhythm as the other error groups.
- Keeps runtime errors reportable via Get Help and Find on GitHub,
because these can still represent unexpected execution failures.
- Updates prompt error detail styling to match the darker runtime
error-log treatment.
- Restores display-message semantics for grouped execution messages:
`displayMessage ?? message` is used for user-facing dedupe instead of
raw backend-only messages.
- Adds focused unit coverage for catalog grouping, fallback validation
catalog ids, display-message grouping, runtime detail behavior, and the
updated prompt/validation action model.

## Planned stack

This PR intentionally keeps the first slice narrow. The broader redesign
is planned as a sequence of follow-up PRs rather than one large change:

1. Execution errors, this PR: validation, runtime, and prompt error
grouping/presentation.
2. Missing media: simplify image/video/audio missing-media cards around
catalog item labels and locate actions.
3. Missing node and swap node: align missing-pack rows, nested node
references, install/replace actions, and locate behavior.
4. Missing model: unify OSS and Cloud presentation, simplify
download/import actions, and improve import/download progress behavior.

The goal is to review and stabilize each slice before stacking the next
one. This is especially important because later missing-model changes
are much larger and should not obscure the catalog/error-card behavior
introduced here.

## Review focus

- Validation errors should now group by catalog id, not by node class.
- Validation groups intentionally show one message per group, with
individual affected inputs rendered as rows.
- Prompt and validation errors intentionally no longer show
report/copy/help actions.
- Runtime errors intentionally still show report actions, but only
inside the error-log panel.
- Node id badges are intentionally not shown in these execution error
rows; the follow-up missing-resource PRs will handle their own row
treatments separately.
- This PR does not change missing media, missing model, missing node
pack, or swap node cards.

## Screenshots  
### This PR 

Validation error
<img width="457" height="362" alt="스크린샷 2026-06-07 오전 4 26 19"
src="https://github.com/user-attachments/assets/4c35b9f3-57dd-4dae-b44a-6d2fd8547b7c"
/>

Runtime error
<img width="454" height="545" alt="스크린샷 2026-06-07 오전 4 24 24"
src="https://github.com/user-attachments/assets/b7d4482f-b35b-4ed2-90f2-0a62dafa3519"
/>

Prompt / Service error 
<img width="456" height="192" alt="스크린샷 2026-06-07 오전 4 27 58"
src="https://github.com/user-attachments/assets/aeec0978-b47f-40c7-ab71-0a0d18ceb054"
/>


### Old (main)
Validation error
<img width="457" height="853" alt="스크린샷 2026-06-07 오전 4 25 09"
src="https://github.com/user-attachments/assets/185dd573-430d-4041-8b31-a8eb6346f1ff"
/>

Runtime error
<img width="455" height="554" alt="스크린샷 2026-06-07 오전 4 24 58"
src="https://github.com/user-attachments/assets/deb1c09d-ea58-4d6a-9ac6-d2a3a9832fbe"
/>

Prompt / Service error
<img width="455" height="297" alt="스크린샷 2026-06-07 오전 4 28 14"
src="https://github.com/user-attachments/assets/c68eef7c-6525-4a5b-858c-6482fe76ad27"
/>





## Validation

- `pnpm format:check`
- `pnpm test:unit src/components/rightSidePanel/errors/TabErrors.test.ts
src/components/rightSidePanel/errors/ErrorNodeCard.test.ts
src/components/rightSidePanel/errors/useErrorGroups.test.ts
src/platform/errorCatalog/errorMessageResolver.test.ts`
- `pnpm typecheck`
- `pnpm lint`
- `pnpm knip`
- `pnpm build`
2026-06-08 06:52:57 +00:00
jaeone94
dbeb9cc10d fix: clear missing model on promoted widget change (#12677)
## Summary

Fixes FE-942 by clearing missing model indicators when promoted subgraph
widgets are changed through the legacy canvas path.

## Changes

- **What**: Resolves promoted widget error-clearing targets in
`useErrorClearingHooks`, including legacy canvas path events from
interior widgets.
- **Dependencies**: None.

## Review Focus

- Promoted widget validation errors clear by resolved interior widget
name, while missing model/media state clears by source widget name.
- Same-named promoted widgets are value-gated so changing one promoted
model widget does not clear unchanged sibling indicators.
- Core promoted widget event emission remains unchanged; the fix is
scoped to the error-clearing hook.

## Validation

- `pnpm test:unit src/composables/graph/useErrorClearingHooks.test.ts`
- `pnpm test:unit src/core/graph/subgraph/promotedWidgetView.test.ts
src/lib/litegraph/src/subgraph/SubgraphWidgetPromotion.test.ts`
- `pnpm exec oxfmt --check
src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm exec oxlint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts --type-aware`
- `pnpm exec eslint src/composables/graph/useErrorClearingHooks.ts
src/composables/graph/useErrorClearingHooks.test.ts
src/stores/executionErrorStore.ts`
- `pnpm typecheck`
- `pnpm test:browser:local
browser_tests/tests/propertiesPanel/errorsTabModeAware.spec.ts
--project=chromium --grep Subgraph`
- pre-push `knip --cache`

## Screenshots (if applicable)

N/A
2026-06-08 06:49:10 +00:00
Dante
49a05f32ca fix: stop Add Secret dialog rendering behind Settings modal (FE-939) (#12665)
## Summary

The "Add Secret" dialog (Settings → Secrets) opens but renders
**behind** the Settings modal because it never joins the shared dialog
z-index stack. This is the intermittent "dialog doesn't show" report in
FE-939.

## Changes

- **What**: Apply the existing `v-reka-z-index` directive to
`SecretFormDialog`'s `DialogOverlay` and `DialogContent`, mirroring
`GlobalDialog.vue`. This registers the dialog with the shared
`@primeuix` `ZIndex` `'modal'` sequence instead of relying on the static
`z-1700` Tailwind class, which always lost to the Settings modal once
the shared counter climbed past 1700.

## Review Focus

Root cause: `@primeuix` `ZIndex.set` runs with `autoZIndex=true`, so
every registered modal lands above the last in the shared `'modal'`
sequence — "whichever dialog opens last wins." The Settings modal
registers in that sequence (via `GlobalDialog`'s `v-reka-z-index` in the
reka renderer, or as a PrimeVue `p-dialog-mask` in the cloud build).
`SecretFormDialog` is a standalone reka dialog whose overlay/content
carried only the **static** `z-1700` class and never joined the
sequence, so it sat at a fixed `1700`. Whenever the shared counter has
climbed past 1700 (the report's `2102/2103` state, reached through
repeated dialog/section navigation) the Settings modal paints on top —
hence the intermittency. The ~3-line fix lets Add Secret join the same
sequence so it always opens above.

Scope is intentionally limited to `SecretFormDialog`. Two related items
are filed as follow-ups: FE-940 (durable self-registration in the shared
dialog primitives so static `z-1700` can be dropped everywhere) and
FE-941 (`VideoHelpDialog`, same bug class under the primevue-renderer
`UploadModelDialog`).

Linear:
[FE-939](https://linear.app/comfyorg/issue/FE-939/add-secret-dialog-renders-behind-settings-modal)

## Test plan

New `SecretFormDialog.zindex.test.ts` proves the fix red→green: with a
modal already registered at `1700`, the unfixed dialog content exposes
no inline z-index (`0`, fails the ordering assertion) and the fixed
dialog registers strictly above it (passes). It asserts a **relative**
ordering against a real prior registration, not a hardcoded constant, so
it is a behavioral regression guard rather than a change-detector.
(`@cloud` e2e deferred to FE-940/FE-941 follow-up work — the unit test
is the regression guard.)

CI red→green proof (Tests Unit):
- 🔴 test-only commit `f68d28b6` — [run
failed](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27001789061)
(`AssertionError: expected 0 to be greater than 1701`)
- 🟢 fix commit `add1862db1` —
[run](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27002203164)

## Screenshots

Reproduced on cloud with the shared PrimeVue `'modal'` counter driven to
the reported climbed state (`z-2102`); the only difference between the
two is whether Add Secret joins the sequence.

| Before (bug) — Add Secret hidden behind Settings | After (fix) — Add
Secret on top |
| --- | --- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/c0357dcd-5554-450e-8d4f-5a589a1566f6"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/80b7dd3f-5a73-4bfb-ae38-1eaed115bb8d"
/> |
2026-06-07 04:40:35 +00: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
80 changed files with 3644 additions and 3399 deletions

View File

@@ -58,7 +58,7 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
runway: { name: 'Runway', slug: 'runway' },
vidu: { name: 'Vidu', slug: 'vidu' },
bfl: { name: 'Flux (API)', slug: 'flux-api' },
grok: { name: 'Grok Imagine', slug: 'grok-imagine' },
grok: { name: 'Grok Image', slug: 'grok-image' },
stability: { name: 'Stability AI', slug: 'stability-ai' },
bytedance: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
bytedace: { name: 'Seedance (ByteDance)', slug: 'seedance-bytedance' },
@@ -86,20 +86,6 @@ const API_PROVIDER_MAP: Record<string, { name: string; slug: string }> = {
wavespped: { name: 'Wavespeed', slug: 'wavespeed' }
}
// Stub entries that exist only to issue 301 redirects from old slugs to
// their new canonical slugs. Keeps renames reproducible across regenerations.
const LEGACY_SLUG_REDIRECTS: OutputModel[] = [
{
slug: 'grok-image',
canonicalSlug: 'grok-imagine',
name: 'Grok Image',
displayName: 'Grok Image',
directory: 'partner_nodes',
huggingFaceUrl: '',
workflowCount: 0
}
]
function stripExt(name: string): string {
return name.replace(/\.(safetensors|ckpt|pt|bin)$/, '')
}
@@ -313,8 +299,7 @@ function run(): void {
throw new Error(
`Failed to parse ${file}: ${
error instanceof Error ? error.message : String(error)
}`,
{ cause: error }
}`
)
}
}
@@ -382,7 +367,7 @@ function run(): void {
displayName: m.name
}))
const combined = [...apiOutput, ...output, ...LEGACY_SLUG_REDIRECTS]
const combined = [...apiOutput, ...output]
const withThumbs = combined.filter((m) => m.thumbnailUrl).length
process.stdout.write(

View File

@@ -7,16 +7,12 @@ const {
item,
locale = 'en',
aspect = 'var(--aspect-ratio-gallery-card)',
mobile = false,
objectPosition = 'center',
objectFit = 'cover'
mobile = false
} = defineProps<{
item: GalleryItem
locale?: Locale
aspect?: string
mobile?: boolean
objectPosition?: string
objectFit?: string
}>()
defineEmits<{ click: [] }>()
@@ -35,15 +31,13 @@ defineEmits<{ click: [] }>()
loop
muted
playsinline
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<img
v-else
:src="item.image"
:alt="item.title"
class="size-full transition-transform duration-300 group-hover:scale-105"
:style="{ objectPosition, objectFit }"
class="size-full object-cover transition-transform duration-300 group-hover:scale-105"
/>
<!-- Desktop hover overlay -->
<div

View File

@@ -1,152 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { GalleryItem } from '../../data/gallery'
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
import GalleryCard from '../gallery/GalleryCard.vue'
import GalleryDetailModal from '../gallery/GalleryDetailModal.vue'
const { locale = 'en' } = defineProps<{ locale?: Locale }>()
const modelName = 'Grok'
const ctaHref = 'https://comfy.org/workflows/model/grok'
const items: GalleryItem[] = [
{
id: 'subway-swan',
image: 'https://media.comfy.org/website/gallery/subway-swan_compressed.png',
title: 'Subway Swan',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'milos-little-wonder',
video:
'https://media.comfy.org/website/gallery/milos-little-wonder_compressed.mp4',
title: 'Milos Little Wonder',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
},
{
id: 'amber-passage',
image:
'https://media.comfy.org/website/gallery/amber-passage_compressed.jpg',
title: 'Amber Passage',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats',
objectPosition: 'bottom'
},
{
id: 'neon-revenant',
video:
'https://media.comfy.org/website/gallery/neon-revenant_compressed.mp4',
title: 'Neon Revenant',
userAlias: 'Eric Solorio',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.instagram.com/enigmatic_e'
},
{
id: 'midnight-umami',
image:
'https://media.comfy.org/website/gallery/midnight_umami_compressed.png',
title: 'Midnight Umami',
userAlias: 'Purz Beats',
teamAlias: 'Comfy',
tool: 'Grok Imagine',
href: 'https://www.youtube.com/@PurzBeats'
}
]
const modalOpen = ref(false)
const modalIndex = ref(0)
function openDetail(index: number) {
modalIndex.value = index
modalOpen.value = true
}
const title = t('models.list.creations.title', locale).replace(
'{name}',
modelName
)
const ctaLabel = t('models.list.creations.cta', locale)
</script>
<template>
<section
data-testid="model-creations"
class="flex flex-col items-center px-4 py-16 lg:px-20 lg:pt-36"
>
<h2
class="max-w-4xl text-center text-3xl font-light tracking-tight text-primary-comfy-canvas lg:text-5xl"
>
{{ title }}
</h2>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-16 px-8 py-4 uppercase"
>
{{ ctaLabel }}
</BrandButton>
<div class="mt-20 hidden w-full flex-col gap-2 lg:flex">
<div class="grid grid-cols-2 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(0, 2)"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i)"
/>
</div>
<div v-if="items.length > 2" class="grid grid-cols-3 gap-2">
<GalleryCard
v-for="(item, i) in items.slice(2, 5)"
:key="i + 2"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
@click="openDetail(i + 2)"
/>
</div>
</div>
<div
class="rounded-5xl bg-transparency-white-t4 mt-12 flex w-full flex-col gap-6 p-2 max-lg:pb-6 lg:hidden"
>
<GalleryCard
v-for="(item, i) in items"
:key="i"
:item
:locale
:object-position="item.objectPosition"
:object-fit="item.objectFit"
mobile
@click="openDetail(i)"
/>
</div>
<GalleryDetailModal
v-if="modalOpen"
:items
:initial-index="modalIndex"
:locale
@close="modalOpen = false"
/>
</section>
</template>

View File

@@ -1,62 +0,0 @@
<script setup lang="ts">
import type { Locale } from '../../i18n/translations'
import { t } from '../../i18n/translations'
import BrandButton from '../common/BrandButton.vue'
const {
locale = 'en',
modelName,
ctaHref,
videoSrc,
videoAriaLabel
} = defineProps<{
locale?: Locale
modelName: string
ctaHref: string
videoSrc: string
videoAriaLabel?: string
}>()
</script>
<template>
<section class="flex flex-col items-center px-6 pt-16 text-center lg:pt-36">
<h1
class="max-w-4xl text-4xl font-light tracking-tight text-primary-comfy-canvas lg:text-6xl"
>
{{
t('models.list.heroTitle.before', locale).replace('{name}', modelName)
}}
<span class="text-primary-comfy-yellow">ComfyUI</span>
{{
t('models.list.heroTitle.after', locale).replace('{name}', modelName)
}}
</h1>
<p
class="mt-6 max-w-2xl text-sm text-pretty text-primary-comfy-canvas lg:text-base"
>
{{ t('hero.subtitle', locale) }}
</p>
<BrandButton
:href="ctaHref"
variant="solid"
size="lg"
class="mt-10 px-8 py-4 uppercase"
>
{{ t('models.list.heroCta', locale).replace('{name}', modelName) }}
</BrandButton>
<div class="mt-16 w-full max-w-5xl">
<video
:src="videoSrc"
:aria-label="videoAriaLabel || undefined"
:aria-hidden="videoAriaLabel ? undefined : true"
autoplay
loop
muted
playsinline
preload="metadata"
class="rounded-4.5xl size-full object-cover"
/>
</div>
</section>
</template>

View File

@@ -78,7 +78,7 @@ function getCardClass(layoutClass: string): string {
<template>
<section
class="max-w-9xl mx-auto bg-primary-comfy-ink px-4 py-16 lg:px-20 lg:py-40"
class="bg-primary-comfy-ink max-w-9xl mx-auto px-4 py-24 lg:px-20 lg:py-40"
>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center">
<p
@@ -88,18 +88,18 @@ function getCardClass(layoutClass: string): string {
</p>
<h2
class="text-3.5xl/tight mt-8 max-w-4xl text-center font-light text-primary-comfy-canvas lg:text-5xl"
class="text-primary-comfy-canvas text-3.5xl/tight mt-8 max-w-4xl text-center font-light lg:text-5xl"
>
{{ t('cloud.aiModels.heading', locale) }}
</h2>
<p
class="mt-8 max-w-xl text-center text-sm font-light text-primary-comfy-canvas lg:text-base/snug"
class="text-primary-comfy-canvas mt-8 max-w-xl text-center text-sm font-light lg:text-base/snug"
>
{{ t('cloud.aiModels.subtitle', locale) }}
</p>
<div class="mt-16 w-full lg:mt-24">
<div class="mt-24 w-full">
<div class="rounded-4xl border border-white/12 p-2 lg:p-1.5">
<div class="grid grid-cols-1 gap-2 lg:grid-cols-12">
<a
@@ -180,15 +180,14 @@ function getCardClass(layoutClass: string): string {
<BrandButton
:href="externalLinks.workflows"
variant="outline"
size="lg"
class="mt-4 w-full max-w-md px-8 py-4 text-center lg:mt-8 lg:w-auto"
class="mt-4 w-full max-w-md text-center lg:mt-8 lg:w-auto"
>
<!-- <span class="lg:hidden"> -->
{{ t('cloud.aiModels.ctaMobile', locale) }}
<!-- </span> -->
<!-- <span class="hidden lg:inline">{{
<span class="lg:hidden">{{
t('cloud.aiModels.ctaMobile', locale)
}}</span>
<span class="hidden lg:inline">{{
t('cloud.aiModels.ctaDesktop', locale)
}}</span> -->
}}</span>
</BrandButton>
</div>
</section>

File diff suppressed because it is too large Load Diff

View File

@@ -90,7 +90,7 @@ export const modelMetadata: Record<string, ModelOverride> = {
hubSlug: 'seedance',
featured: true
},
'grok-imagine': {
'grok-image': {
hubSlug: 'grok',
featured: false
},

View File

@@ -7,8 +7,6 @@ export interface GalleryItem {
teamAlias: string
tool: string
href?: string
objectPosition?: string
objectFit?: string
/** Defaults to true. Set to false to hide this item from rendered lists. */
visible?: boolean
}

View File

@@ -4458,80 +4458,6 @@ const translations = {
'zh-CN': '支持的模型'
},
// Models list page (/models)
'models.list.label': { en: 'MODELS', 'zh-CN': '模型' },
'models.list.heroCta': {
en: 'Try {name} Now',
'zh-CN': '立即试用 {name}'
},
'models.list.creations.title': {
en: '{name} Image and Video Creations',
'zh-CN': '{name} 图像与视频创作'
},
'models.list.creations.cta': {
en: 'Explore Workflows',
'zh-CN': '探索工作流'
},
'models.list.heroTitle.before': {
en: '{name} in',
'zh-CN': ''
},
'models.list.heroTitle.after': {
en: '',
'zh-CN': ' 中的 {name}'
},
'models.list.heroSubtitle': {
en: 'From open-source diffusion checkpoints to partner APIs — every major model, with community workflow templates ready to run.',
'zh-CN':
'从开源扩散模型到合作伙伴 API涵盖每一个主流模型并附带可直接运行的社区工作流模板。'
},
'models.list.card.workflows': {
en: '{count} workflows',
'zh-CN': '{count} 个工作流'
},
'models.list.contact.label': {
en: 'COMFY HUB',
'zh-CN': 'COMFY HUB'
},
'models.showcase.label': { en: 'AI MODELS', 'zh-CN': 'AI 模型' },
'models.showcase.heading': {
en: 'Run the worlds\nleading AI models',
'zh-CN': '运行全球领先的\nAI 模型'
},
'models.showcase.subtitle': {
en: 'New models are added as they launch.',
'zh-CN': '新模型发布后会第一时间上线。'
},
'models.showcase.cta': {
en: 'EXPLORE WORKFLOWS',
'zh-CN': '探索工作流'
},
'models.showcase.card.grokImagine': {
en: 'Grok Imagine',
'zh-CN': 'Grok Imagine'
},
'models.showcase.card.nanoBananaPro': {
en: 'Nano Banana Pro',
'zh-CN': 'Nano Banana Pro'
},
'models.showcase.card.ltx23': {
en: 'LTX 2.3',
'zh-CN': 'LTX 2.3'
},
'models.showcase.card.qwenAdvancedEdit': {
en: 'Advanced image\nediting with Qwen',
'zh-CN': '使用 Qwen 进行\n高级图像编辑'
},
'models.showcase.card.wan22TextToVideo': {
en: 'Wan 2.2\ntext to video',
'zh-CN': 'Wan 2.2\n文字转视频'
},
'models.list.contact.heading': {
en: 'Pick a model and explore what the community has built. <a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">Browse Comfy Hub</a> for the newest workflows.',
'zh-CN':
'选择一个模型,浏览社区的创作成果。<a href="https://comfy.org/workflows" target="_blank" rel="noopener noreferrer" class="text-primary-comfy-yellow underline">访问 Comfy Hub</a> 查看最新工作流。'
},
// Payment status pages
'payment.success.label': {
en: 'PAYMENT',

View File

@@ -1,22 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro'
import ModelsHeroSection from '../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="Models — Comfy"
description="Run the world's leading AI models in ComfyUI. Browse every supported model with community workflow templates ready to run."
>
<ModelsHeroSection
modelName="Grok Imagine"
ctaHref="/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="Grok Imagine output created with ComfyUI"
/>
<ModelCreationsSection client:load />
<AIModelsSection client:load />
<ProductShowcaseSection client:load />
</BaseLayout>

View File

@@ -1,23 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro'
import ModelsHeroSection from '../../components/models/ModelsHeroSection.vue'
import ModelCreationsSection from '../../components/models/ModelCreationsSection.vue'
import AIModelsSection from '../../components/product/shared/AIModelsSection.vue'
import ProductShowcaseSection from '../../components/home/ProductShowcaseSection.vue'
---
<BaseLayout
title="模型 — Comfy"
description="在 ComfyUI 中运行世界领先的 AI 模型。浏览所有支持的模型及社区工作流模板。"
>
<ModelsHeroSection
locale="zh-CN"
modelName="Grok Imagine"
ctaHref="/zh-CN/p/supported-models/grok-imagine"
videoSrc="https://media.comfy.org/website/models/video_ComfdyUI_00001_.mp4"
videoAriaLabel="使用 ComfyUI 创建的 Grok Imagine 作品"
/>
<ModelCreationsSection client:load locale="zh-CN" />
<AIModelsSection client:load locale="zh-CN" />
<ProductShowcaseSection client:load locale="zh-CN" />
</BaseLayout>

View File

@@ -0,0 +1,115 @@
{
"id": "test-missing-model-promoted-widget",
"revision": 0,
"last_node_id": 2,
"last_link_id": 0,
"nodes": [
{
"id": 2,
"type": "subgraph-with-promoted-missing-model",
"pos": [450, 250],
"size": [400, 200],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [],
"properties": {},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [],
"groups": [],
"definitions": {
"subgraphs": [
{
"id": "subgraph-with-promoted-missing-model",
"version": 1,
"state": {
"lastGroupId": 0,
"lastNodeId": 1,
"lastLinkId": 1,
"lastRerouteId": 0
},
"revision": 0,
"config": {},
"name": "Subgraph with Promoted Missing Model",
"inputNode": {
"id": -10,
"bounding": [100, 200, 120, 60]
},
"outputNode": {
"id": -20,
"bounding": [500, 200, 120, 60]
},
"inputs": [
{
"id": "ckpt-name-input-id",
"name": "ckpt_name",
"type": "COMBO",
"linkIds": [1],
"pos": { "0": 150, "1": 220 }
}
],
"outputs": [],
"widgets": [],
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [250, 180],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"localized_name": "ckpt_name",
"name": "ckpt_name",
"type": "COMBO",
"widget": {
"name": "ckpt_name"
},
"link": 1
}
],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": null },
{ "name": "CLIP", "type": "CLIP", "links": null },
{ "name": "VAE", "type": "VAE", "links": null }
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": ["fake_model.safetensors"]
}
],
"links": [
{
"id": 1,
"origin_id": -10,
"origin_slot": 0,
"target_id": 1,
"target_slot": 0,
"type": "COMBO"
}
]
}
]
},
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [0, 0]
}
},
"models": [
{
"name": "fake_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "checkpoints"
}
],
"version": 0.4
}

View File

@@ -69,11 +69,6 @@ export const TestIds = {
missingMediaGroup: 'error-group-missing-media',
swapNodesGroup: 'error-group-swap-nodes',
missingMediaRow: 'missing-media-row',
missingMediaUploadDropzone: 'missing-media-upload-dropzone',
missingMediaLibrarySelect: 'missing-media-library-select',
missingMediaStatusCard: 'missing-media-status-card',
missingMediaConfirmButton: 'missing-media-confirm-button',
missingMediaCancelButton: 'missing-media-cancel-button',
missingMediaLocateButton: 'missing-media-locate-button',
publishTabPanel: 'publish-tab-panel',
apiSignin: 'api-signin-dialog',

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')
})
})

View File

@@ -5,37 +5,10 @@ import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
import { TestIds } from '@e2e/fixtures/selectors'
import { loadWorkflowAndOpenErrorsTab } from '@e2e/fixtures/helpers/ErrorsTabHelper'
async function uploadFileViaDropzone(comfyPage: ComfyPage) {
const dropzone = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaUploadDropzone
)
const [fileChooser] = await Promise.all([
comfyPage.page.waitForEvent('filechooser'),
dropzone.click()
])
await fileChooser.setFiles(comfyPage.assetPath('test_upload_image.png'))
}
async function confirmPendingSelection(comfyPage: ComfyPage) {
const confirmButton = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaConfirmButton
)
await expect(confirmButton).toBeEnabled()
await confirmButton.click()
}
function getMediaRow(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaRow)
}
function getStatusCard(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaStatusCard)
}
function getDropzone(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.missingMediaUploadDropzone)
}
function getErrorOverlay(comfyPage: ComfyPage) {
return comfyPage.page.getByTestId(TestIds.dialogs.errorOverlay)
}
@@ -81,7 +54,7 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
await expect(getMediaRow(comfyPage)).toHaveCount(2)
})
test('Shows upload dropzone and library select for each missing item', async ({
test('Shows missing item label and locate action', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -89,32 +62,15 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
await expect(getDropzone(comfyPage)).toBeVisible()
await expect(getMediaRow(comfyPage)).toHaveText(/Load Image - image/)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLibrarySelect)
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaLocateButton)
).toBeVisible()
})
})
test.describe('Upload flow', () => {
test('Upload via file picker shows status card then allows confirm', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Library select flow', () => {
test('Selecting from library shows status card then allows confirm', async ({
test.describe('List behavior', () => {
test('Clicking the missing item label navigates canvas to the node', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
@@ -122,63 +78,27 @@ test.describe('Errors tab - Missing media', { tag: '@ui' }, () => {
'missing/missing_media_single'
)
const librarySelect = comfyPage.page.getByTestId(
TestIds.dialogs.missingMediaLibrarySelect
)
await librarySelect.getByRole('combobox').click()
const offsetBefore = await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
const optionCount = await comfyPage.page.getByRole('option').count()
if (optionCount === 0) {
// oxlint-disable-next-line playwright/no-skipped-test -- no library options available in CI
test.skip()
return
}
await comfyPage.page.getByRole('option').first().click()
await expect(getStatusCard(comfyPage)).toBeVisible()
await confirmPendingSelection(comfyPage)
await expect(getMediaRow(comfyPage)).toHaveCount(0)
})
})
test.describe('Cancel selection', () => {
test('Cancelling pending selection returns to upload/library UI', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await expect(getStatusCard(comfyPage)).toBeVisible()
await expect(getDropzone(comfyPage)).toBeHidden()
await comfyPage.page
.getByTestId(TestIds.dialogs.missingMediaCancelButton)
await getMediaRow(comfyPage)
.getByRole('button', { name: 'Load Image - image', exact: true })
.click()
await expect(getStatusCard(comfyPage)).toBeHidden()
await expect(getDropzone(comfyPage)).toBeVisible()
})
})
test.describe('All resolved', () => {
test('Missing Inputs group disappears when all items are resolved', async ({
comfyPage
}) => {
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_media_single'
)
await uploadFileViaDropzone(comfyPage)
await confirmPendingSelection(comfyPage)
await expect(
comfyPage.page.getByTestId(TestIds.dialogs.missingMediaGroup)
).toBeHidden()
await expect
.poll(async () => {
return await comfyPage.page.evaluate(() => {
const canvas = window['app']?.canvas
return canvas?.ds?.offset
? [canvas.ds.offset[0], canvas.ds.offset[1]]
: null
})
})
.not.toEqual(offsetBefore)
})
})

View File

@@ -369,6 +369,62 @@ test.describe('Errors tab - Mode-aware errors', { tag: '@ui' }, () => {
await cleanupFakeModel(comfyPage)
})
test(
'Resolving a promoted missing model widget through the legacy canvas path clears its error',
{ tag: ['@canvas', '@widget', '@subgraph'] },
async ({ comfyPage }) => {
const resolvedModelName = 'v1-5-pruned-emaonly-fp16.safetensors'
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', false)
await loadWorkflowAndOpenErrorsTab(
comfyPage,
'missing/missing_model_promoted_widget'
)
const missingModelGroup = comfyPage.page.getByTestId(
TestIds.dialogs.missingModelsGroup
)
await expect(missingModelGroup).toContainText(
/fake_model\.safetensors\s*\(1\)/
)
await comfyPage.page.evaluate((value) => {
const hostNode = window.app!.graph!.getNodeById(2)
if (!hostNode?.isSubgraphNode()) {
throw new Error('Expected subgraph host node')
}
const interiorNode = hostNode.subgraph.getNodeById(1)
const widget = interiorNode?.widgets?.find(
(entry) => entry.name === 'ckpt_name'
)
type SettableWidget = typeof widget & {
setValue?: (
value: string,
options: {
e: PointerEvent
node: unknown
canvas: unknown
}
) => void
}
const settableWidget = widget as SettableWidget | undefined
if (!settableWidget?.setValue) {
throw new Error('Expected concrete ckpt_name widget')
}
settableWidget.setValue(value, {
e: new PointerEvent('pointerup'),
node: hostNode,
canvas: window.app!.canvas
})
}, resolvedModelName)
await expect(missingModelGroup).toBeHidden()
}
)
test('Bypassing a subgraph hides interior errors, un-bypassing restores them', async ({
comfyPage
}) => {

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',
@@ -71,16 +93,7 @@ describe('TabErrors.vue', () => {
refreshing: 'Refreshing missing models.'
},
missingMedia: {
missingMediaTitle: 'Missing Inputs',
image: 'Images',
uploadFile: 'Upload {type}',
useFromLibrary: 'Use from Library',
confirmSelection: 'Confirm selection',
locateNode: 'Locate node',
expandNodes: 'Show referencing nodes',
collapseNodes: 'Hide referencing nodes',
cancelSelection: 'Cancel selection',
or: 'OR'
missingMediaTitle: 'Missing Inputs'
}
}
}
@@ -144,29 +157,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 +270,7 @@ describe('TabErrors.vue', () => {
title: 'KSampler'
} as ReturnType<typeof getNodeByExecutionId>)
renderComponent({
const { user } = renderComponent({
executionError: {
lastExecutionError: {
prompt_id: 'abc',
@@ -190,12 +285,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 +329,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 +337,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 +378,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)
})
@@ -351,6 +459,50 @@ describe('TabErrors.vue', () => {
).toBeInTheDocument()
})
it('renders one missing media item per referencing node and locates the selected node', async () => {
const { getNodeByExecutionId } = await import('@/utils/graphTraversalUtil')
vi.mocked(getNodeByExecutionId).mockImplementation((_, nodeId) => {
const titles: Record<string, string> = {
'3': 'First Loader',
'4': 'Second Loader'
}
return {
title: titles[String(nodeId)] ?? ''
} as ReturnType<typeof getNodeByExecutionId>
})
const { user } = renderComponent({
missingMedia: {
missingMediaCandidates: [
{
nodeId: '3',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
{
nodeId: '4',
nodeType: 'PreviewImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
}
] satisfies MissingMediaCandidate[]
}
})
expect(screen.getAllByTestId('missing-media-row')).toHaveLength(2)
await user.click(
screen.getByRole('button', { name: 'Second Loader - image' })
)
expect(mockFocusNode.mock.calls.at(-1)?.[0]).toBe('4')
})
it('renders swap node rows below the section display message', () => {
const swapNode = {
type: 'OldSampler',

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"
@@ -211,7 +256,6 @@
<MissingMediaCard
v-if="group.type === 'missing_media'"
:missing-media-groups="missingMediaGroups"
:show-node-id-badge="showNodeIdBadge"
@locate-node="handleLocateAssetNode"
/>
</PropertiesAccordionItem>
@@ -255,6 +299,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 +311,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 +331,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 +360,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 +379,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 +482,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':
@@ -116,6 +131,7 @@ import { useExecutionErrorStore } from '@/stores/executionErrorStore'
import { useMissingNodesErrorStore } from '@/platform/nodeReplacement/missingNodesErrorStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
import { useErrorGroups } from './useErrorGroups'
import type { MissingMediaCandidate } from '@/platform/missingMedia/types'
function makeMissingNodeType(
type: string,
@@ -163,6 +179,24 @@ function makeModel(
}
}
function makeMedia(
name: string,
opts: {
nodeId: string | number
nodeType?: string
widgetName?: string
}
): MissingMediaCandidate {
return {
name,
nodeId: opts.nodeId,
nodeType: opts.nodeType ?? 'LoadImage',
widgetName: opts.widgetName ?? 'image',
mediaType: 'image',
isMissing: true
}
}
function createErrorGroups() {
const store = useExecutionErrorStore()
const searchQuery = ref('')
@@ -384,7 +418,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 +439,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 +489,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 +799,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 +819,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 () => {
@@ -999,6 +1079,27 @@ describe('useErrorGroups', () => {
groups.missingModelGroups.value
)
})
it('counts missing media by affected node rows, not grouped filenames', async () => {
const { store, groups } = createErrorGroups()
store.surfaceMissingMedia([
makeMedia('shared.png', { nodeId: '1', nodeType: 'LoadImage' }),
makeMedia('shared.png', { nodeId: '2', nodeType: 'PreviewImage' })
])
await nextTick()
expect(store.totalErrorCount).toBe(2)
expect(groups.missingMediaGroups.value).toHaveLength(1)
expect(groups.missingMediaGroups.value[0].items).toHaveLength(1)
expect(
groups.missingMediaGroups.value[0].items[0].referencingNodes
).toHaveLength(2)
const missingMediaGroup = groups.allErrorGroups.value.find(
(group) => group.type === 'missing_media'
)
expect(missingMediaGroup?.displayTitle).toBe('Missing Inputs (2)')
})
})
describe('tabErrorGroups', () => {

View File

@@ -30,9 +30,11 @@ 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'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import {
resolveMissingErrorMessage,
resolveRunErrorMessage
@@ -43,7 +45,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 +67,7 @@ export interface SwapNodeGroup {
interface GroupEntry {
type: 'execution'
displayTitle: string
displayMessage?: string
priority: number
cards: Map<string, ErrorCardData>
}
@@ -75,10 +77,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 +112,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 +148,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 +158,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 +182,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 +200,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 +310,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 +350,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 +378,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 +392,10 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
error: e,
nodeDisplayName
})
}
}),
filterBySelection
)
},
filterBySelection
)
}
}
}
@@ -428,20 +411,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
)
}
@@ -710,10 +691,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroups(): ErrorGroup[] {
if (!missingMediaGroups.value.length) return []
const totalItems = missingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
)
const totalRows = countMissingMediaReferences(missingMediaGroups.value)
return [
{
type: 'missing_media' as const,
@@ -722,7 +700,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: missingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}
@@ -826,9 +804,8 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
function buildMissingMediaGroupsFiltered(): ErrorGroup[] {
if (!filteredMissingMediaGroups.value.length) return []
const totalItems = filteredMissingMediaGroups.value.reduce(
(count, group) => count + group.items.length,
0
const totalRows = countMissingMediaReferences(
filteredMissingMediaGroups.value
)
return [
{
@@ -838,7 +815,7 @@ export function useErrorGroups(searchQuery: MaybeRefOrGetter<string>) {
...resolveMissingErrorMessage({
kind: 'missing_media',
groups: filteredMissingMediaGroups.value,
count: totalItems,
count: totalRows,
isCloud
})
}
@@ -867,10 +844,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 +856,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

@@ -5,6 +5,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { installErrorClearingHooks } from '@/composables/graph/useErrorClearingHooks'
import { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
import type {
CanvasPointer,
CanvasPointerEvent,
LGraphCanvas
} from '@/lib/litegraph/src/litegraph'
import {
createTestSubgraph,
createTestSubgraphNode
@@ -285,14 +290,7 @@ describe('Widget change error clearing via onWidgetChanged', () => {
)
expect(promotedWidget).toBeDefined()
// PromotedWidgetView.name returns displayName ("ckpt_input"), which is
// passed as errorInputName to clearSimpleNodeErrors. Seed the error
// with that name so the slot-name filter matches.
seedRequiredInputMissingNodeError(
store,
interiorExecId,
promotedWidget!.name
)
seedRequiredInputMissingNodeError(store, interiorExecId, 'ckpt_name')
subgraphNode.onWidgetChanged!.call(
subgraphNode,
@@ -304,6 +302,227 @@ describe('Widget change error clearing via onWidgetChanged', () => {
expect(store.lastNodeErrors).toBeNull()
})
it('clears range errors for promoted widgets by interior widget name', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'steps_input', type: 'INT' }]
})
const interiorNode = new LGraphNode('KSampler')
const interiorInput = interiorNode.addInput('steps_input', 'INT')
interiorNode.addWidget('number', 'steps', 150, () => undefined, {
min: 1,
max: 100
})
interiorInput.widget = { name: 'steps' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const store = useExecutionErrorStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
store.lastNodeErrors = {
[interiorExecId]: {
errors: [
{
type: 'value_bigger_than_max',
message: 'Too big',
details: '',
extra_info: { input_name: 'steps' }
}
],
dependent_outputs: [],
class_type: 'KSampler'
}
}
const promotedWidget = subgraphNode.widgets?.find(
(w) => 'sourceWidgetName' in w && w.sourceWidgetName === 'steps'
)
expect(promotedWidget).toBeDefined()
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'steps',
50,
150,
promotedWidget!
)
expect(store.lastNodeErrors).toBeNull()
})
it('clears missing model state when a promoted widget changes through the legacy canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [{ name: 'ckpt_input', type: '*' }]
})
const interiorNode = new LGraphNode('CheckpointLoaderSimple')
interiorNode.type = 'CheckpointLoaderSimple'
const interiorInput = interiorNode.addInput('ckpt_input', '*')
interiorNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
interiorInput.widget = { name: 'ckpt_name' }
subgraph.add(interiorNode)
subgraph.inputNode.slots[0].connect(interiorInput, interiorNode)
const subgraphNode = createTestSubgraphNode(subgraph, {
id: 65,
pos: [0, 0],
size: [200, 100]
})
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const missingModelStore = useMissingModelStore()
const interiorExecId = `${subgraphNode.id}:${interiorNode.id}`
missingModelStore.setMissingModels([
{
nodeId: interiorExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
const promotedWidget = subgraphNode.widgets?.find(
(widget) =>
'sourceWidgetName' in widget && widget.sourceWidgetName === 'ckpt_name'
)
expect(promotedWidget).toBeDefined()
const clickEvent = fromAny<CanvasPointerEvent, unknown>({
canvasX: 190,
canvasY: 20,
deltaX: 0
})
const pointer = fromAny<CanvasPointer, unknown>({
eDown: clickEvent
})
const canvas = fromAny<LGraphCanvas, unknown>({
graph_mouse: [190, 20],
last_mouseclick: 0
})
const handled = promotedWidget!.onPointerDown?.(
pointer,
subgraphNode,
canvas
)
expect(handled).toBe(true)
expect(pointer.onClick).toBeDefined()
pointer.onClick?.(clickEvent)
expect(missingModelStore.missingModelCandidates).toBeNull()
})
it('keeps unchanged same-named promoted model targets on the canvas path', () => {
const subgraph = createTestSubgraph({
inputs: [
{ name: 'first_ckpt', type: '*' },
{ name: 'second_ckpt', type: '*' }
]
})
const firstNode = new LGraphNode('CheckpointLoaderSimple')
firstNode.type = 'CheckpointLoaderSimple'
const firstInput = firstNode.addInput('first_ckpt', '*')
const firstWidget = firstNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
firstInput.widget = { name: 'ckpt_name' }
subgraph.add(firstNode)
subgraph.inputNode.slots[0].connect(firstInput, firstNode)
const secondNode = new LGraphNode('CheckpointLoaderSimple')
secondNode.type = 'CheckpointLoaderSimple'
const secondInput = secondNode.addInput('second_ckpt', '*')
secondNode.addWidget(
'combo',
'ckpt_name',
'missing.safetensors',
() => undefined,
{ values: ['missing.safetensors', 'present.safetensors'] }
)
secondInput.widget = { name: 'ckpt_name' }
subgraph.add(secondNode)
subgraph.inputNode.slots[1].connect(secondInput, secondNode)
const subgraphNode = createTestSubgraphNode(subgraph, { id: 65 })
subgraphNode._internalConfigureAfterSlots()
const graph = subgraphNode.graph as LGraph
graph.add(subgraphNode)
vi.spyOn(app, 'rootGraph', 'get').mockReturnValue(graph)
installErrorClearingHooks(graph)
const promotedWidgets =
subgraphNode.widgets?.filter(
(widget) =>
'sourceWidgetName' in widget &&
widget.sourceWidgetName === 'ckpt_name'
) ?? []
expect(promotedWidgets).toHaveLength(2)
const missingModelStore = useMissingModelStore()
const firstExecId = `${subgraphNode.id}:${firstNode.id}`
const secondExecId = `${subgraphNode.id}:${secondNode.id}`
missingModelStore.setMissingModels([
{
nodeId: firstExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate,
{
nodeId: secondExecId,
nodeType: 'CheckpointLoaderSimple',
widgetName: 'ckpt_name',
isAssetSupported: false,
name: 'missing.safetensors',
isMissing: true
} satisfies MissingModelCandidate
])
firstWidget.value = 'present.safetensors'
subgraphNode.onWidgetChanged!.call(
subgraphNode,
'ckpt_name',
'present.safetensors',
'missing.safetensors',
firstWidget
)
expect(missingModelStore.missingModelCandidates).toEqual([
expect.objectContaining({
nodeId: secondExecId,
widgetName: 'ckpt_name',
name: 'missing.safetensors'
})
])
})
})
describe('installErrorClearingHooks lifecycle', () => {

View File

@@ -7,6 +7,7 @@
*/
import { useChainCallback } from '@/composables/functional/useChainCallback'
import { isPromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import type { PromotedWidgetView } from '@/core/graph/subgraph/promotedWidgetTypes'
import { resolveConcretePromotedWidget } from '@/core/graph/subgraph/resolveConcretePromotedWidget'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraph, LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -45,22 +46,128 @@ import {
isAncestorPathActive
} from '@/utils/graphTraversalUtil'
function resolvePromotedExecId(
rootGraph: LGraph,
node: LGraphNode,
interface WidgetErrorClearingTarget {
executionId: string
validationInputName: string
assetWidgetName: string
currentValue: unknown
options?: { min?: number; max?: number }
}
function getWidgetRangeOptions(widget: IBaseWidget): {
min?: number
max?: number
} {
return {
min: widget.options?.min,
max: widget.options?.max
}
}
function plainWidgetToErrorTarget(
widget: IBaseWidget,
hostExecId: string
): string {
if (!isPromotedWidgetView(widget)) return hostExecId
): WidgetErrorClearingTarget {
return {
executionId: hostExecId,
validationInputName: widget.name,
assetWidgetName: widget.name,
currentValue: widget.value,
options: getWidgetRangeOptions(widget)
}
}
function promotedWidgetToErrorTarget(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: PromotedWidgetView,
hostExecId: string
): WidgetErrorClearingTarget {
const result = resolveConcretePromotedWidget(
node,
hostNode,
widget.sourceNodeId,
widget.sourceWidgetName
)
if (result.status === 'resolved' && result.resolved.node) {
return getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId
const execId =
result.status === 'resolved' && result.resolved.node
? (getExecutionIdByNode(rootGraph, result.resolved.node) ?? hostExecId)
: hostExecId
const resolvedWidget =
result.status === 'resolved' ? result.resolved.widget : widget
return {
executionId: execId,
validationInputName: resolvedWidget.name,
assetWidgetName: widget.sourceWidgetName,
currentValue: resolvedWidget.value,
options: getWidgetRangeOptions(resolvedWidget)
}
}
function resolveCanvasPathPromotedWidgetTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (!hostNode.isSubgraphNode?.() || isPromotedWidgetView(widget)) return []
// Canvas-path events lose promoted identity, so the post-write value
// disambiguates same-named promoted widgets.
return (hostNode.widgets ?? [])
.filter(isPromotedWidgetView)
.filter((promotedWidget) => promotedWidget.sourceWidgetName === widget.name)
.map((promotedWidget) =>
promotedWidgetToErrorTarget(
rootGraph,
hostNode,
promotedWidget,
hostExecId
)
)
.filter((target) => Object.is(target.currentValue, newValue))
}
function resolveWidgetErrorTargets(
rootGraph: LGraph,
hostNode: LGraphNode,
widget: IBaseWidget,
hostExecId: string,
newValue: unknown
): WidgetErrorClearingTarget[] {
if (isPromotedWidgetView(widget)) {
return [
promotedWidgetToErrorTarget(rootGraph, hostNode, widget, hostExecId)
]
}
const canvasPathTargets = resolveCanvasPathPromotedWidgetTargets(
rootGraph,
hostNode,
widget,
hostExecId,
newValue
)
return canvasPathTargets.length
? canvasPathTargets
: [plainWidgetToErrorTarget(widget, hostExecId)]
}
function clearWidgetErrorTargets(
targets: WidgetErrorClearingTarget[],
newValue: unknown
): void {
const store = useExecutionErrorStore()
for (const target of targets) {
store.clearWidgetRelatedErrors(
target.executionId,
target.validationInputName,
target.assetWidgetName,
newValue,
target.options
)
}
return hostExecId
}
const hookedNodes = new WeakSet<LGraphNode>()
@@ -103,23 +210,14 @@ function installNodeHooks(node: LGraphNode): void {
const hostExecId = getExecutionIdByNode(app.rootGraph, node)
if (!hostExecId) return
const execId = resolvePromotedExecId(
const targets = resolveWidgetErrorTargets(
app.rootGraph,
node,
widget,
hostExecId
)
const widgetName = isPromotedWidgetView(widget)
? widget.sourceWidgetName
: widget.name
useExecutionErrorStore().clearWidgetRelatedErrors(
execId,
widget.name,
widgetName,
newValue,
{ min: widget.options?.min, max: widget.options?.max }
hostExecId,
newValue
)
clearWidgetErrorTargets(targets, newValue)
}
)
}

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",
@@ -3658,21 +3667,7 @@
"refreshFailed": "Failed to refresh missing models. Please try again."
},
"missingMedia": {
"missingMediaTitle": "Missing Inputs",
"image": "Images",
"video": "Videos",
"audio": "Audio",
"locateNode": "Locate node",
"expandNodes": "Show referencing nodes",
"collapseNodes": "Hide referencing nodes",
"uploadFile": "Upload {type}",
"uploading": "Uploading...",
"uploaded": "Uploaded",
"selectedFromLibrary": "Selected from library",
"useFromLibrary": "Use from Library",
"confirmSelection": "Confirm selection",
"cancelSelection": "Cancel selection",
"or": "OR"
"missingMediaTitle": "Missing Inputs"
}
},
"errorOverlay": {
@@ -3723,6 +3718,7 @@
},
"missing_media": {
"displayMessage": "A required media input has no file selected.",
"itemLabel": "{nodeName} - {inputName}",
"toastTitleOne": "Media input missing",
"toastTitleMany": "Missing media inputs",
"toastMessageWithNode": "{nodeName} is missing a required media file.",
@@ -3805,6 +3801,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

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel,
resolveRunErrorMessage
} from './errorMessageResolver'
import type { NodeValidationError } from './types'
@@ -144,6 +145,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')
@@ -1559,6 +1580,32 @@ describe('errorMessageResolver', () => {
})
})
it.for([
{
source: { nodeType: 'LoadImage', widgetName: 'image' },
displayItemLabel: 'Load Image - image'
},
{
source: {
nodeDisplayName: 'Custom Loader',
nodeType: 'LoadImage',
widgetName: 'image'
},
displayItemLabel: 'Custom Loader - image'
},
{
source: { nodeType: '', widgetName: '' },
displayItemLabel: 'This node - unknown input'
}
] as const)(
'resolves missing media item labels from $source',
({ source, displayItemLabel }) => {
expect(resolveMissingMediaItemLabel(source)).toEqual({
displayItemLabel
})
}
)
it.for([
[
'image',
@@ -1629,6 +1676,44 @@ describe('errorMessageResolver', () => {
}
)
it('summarizes a shared missing media file by affected node references', () => {
expect(
resolveMissingErrorMessage({
kind: 'missing_media',
groups: [
{
mediaType: 'image',
items: [
{
name: 'shared.png',
mediaType: 'image',
representative: {
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image',
mediaType: 'image',
name: 'shared.png',
isMissing: true
},
referencingNodes: [
{ nodeId: '1', widgetName: 'image' },
{ nodeId: '2', widgetName: 'image' }
]
}
]
}
],
count: 2,
isCloud: false
})
).toMatchObject({
displayTitle: 'Missing Inputs (2)',
toastTitle: 'Missing media inputs',
toastMessage:
'Please select the missing media inputs before running this workflow.'
})
})
it('summarizes multiple missing model and media items', () => {
const modelGroups = missingModelGroups('a.safetensors', 'b.safetensors')

View File

@@ -1,14 +1,29 @@
import type { ResolvedErrorMessage, RunErrorMessageSource } from './types'
import type {
ResolvedCatalogErrorMessage,
ResolvedErrorMessage,
RunErrorMessageSource
} from './types'
import { resolveExecutionErrorMessage } from './executionErrorResolver'
import { resolveMissingErrorMessage } from './missingErrorResolver'
import { resolvePromptErrorMessage } from './promptErrorResolver'
import { resolveNodeValidationErrorMessage } from './validationErrorResolver'
// Public facade for error catalog resolution. Source-specific resolver modules
// own the actual matching/copy rules so this file stays as the routing boundary.
export { resolveMissingErrorMessage }
export {
resolveMissingErrorMessage,
resolveMissingMediaItemLabel
} from './missingErrorResolver'
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

@@ -2,7 +2,8 @@ import type {
MissingErrorMessageSource,
ResolvedMissingErrorMessage
} from './types'
import { translateCatalogMessage } from './catalogI18n'
import { normalizeNodeName, translateCatalogMessage } from './catalogI18n'
import { countMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import { st } from '@/i18n'
function formatCountTitle(title: string, count: number): string {
@@ -255,6 +256,12 @@ type MissingMediaSource = Extract<
{ kind: 'missing_media' }
>
interface MissingMediaItemLabelSource {
nodeDisplayName?: string
nodeType?: string
widgetName?: string
}
function getMissingMediaItems(source: MissingMediaSource) {
return source.groups.flatMap((group) => group.items)
}
@@ -272,9 +279,29 @@ function resolveMissingMediaDisplayMessage(): string {
)
}
export function resolveMissingMediaItemLabel(
source: MissingMediaItemLabelSource
): { displayItemLabel: string } {
const nodeName = normalizeNodeName(
source.nodeDisplayName ||
formatNodeTypeName(source.nodeType ?? '') ||
undefined
)
const inputName =
source.widgetName?.trim() ||
translateCatalogMessage('errorCatalog.fallbacks.inputName', 'unknown input')
return {
displayItemLabel: translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.itemLabel',
'{nodeName} - {inputName}',
{ nodeName, inputName }
)
}
}
function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
if (items.length !== 1) {
if (countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastTitleMany',
'Missing media inputs'
@@ -290,7 +317,7 @@ function resolveMissingMediaToastTitle(source: MissingMediaSource): string {
function resolveMissingMediaToastMessage(source: MissingMediaSource): string {
const items = getMissingMediaItems(source)
const [firstItem] = items
if (!firstItem || items.length !== 1) {
if (!firstItem || countMissingMediaReferences(source.groups) !== 1) {
return translateCatalogMessage(
'errorCatalog.missingErrors.missing_media.toastMessageMany',
'Please select the missing media inputs before running this workflow.'

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

@@ -1,50 +1,61 @@
<template>
<div class="px-4 pb-2">
<div
v-for="group in missingMediaGroups"
:key="group.mediaType"
class="flex w-full flex-col border-t border-interface-stroke py-2 first:border-t-0 first:pt-0"
<TransitionGroup
tag="ul"
name="list-scale"
class="relative m-0 list-none space-y-1 p-0"
>
<!-- Media type header -->
<div class="flex h-8 w-full items-center">
<p
class="min-w-0 flex-1 truncate text-sm font-medium text-destructive-background-hover"
>
<i
aria-hidden="true"
:class="MEDIA_TYPE_ICONS[group.mediaType]"
class="mr-1 size-3.5 align-text-bottom"
/>
{{ t(`rightSidePanel.missingMedia.${group.mediaType}`) }}
({{ group.items.length }})
</p>
</div>
<!-- Media file rows -->
<div class="flex flex-col gap-1 overflow-hidden pl-2">
<MissingMediaRow
v-for="item in group.items"
:key="item.name"
:item="item"
:show-node-id-badge="showNodeIdBadge"
@locate-node="emit('locateNode', $event)"
/>
</div>
</div>
<li
v-for="item in missingMediaItems"
:key="item.key"
data-testid="missing-media-row"
class="min-w-0"
>
<div class="flex min-w-0 items-center gap-2">
<span class="flex min-w-0 flex-1">
<button
type="button"
class="focus-visible:ring-ring m-0 inline max-w-full cursor-pointer appearance-none rounded-sm 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-1 focus-visible:outline-none"
@click="emit('locateNode', item.nodeId)"
>
{{ item.displayItemLabel }}
</button>
</span>
<Button
data-testid="missing-media-locate-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.displayItemLabel
})
"
@click.stop="emit('locateNode', item.nodeId)"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-4" />
</Button>
</div>
</li>
</TransitionGroup>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import type {
MissingMediaGroup,
MediaType
} from '@/platform/missingMedia/types'
import MissingMediaRow from '@/platform/missingMedia/components/MissingMediaRow.vue'
import Button from '@/components/ui/button/Button.vue'
import { resolveMissingMediaItemLabel } from '@/platform/errorCatalog/errorMessageResolver'
import { getMissingMediaReferences } from '@/platform/missingMedia/missingMediaGrouping'
import type { MissingMediaGroup } from '@/platform/missingMedia/types'
import { app } from '@/scripts/app'
import { st } from '@/i18n'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
const { missingMediaGroups } = defineProps<{
missingMediaGroups: MissingMediaGroup[]
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
@@ -53,9 +64,60 @@ const emit = defineEmits<{
const { t } = useI18n()
const MEDIA_TYPE_ICONS: Record<MediaType, string> = {
image: 'icon-[lucide--image]',
video: 'icon-[lucide--video]',
audio: 'icon-[lucide--music]'
interface MissingMediaItemEntry {
key: string
nodeId: string
displayItemLabel: string
}
const missingMediaItems = computed(() => {
return getMissingMediaReferences(missingMediaGroups)
.map(({ mediaItem, nodeRef }) => {
const nodeId = String(nodeRef.nodeId)
return {
key: `${nodeId}:${nodeRef.widgetName}:${mediaItem.name}`,
nodeId,
displayItemLabel: getDisplayItemLabel(
nodeId,
nodeRef.nodeType ?? mediaItem.representative.nodeType,
nodeRef.widgetName
)
}
})
.sort(compareMissingMediaItems)
})
function getDisplayItemLabel(
nodeId: string,
nodeType: string,
widgetName: string
) {
const nodeDisplayName = getNodeDisplayLabel(nodeId, '')
return resolveMissingMediaItemLabel({
nodeDisplayName,
nodeType,
widgetName
}).displayItemLabel
}
function compareMissingMediaItems(
a: MissingMediaItemEntry,
b: MissingMediaItemEntry
) {
return (
a.nodeId.localeCompare(b.nodeId, undefined, { numeric: true }) ||
a.displayItemLabel.localeCompare(b.displayItemLabel)
)
}
function getNodeDisplayLabel(nodeId: string, fallback: string): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, nodeId)
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
</script>

View File

@@ -1,147 +0,0 @@
<template>
<div class="flex flex-col gap-2">
<div v-if="showDivider" class="flex items-center justify-center py-0.5">
<span class="text-xs font-bold text-muted-foreground">
{{ t('rightSidePanel.missingMedia.or') }}
</span>
</div>
<Select
:model-value="modelValue"
:disabled="options.length === 0"
@update:model-value="handleSelect"
>
<SelectTrigger
size="md"
class="border-transparent bg-secondary-background text-xs hover:border-interface-stroke"
>
<SelectValue
:placeholder="t('rightSidePanel.missingMedia.useFromLibrary')"
/>
</SelectTrigger>
<SelectContent class="max-h-72">
<template v-if="options.length > SEARCH_THRESHOLD" #prepend>
<div class="px-1 pb-1.5">
<div
class="flex items-center gap-1.5 rounded-md border border-border-default px-2"
>
<i
aria-hidden="true"
class="icon-[lucide--search] size-3.5 shrink-0 text-muted-foreground"
/>
<input
v-model="filterQuery"
type="text"
:aria-label="t('g.searchPlaceholder', { subject: '' })"
class="h-7 w-full border-none bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('g.searchPlaceholder', { subject: '' })"
@keydown.stop
/>
</div>
</div>
</template>
<SelectItem
v-for="option in filteredOptions"
:key="option.value"
:value="option.value"
class="text-xs"
>
<div class="flex items-center gap-2">
<img
v-if="mediaType === 'image'"
:src="getPreviewUrl(option.value)"
alt=""
class="size-8 shrink-0 rounded-sm object-cover"
loading="lazy"
/>
<video
v-else-if="mediaType === 'video'"
aria-hidden="true"
:src="getPreviewUrl(option.value)"
class="size-8 shrink-0 rounded-sm object-cover"
preload="metadata"
muted
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--music] size-5 shrink-0 text-muted-foreground"
/>
<span class="min-w-0 truncate">{{ option.name }}</span>
</div>
</SelectItem>
<div
v-if="filteredOptions.length === 0"
role="status"
class="px-3 py-2 text-xs text-muted-foreground"
>
{{ t('g.noResultsFound') }}
</div>
</SelectContent>
</Select>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useFuse } from '@vueuse/integrations/useFuse'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectTrigger from '@/components/ui/select/SelectTrigger.vue'
import SelectValue from '@/components/ui/select/SelectValue.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'
import type { MediaType } from '@/platform/missingMedia/types'
import { api } from '@/scripts/api'
const {
options,
showDivider = false,
mediaType
} = defineProps<{
modelValue: string | undefined
options: { name: string; value: string }[]
showDivider?: boolean
mediaType: MediaType
}>()
const emit = defineEmits<{
select: [value: string]
}>()
const { t } = useI18n()
const SEARCH_THRESHOLD = 4
const filterQuery = ref('')
watch(
() => options.length,
(len) => {
if (len <= SEARCH_THRESHOLD) filterQuery.value = ''
}
)
const { results: fuseResults } = useFuse(filterQuery, () => options, {
fuseOptions: {
keys: ['name'],
threshold: 0.4,
ignoreLocation: true
},
matchAllWhenSearchEmpty: true
})
const filteredOptions = computed(() => fuseResults.value.map((r) => r.item))
function getPreviewUrl(filename: string): string {
return api.apiURL(`/view?filename=${encodeURIComponent(filename)}&type=input`)
}
function handleSelect(value: unknown) {
if (typeof value === 'string') {
filterQuery.value = ''
emit('select', value)
}
}
</script>

View File

@@ -1,318 +0,0 @@
<template>
<div data-testid="missing-media-row" class="flex w-full flex-col pb-3">
<!-- File header -->
<div class="flex h-8 w-full items-center gap-2">
<i
aria-hidden="true"
class="text-foreground icon-[lucide--file] size-4 shrink-0"
/>
<!-- Single node: show node display name instead of filename -->
<template v-if="isSingleNode">
<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"
>
#{{ item.referencingNodes[0].nodeId }}
</span>
<p
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="singleNodeLabel"
>
{{ singleNodeLabel }}
</p>
</template>
<!-- Multiple nodes: show filename with count -->
<p
v-else
class="text-foreground min-w-0 flex-1 truncate text-sm font-medium"
:title="displayName"
>
{{ displayName }}
({{ item.referencingNodes.length }})
</p>
<!-- Confirm button (visible when pending selection exists) -->
<Button
data-testid="missing-media-confirm-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.confirmSelection')"
:disabled="!isPending"
:class="
cn(
'size-8 shrink-0 rounded-lg transition-colors',
isPending ? 'bg-primary/10 hover:bg-primary/15' : 'opacity-20'
)
"
@click="confirmSelection(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--check] size-4"
:class="isPending ? 'text-primary' : 'text-foreground'"
/>
</Button>
<!-- Locate button (single node only) -->
<Button
v-if="isSingleNode"
data-testid="missing-media-locate-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(item.referencingNodes[0].nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
<!-- Expand button (multiple nodes only) -->
<Button
v-if="!isSingleNode"
variant="textonly"
size="icon-sm"
:aria-label="
expanded
? t('rightSidePanel.missingMedia.collapseNodes')
: t('rightSidePanel.missingMedia.expandNodes')
"
:aria-expanded="expanded"
:class="
cn(
'size-8 shrink-0 transition-transform duration-200 hover:bg-transparent',
expanded && 'rotate-180'
)
"
@click="toggleExpand(item.name)"
>
<i
aria-hidden="true"
class="icon-[lucide--chevron-down] size-4 text-muted-foreground"
/>
</Button>
</div>
<!-- Referencing nodes (expandable) -->
<TransitionCollapse>
<div
v-if="expanded && item.referencingNodes.length > 1"
class="mb-1 flex flex-col gap-0.5 overflow-hidden pl-6"
>
<div
v-for="nodeRef in item.referencingNodes"
:key="`${String(nodeRef.nodeId)}::${nodeRef.widgetName}`"
class="flex h-7 items-center"
>
<span
v-if="showNodeIdBadge"
class="mr-1 shrink-0 rounded-md bg-secondary-background-selected px-2 py-0.5 font-mono text-xs font-bold text-muted-foreground"
>
#{{ nodeRef.nodeId }}
</span>
<p class="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{{ getNodeDisplayLabel(String(nodeRef.nodeId), item.name) }}
</p>
<Button
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.locateNode')"
class="mr-1 size-8 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="emit('locateNode', String(nodeRef.nodeId))"
>
<i aria-hidden="true" class="icon-[lucide--locate] size-3" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Status card (uploading, uploaded, or library select) -->
<TransitionCollapse>
<div
v-if="isPending || isUploading"
data-testid="missing-media-status-card"
role="status"
aria-live="polite"
class="bg-foreground/5 relative mt-1 overflow-hidden rounded-lg border border-interface-stroke p-2"
>
<div class="relative z-10 flex items-center gap-2">
<div class="flex size-8 shrink-0 items-center justify-center">
<i
v-if="currentUpload?.status === 'uploading'"
aria-hidden="true"
class="icon-[lucide--loader-circle] size-5 animate-spin text-muted-foreground"
/>
<i
v-else
aria-hidden="true"
class="icon-[lucide--file-check] size-5 text-muted-foreground"
/>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center">
<span class="text-foreground truncate text-xs/tight font-medium">
{{ pendingDisplayName }}
</span>
<span class="mt-0.5 text-xs/tight text-muted-foreground">
<template v-if="currentUpload?.status === 'uploading'">
{{ t('rightSidePanel.missingMedia.uploading') }}
</template>
<template v-else-if="currentUpload?.status === 'uploaded'">
{{ t('rightSidePanel.missingMedia.uploaded') }}
</template>
<template v-else>
{{ t('rightSidePanel.missingMedia.selectedFromLibrary') }}
</template>
</span>
</div>
<Button
data-testid="missing-media-cancel-button"
variant="textonly"
size="icon-sm"
:aria-label="t('rightSidePanel.missingMedia.cancelSelection')"
class="relative z-10 size-6 shrink-0 text-muted-foreground hover:text-base-foreground"
@click="cancelSelection(item.name)"
>
<i aria-hidden="true" class="icon-[lucide--circle-x] size-4" />
</Button>
</div>
</div>
</TransitionCollapse>
<!-- Upload + Library (when no pending selection) -->
<TransitionCollapse>
<div v-if="!isPending && !isUploading" class="mt-1 flex flex-col gap-1">
<!-- Upload dropzone -->
<div ref="dropZoneRef" class="flex w-full flex-col py-1">
<button
data-testid="missing-media-upload-dropzone"
type="button"
:class="
cn(
'flex w-full cursor-pointer items-center justify-center rounded-lg border border-dashed border-component-node-border bg-transparent px-3 py-2 text-xs text-muted-foreground transition-colors hover:border-base-foreground hover:text-base-foreground',
isOverDropZone && 'border-primary text-primary'
)
"
@click="openFilePicker()"
>
{{
t('rightSidePanel.missingMedia.uploadFile', {
type: extensionHint
})
}}
</button>
</div>
<!-- OR separator + Use from Library -->
<MissingMediaLibrarySelect
data-testid="missing-media-library-select"
:model-value="undefined"
:options="libraryOptions"
:show-divider="true"
:media-type="item.mediaType"
@select="handleLibrarySelect(item.name, $event)"
/>
</div>
</TransitionCollapse>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useDropZone, useFileDialog } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { cn } from '@comfyorg/tailwind-utils'
import Button from '@/components/ui/button/Button.vue'
import TransitionCollapse from '@/components/rightSidePanel/layout/TransitionCollapse.vue'
import MissingMediaLibrarySelect from '@/platform/missingMedia/components/MissingMediaLibrarySelect.vue'
import type { MissingMediaViewModel } from '@/platform/missingMedia/types'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import {
useMissingMediaInteractions,
getNodeDisplayLabel,
getMediaDisplayName
} from '@/platform/missingMedia/composables/useMissingMediaInteractions'
const { item, showNodeIdBadge } = defineProps<{
item: MissingMediaViewModel
showNodeIdBadge: boolean
}>()
const emit = defineEmits<{
locateNode: [nodeId: string]
}>()
const { t } = useI18n()
const store = useMissingMediaStore()
const { uploadState, pendingSelection } = storeToRefs(store)
const {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
} = useMissingMediaInteractions()
const displayName = getMediaDisplayName(item.name)
const isSingleNode = item.referencingNodes.length === 1
const singleNodeLabel = isSingleNode
? getNodeDisplayLabel(String(item.referencingNodes[0].nodeId), item.name)
: ''
const acceptType = getAcceptType(item.mediaType)
const extensionHint = getExtensionHint(item.mediaType)
const expanded = computed(() => isExpanded(item.name))
const matchingCandidate = computed(() => {
const candidates = store.missingMediaCandidates
if (!candidates?.length) return null
return candidates.find((c) => c.name === item.name) ?? null
})
const libraryOptions = computed(() => {
const candidate = matchingCandidate.value
if (!candidate) return []
return getLibraryOptions(candidate)
})
const isPending = computed(() => hasPendingSelection(item.name))
const isUploading = computed(
() => uploadState.value[item.name]?.status === 'uploading'
)
const currentUpload = computed(() => uploadState.value[item.name])
const pendingDisplayName = computed(() => {
if (currentUpload.value) return currentUpload.value.fileName
const pending = pendingSelection.value[item.name]
return pending ? getMediaDisplayName(pending) : ''
})
const dropZoneRef = ref<HTMLElement | null>(null)
const { isOverDropZone } = useDropZone(dropZoneRef, {
onDrop: (_files, event) => {
event?.stopPropagation()
const file = _files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
}
})
const { open: openFilePicker, onChange: onFileSelected } = useFileDialog({
accept: acceptType,
multiple: false
})
onFileSelected((files) => {
const file = files?.[0]
if (file) {
handleUpload(file, item.name, item.mediaType)
}
})
</script>

View File

@@ -1,224 +0,0 @@
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useAssetsStore } from '@/stores/assetsStore'
import { useMissingMediaStore } from '@/platform/missingMedia/missingMediaStore'
import type {
MissingMediaCandidate,
MediaType
} from '@/platform/missingMedia/types'
import { getNodeByExecutionId } from '@/utils/graphTraversalUtil'
import { isCloud } from '@/platform/distribution/types'
import { addToComboValues, resolveComboValues } from '@/utils/litegraphUtil'
import { resolveNodeDisplayName } from '@/utils/nodeTitleUtil'
import { st } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { IComboWidget } from '@/lib/litegraph/src/types/widgets'
import {
ACCEPTED_IMAGE_TYPES,
ACCEPTED_VIDEO_TYPES
} from '@/utils/mediaUploadUtil'
const MEDIA_ACCEPT_MAP: Record<MediaType, string> = {
image: ACCEPTED_IMAGE_TYPES,
video: ACCEPTED_VIDEO_TYPES,
audio: 'audio/*'
}
function getMediaComboWidget(
candidate: MissingMediaCandidate
): { node: LGraphNode; widget: IComboWidget } | null {
const graph = app.rootGraph
if (!graph || candidate.nodeId == null) return null
const node = getNodeByExecutionId(graph, String(candidate.nodeId))
if (!node) return null
const widget = node.widgets?.find(
(w) => w.name === candidate.widgetName && w.type === 'combo'
) as IComboWidget | undefined
if (!widget) return null
return { node, widget }
}
function resolveLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
const result = getMediaComboWidget(candidate)
if (!result) return []
return resolveComboValues(result.widget)
.filter((v) => v !== candidate.name)
.map((v) => ({ name: getMediaDisplayName(v), value: v }))
}
function applyValueToNodes(
candidates: MissingMediaCandidate[],
name: string,
newValue: string
) {
const matching = candidates.filter((c) => c.name === name)
for (const c of matching) {
const result = getMediaComboWidget(c)
if (!result) continue
addToComboValues(result.widget, newValue)
result.widget.value = newValue
result.widget.callback?.(newValue)
result.node.graph?.setDirtyCanvas(true, true)
}
}
export function getNodeDisplayLabel(
nodeId: string | number,
fallback: string
): string {
const graph = app.rootGraph
if (!graph) return fallback
const node = getNodeByExecutionId(graph, String(nodeId))
return resolveNodeDisplayName(node, {
emptyLabel: fallback,
untitledLabel: fallback,
st
})
}
/**
* Resolve display name for a media file.
* Cloud widgets store asset hashes as values; this resolves them to
* human-readable names via assetsStore.getInputName().
*/
export function getMediaDisplayName(name: string): string {
if (!isCloud) return name
return useAssetsStore().getInputName(name)
}
export function useMissingMediaInteractions() {
const store = useMissingMediaStore()
const assetsStore = useAssetsStore()
function isExpanded(key: string): boolean {
return store.expandState[key] ?? false
}
function toggleExpand(key: string) {
store.expandState[key] = !isExpanded(key)
}
function getAcceptType(mediaType: MediaType): string {
return MEDIA_ACCEPT_MAP[mediaType]
}
function getExtensionHint(mediaType: MediaType): string {
if (mediaType === 'audio') return 'audio'
const exts = MEDIA_ACCEPT_MAP[mediaType]
.split(',')
.map((mime) => mime.split('/')[1])
.join(', ')
return `${exts}, ...`
}
function getLibraryOptions(
candidate: MissingMediaCandidate
): { name: string; value: string }[] {
return resolveLibraryOptions(candidate)
}
/** Step 1: Store selection from library (does not apply yet). */
function handleLibrarySelect(name: string, value: string) {
store.pendingSelection[name] = value
}
/** Step 1: Upload file and store result as pending (does not apply yet). */
async function handleUpload(file: File, name: string, mediaType: MediaType) {
if (!file.type || !file.type.startsWith(`${mediaType}/`)) {
useToastStore().addAlert(
st(
'toastMessages.unsupportedFileType',
'Unsupported file type. Please select a valid file.'
)
)
return
}
store.uploadState[name] = { fileName: file.name, status: 'uploading' }
try {
const body = new FormData()
body.append('image', file)
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
return
}
const data = await resp.json()
const uploadedPath: string = data.subfolder
? `${data.subfolder}/${data.name}`
: data.name
store.uploadState[name] = { fileName: file.name, status: 'uploaded' }
store.pendingSelection[name] = uploadedPath
// Refresh assets store (non-critical — upload already succeeded)
try {
await assetsStore.updateInputs()
} catch {
// Asset list refresh failed but upload is valid; selection can proceed
}
} catch {
useToastStore().addAlert(
st(
'toastMessages.uploadFailed',
'Failed to upload file. Please try again.'
)
)
delete store.uploadState[name]
}
}
/** Step 2: Apply pending selection to widgets and remove from missing list. */
function confirmSelection(name: string) {
const value = store.pendingSelection[name]
if (!value || !store.missingMediaCandidates) return
applyValueToNodes(store.missingMediaCandidates, name, value)
store.removeMissingMediaByName(name)
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function cancelSelection(name: string) {
delete store.pendingSelection[name]
delete store.uploadState[name]
}
function hasPendingSelection(name: string): boolean {
return name in store.pendingSelection
}
return {
isExpanded,
toggleExpand,
getAcceptType,
getExtensionHint,
getLibraryOptions,
handleLibrarySelect,
handleUpload,
confirmSelection,
cancelSelection,
hasPendingSelection
}
}

View File

@@ -0,0 +1,26 @@
import { sumBy } from 'es-toolkit'
import type { MissingMediaGroup, MissingMediaViewModel } from './types'
export interface MissingMediaReference {
mediaItem: MissingMediaViewModel
nodeRef: MissingMediaViewModel['referencingNodes'][number]
}
export function getMissingMediaReferences(
groups: MissingMediaGroup[]
): MissingMediaReference[] {
return groups.flatMap((group) =>
group.items.flatMap((mediaItem) =>
mediaItem.referencingNodes.map((nodeRef) => ({ mediaItem, nodeRef }))
)
)
}
export function countMissingMediaReferences(
groups: MissingMediaGroup[]
): number {
return sumBy(groups, (group) =>
sumBy(group.items, (item) => item.referencingNodes.length)
)
}

View File

@@ -16,6 +16,10 @@ import {
groupCandidatesByName,
groupCandidatesByMediaType
} from './missingMediaScan'
import {
countMissingMediaReferences,
getMissingMediaReferences
} from './missingMediaGrouping'
import type { MissingMediaCandidate } from './types'
const { mockGetInputAssetsIncludingPublic, mockGetAssetsPageByTag } =
@@ -421,6 +425,11 @@ describe('groupCandidatesByName', () => {
const photoGroup = result.find((g) => g.name === 'photo.png')
expect(photoGroup?.referencingNodes).toHaveLength(2)
expect(photoGroup?.referencingNodes[0]).toMatchObject({
nodeId: '1',
nodeType: 'LoadImage',
widgetName: 'image'
})
expect(photoGroup?.mediaType).toBe('image')
expect(photoGroup?.representative.nodeType).toBe('LoadImage')
@@ -487,6 +496,27 @@ describe('groupCandidatesByMediaType', () => {
})
})
describe('missing media references', () => {
it('flattens references without deduping shared filenames', () => {
const groups = groupCandidatesByMediaType([
makeCandidate('1', 'shared.png'),
makeCandidate('2', 'shared.png'),
makeCandidate('3', 'other.png')
])
expect(groups).toHaveLength(1)
expect(groups[0].items).toHaveLength(2)
expect(countMissingMediaReferences(groups)).toBe(3)
expect(
getMissingMediaReferences(groups).map(({ nodeRef }) => nodeRef)
).toEqual([
expect.objectContaining({ nodeId: '1' }),
expect.objectContaining({ nodeId: '2' }),
expect.objectContaining({ nodeId: '3' })
])
})
})
describe('verifyMediaCandidates', () => {
const existingHash =
'blake3:1111111111111111111111111111111111111111111111111111111111111111'

View File

@@ -256,6 +256,7 @@ export function groupCandidatesByName(
if (existing) {
existing.referencingNodes.push({
nodeId: c.nodeId,
nodeType: c.nodeType,
widgetName: c.widgetName
})
} else {
@@ -263,7 +264,9 @@ export function groupCandidatesByName(
name: c.name,
mediaType: c.mediaType,
representative: c,
referencingNodes: [{ nodeId: c.nodeId, widgetName: c.widgetName }]
referencingNodes: [
{ nodeId: c.nodeId, nodeType: c.nodeType, widgetName: c.widgetName }
]
})
}
}

View File

@@ -67,18 +67,12 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('clearMissingMedia resets all state including interaction state', () => {
it('clearMissingMedia resets candidates and aborts verification', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
const controller = store.createVerificationAbortController()
store.clearMissingMedia()
@@ -87,9 +81,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
expect(store.missingMediaCount).toBe(0)
expect(controller.signal.aborted).toBe(true)
expect(store.expandState).toEqual({})
expect(store.uploadState).toEqual({})
expect(store.pendingSelection).toEqual({})
})
it('missingMediaNodeIds tracks unique node IDs', () => {
@@ -145,47 +136,6 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
})
it('removeMissingMediaByName clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByName('photo.png')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget clears interaction state for removed name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([makeCandidate('1', 'photo.png')])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('removeMissingMediaByWidget preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByWidget('1', 'image')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('createVerificationAbortController aborts previous controller', () => {
const store = useMissingMediaStore()
const first = store.createVerificationAbortController()
@@ -264,40 +214,6 @@ describe('useMissingMediaStore', () => {
expect(store.hasMissingMedia).toBe(false)
})
it('cleans interaction state for removed names', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'clip.mp4', 'video')
])
store.expandState['photo.png'] = true
store.uploadState['photo.png'] = {
fileName: 'photo.png',
status: 'uploaded'
}
store.pendingSelection['photo.png'] = 'uploaded/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.expandState['photo.png']).toBeUndefined()
expect(store.uploadState['photo.png']).toBeUndefined()
expect(store.pendingSelection['photo.png']).toBeUndefined()
})
it('preserves interaction state when other candidates share the name', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('1', 'photo.png'),
makeCandidate('2', 'photo.png')
])
store.pendingSelection['photo.png'] = 'library/photo.png'
store.removeMissingMediaByNodeId('1')
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.pendingSelection['photo.png']).toBe('library/photo.png')
})
it('does nothing when candidates are null', () => {
const store = useMissingMediaStore()
store.removeMissingMediaByNodeId('1')
@@ -397,21 +313,5 @@ describe('useMissingMediaStore', () => {
expect(store.missingMediaCandidates).toHaveLength(1)
expect(store.missingMediaCandidates![0].name).toBe('orphan.png')
})
it('clears interaction state for removed names not used elsewhere', () => {
const store = useMissingMediaStore()
store.setMissingMedia([
makeCandidate('65:70:63', 'shared.png'),
makeCandidate('65:80:5', 'shared.png'),
makeCandidate('65:70:64', 'only-interior.png')
])
store.pendingSelection['shared.png'] = 'library/shared.png'
store.pendingSelection['only-interior.png'] = 'library/interior.png'
store.removeMissingMediaByPrefix('65:70:')
expect(store.pendingSelection['only-interior.png']).toBeUndefined()
expect(store.pendingSelection['shared.png']).toBe('library/shared.png')
})
})
})

View File

@@ -56,14 +56,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
)
})
// Interaction state — persists across component re-mounts
const expandState = ref<Record<string, boolean>>({})
const uploadState = ref<
Record<string, { fileName: string; status: 'uploading' | 'uploaded' }>
>({})
/** Pending selection: value to apply on confirm. */
const pendingSelection = ref<Record<string, string>>({})
let _verificationAbortController: AbortController | null = null
function createVerificationAbortController(): AbortController {
@@ -84,58 +76,20 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
return activeMissingMediaGraphIds.value.has(String(node.id))
}
function clearInteractionStateForName(name: string) {
delete expandState.value[name]
delete uploadState.value[name]
delete pendingSelection.value[name]
}
function removeMissingMediaByName(name: string) {
if (!missingMediaCandidates.value) return
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => m.name !== name
)
clearInteractionStateForName(name)
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByWidget(nodeId: string, widgetName: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter(
(m) => String(m.nodeId) === nodeId && m.widgetName === widgetName
)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => !(String(m.nodeId) === nodeId && m.widgetName === widgetName)
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
function removeMissingMediaByNodeId(nodeId: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set(
missingMediaCandidates.value
.filter((m) => String(m.nodeId) === nodeId)
.map((m) => m.name)
)
missingMediaCandidates.value = missingMediaCandidates.value.filter(
(m) => String(m.nodeId) !== nodeId
)
for (const name of removedNames) {
if (!missingMediaCandidates.value.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
if (!missingMediaCandidates.value.length)
missingMediaCandidates.value = null
}
@@ -150,7 +104,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
*/
function removeMissingMediaByPrefix(prefix: string) {
if (!missingMediaCandidates.value) return
const removedNames = new Set<string>()
const remaining: MissingMediaCandidate[] = []
for (const m of missingMediaCandidates.value) {
// Preserve candidates without a nodeId; they cannot belong to any
@@ -160,19 +113,12 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
remaining.push(m)
continue
}
if (String(m.nodeId).startsWith(prefix)) {
removedNames.add(m.name)
} else {
if (!String(m.nodeId).startsWith(prefix)) {
remaining.push(m)
}
}
if (removedNames.size === 0) return
if (remaining.length === missingMediaCandidates.value.length) return
missingMediaCandidates.value = remaining.length ? remaining : null
for (const name of removedNames) {
if (!remaining.some((m) => m.name === name)) {
clearInteractionStateForName(name)
}
}
}
function addMissingMedia(media: MissingMediaCandidate[]) {
@@ -193,9 +139,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
_verificationAbortController?.abort()
_verificationAbortController = null
missingMediaCandidates.value = null
expandState.value = {}
uploadState.value = {}
pendingSelection.value = {}
}
return {
@@ -208,7 +151,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
setMissingMedia,
addMissingMedia,
removeMissingMediaByName,
removeMissingMediaByWidget,
removeMissingMediaByNodeId,
removeMissingMediaByPrefix,
@@ -216,10 +158,6 @@ export const useMissingMediaStore = defineStore('missingMedia', () => {
createVerificationAbortController,
hasMissingMediaOnNode,
isContainerWithMissingMedia,
expandState,
uploadState,
pendingSelection
isContainerWithMissingMedia
}
})

View File

@@ -30,6 +30,7 @@ export interface MissingMediaViewModel {
representative: MissingMediaCandidate
referencingNodes: Array<{
nodeId: NodeId
nodeType?: string
widgetName: string
}>
}

View File

@@ -1,8 +1,9 @@
<template>
<Dialog v-model:open="visible">
<DialogPortal>
<DialogOverlay />
<DialogOverlay v-reka-z-index />
<DialogContent
v-reka-z-index
size="md"
:aria-labelledby="titleId"
@pointer-down-outside.prevent
@@ -125,6 +126,7 @@ import DialogHeader from '@/components/ui/dialog/DialogHeader.vue'
import DialogOverlay from '@/components/ui/dialog/DialogOverlay.vue'
import DialogPortal from '@/components/ui/dialog/DialogPortal.vue'
import DialogTitle from '@/components/ui/dialog/DialogTitle.vue'
import { vRekaZIndex } from '@/components/dialog/vRekaZIndex'
import Select from '@/components/ui/select/Select.vue'
import SelectContent from '@/components/ui/select/SelectContent.vue'
import SelectItem from '@/components/ui/select/SelectItem.vue'

View File

@@ -0,0 +1,77 @@
import { ZIndex } from '@primeuix/utils/zindex'
import { cleanup, render, screen } from '@testing-library/vue'
import PrimeVue from 'primevue/config'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import SecretFormDialog from './SecretFormDialog.vue'
vi.mock('../composables/useSecretForm', () => ({
useSecretForm: () => ({
form: { provider: '', name: '', secretValue: '' },
errors: {},
loading: false,
apiError: '',
providerOptions: [],
handleSubmit: vi.fn()
})
}))
vi.mock('primevue/inputtext', () => ({
default: { name: 'InputText', template: '<input />' }
}))
vi.mock('primevue/password', () => ({
default: { name: 'Password', template: '<input type="password" />' }
}))
vi.mock('@/components/ui/button/Button.vue', () => ({
default: { name: 'Button', template: '<button><slot /></button>' }
}))
vi.mock('@/components/ui/select/Select.vue', () => ({
default: { name: 'Select', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectContent.vue', () => ({
default: { name: 'SelectContent', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectItem.vue', () => ({
default: { name: 'SelectItem', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectTrigger.vue', () => ({
default: { name: 'SelectTrigger', template: '<div><slot /></div>' }
}))
vi.mock('@/components/ui/select/SelectValue.vue', () => ({
default: { name: 'SelectValue', template: '<span />' }
}))
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: {} },
missingWarn: false,
fallbackWarn: false
})
describe('SecretFormDialog z-index stacking', () => {
afterEach(() => {
cleanup()
})
let openModalZIndex: number
beforeEach(() => {
const openModal = document.createElement('div')
ZIndex.set('modal', openModal, 1700)
openModalZIndex = Number(openModal.style.zIndex)
})
it('renders above a modal that is already open', async () => {
render(SecretFormDialog, {
global: { plugins: [PrimeVue, i18n] },
props: { visible: true }
})
const content = await screen.findByRole('dialog')
expect(Number(content.style.zIndex)).toBeGreaterThan(openModalZIndex)
})
})

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'

View File

@@ -152,8 +152,7 @@ export const useExecutionErrorStore = defineStore('executionError', () => {
* Clears both validation errors and missing model state for a widget.
*
* @param errorInputName Name matched against `error.extra_info.input_name`.
* For promoted subgraph widgets this is the subgraph input slot name
* (`widget.slotName`), which differs from the interior widget name.
* For promoted subgraph widgets this is the resolved interior widget name.
* @param widgetName The actual widget name, used for missing model lookup.
* At the legacy canvas call site both names are identical (`widget.name`).
*/